aigc-vault-x-video-service 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AIGC Vault
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # AIGC Vault X Video Service
2
+
3
+ Local helper service for AIGC Vault. It resolves X/Twitter status URLs with `yt-dlp`, downloads MP4 files to the user's machine, and exposes local file URLs for the browser extension.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ brew install node yt-dlp ffmpeg
9
+ npm install -g aigc-vault-x-video-service
10
+ ```
11
+
12
+ ## Start
13
+
14
+ ```bash
15
+ aigc-vault-x-video-service
16
+ ```
17
+
18
+ The default service URL is:
19
+
20
+ ```txt
21
+ http://127.0.0.1:8899
22
+ ```
23
+
24
+ The default download directory is:
25
+
26
+ ```txt
27
+ ~/Movies/AIGC-Vault-X-Videos
28
+ ```
29
+
30
+ ## Auto-start on macOS
31
+
32
+ ```bash
33
+ aigc-vault-x-video-service --install
34
+ ```
35
+
36
+ Remove auto-start:
37
+
38
+ ```bash
39
+ aigc-vault-x-video-service --uninstall
40
+ ```
41
+
42
+ ## Options
43
+
44
+ ```bash
45
+ aigc-vault-x-video-service --host 127.0.0.1 --port 8899
46
+ aigc-vault-x-video-service --download-dir ~/Movies/AIGC-Vault-X-Videos
47
+ ```
48
+
49
+ If you want another computer on the same network to access this service, start it with a LAN host such as:
50
+
51
+ ```bash
52
+ aigc-vault-x-video-service --host 0.0.0.0
53
+ ```
54
+
55
+ Then set the AIGC Vault extension service URL to that computer's LAN address, for example:
56
+
57
+ ```txt
58
+ http://192.168.1.23:8899
59
+ ```
60
+
61
+ ## API
62
+
63
+ ### GET `/health`
64
+
65
+ Returns service status.
66
+
67
+ ### POST `/resolve-x-video`
68
+
69
+ Body:
70
+
71
+ ```json
72
+ {
73
+ "url": "https://x.com/user/status/123",
74
+ "download": true
75
+ }
76
+ ```
77
+
78
+ Response:
79
+
80
+ ```json
81
+ {
82
+ "success": true,
83
+ "videoUrl": "http://127.0.0.1:8899/files/123.mp4",
84
+ "filePath": "/Users/name/Movies/AIGC-Vault-X-Videos/123.mp4",
85
+ "fileName": "123.mp4"
86
+ }
87
+ ```
88
+
89
+ Only `x.com` / `twitter.com` status URLs are accepted.
90
+
91
+ ## Notes
92
+
93
+ This service uses:
94
+
95
+ ```bash
96
+ yt-dlp --cookies-from-browser chrome -S "res,ext:mp4:m4a" --merge-output-format mp4
97
+ ```
98
+
99
+ Chrome may ask for macOS Keychain permission when `yt-dlp` reads browser cookies. Enter the Mac login password and allow access.
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+
3
+ import http from 'node:http';
4
+ import { spawn, execFileSync } from 'node:child_process';
5
+ import { createReadStream, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
6
+ import { mkdir, readdir, stat } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+
10
+ const SERVICE_LABEL = 'com.aigcvault.x-video-service';
11
+ const DEFAULT_HOST = '127.0.0.1';
12
+ const DEFAULT_PORT = 8899;
13
+ const DEFAULT_DOWNLOAD_DIR = path.join(os.homedir(), 'Movies', 'AIGC-Vault-X-Videos');
14
+
15
+ function parseArgs(argv) {
16
+ const args = {
17
+ command: 'serve',
18
+ host: process.env.X_VIDEO_SERVICE_HOST || DEFAULT_HOST,
19
+ port: Number(process.env.X_VIDEO_SERVICE_PORT || DEFAULT_PORT),
20
+ downloadDir: process.env.X_VIDEO_DOWNLOAD_DIR || DEFAULT_DOWNLOAD_DIR,
21
+ };
22
+
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const arg = argv[i];
25
+ if (arg === 'install' || arg === '--install') args.command = 'install';
26
+ else if (arg === 'uninstall' || arg === '--uninstall') args.command = 'uninstall';
27
+ else if (arg === 'help' || arg === '--help' || arg === '-h') args.command = 'help';
28
+ else if (arg === '--host') args.host = argv[++i] || args.host;
29
+ else if (arg.startsWith('--host=')) args.host = arg.slice('--host='.length);
30
+ else if (arg === '--port') args.port = Number(argv[++i] || args.port);
31
+ else if (arg.startsWith('--port=')) args.port = Number(arg.slice('--port='.length));
32
+ else if (arg === '--download-dir') args.downloadDir = argv[++i] || args.downloadDir;
33
+ else if (arg.startsWith('--download-dir=')) args.downloadDir = arg.slice('--download-dir='.length);
34
+ }
35
+
36
+ args.downloadDir = path.resolve(args.downloadDir);
37
+ if (!Number.isFinite(args.port) || args.port <= 0) args.port = DEFAULT_PORT;
38
+ return args;
39
+ }
40
+
41
+ function printHelp() {
42
+ console.log(`AIGC Vault X video service
43
+
44
+ Usage:
45
+ aigc-vault-x-video-service
46
+ aigc-vault-x-video-service --host 127.0.0.1 --port 8899
47
+ aigc-vault-x-video-service --download-dir ~/Movies/AIGC-Vault-X-Videos
48
+ aigc-vault-x-video-service --install
49
+ aigc-vault-x-video-service --uninstall
50
+
51
+ Prerequisites:
52
+ brew install yt-dlp ffmpeg
53
+
54
+ Endpoints:
55
+ GET /health
56
+ POST /resolve-x-video
57
+ GET /files/<filename>.mp4
58
+ `);
59
+ }
60
+
61
+ function corsHeaders() {
62
+ return {
63
+ 'Access-Control-Allow-Origin': '*',
64
+ 'Access-Control-Allow-Headers': 'content-type',
65
+ 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
66
+ };
67
+ }
68
+
69
+ function sendJson(res, status, data) {
70
+ res.writeHead(status, {
71
+ ...corsHeaders(),
72
+ 'content-type': 'application/json; charset=utf-8',
73
+ });
74
+ res.end(JSON.stringify(data));
75
+ }
76
+
77
+ function isAllowedStatusUrl(value = '') {
78
+ try {
79
+ const parsed = new URL(String(value));
80
+ const host = parsed.hostname.toLowerCase();
81
+ return ['x.com', 'www.x.com', 'twitter.com', 'www.twitter.com'].includes(host)
82
+ && /^\/[^/]+\/status\/\d+/.test(parsed.pathname);
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ function readJsonBody(req) {
89
+ return new Promise((resolve, reject) => {
90
+ let raw = '';
91
+ req.on('data', chunk => {
92
+ raw += chunk;
93
+ if (raw.length > 16 * 1024) {
94
+ reject(new Error('Request body is too large'));
95
+ req.destroy();
96
+ }
97
+ });
98
+ req.on('end', () => {
99
+ if (!raw.trim()) return resolve({});
100
+ try {
101
+ resolve(JSON.parse(raw));
102
+ } catch {
103
+ reject(new Error('Request body is not valid JSON'));
104
+ }
105
+ });
106
+ req.on('error', reject);
107
+ });
108
+ }
109
+
110
+ function runYtDlp(url, downloadDir) {
111
+ return new Promise((resolve, reject) => {
112
+ const outputTemplate = path.join(downloadDir, '%(id)s.%(ext)s');
113
+ const args = [
114
+ '--cookies-from-browser',
115
+ 'chrome',
116
+ '-S',
117
+ 'res,ext:mp4:m4a',
118
+ '--merge-output-format',
119
+ 'mp4',
120
+ '-o',
121
+ outputTemplate,
122
+ url,
123
+ ];
124
+ const child = spawn('yt-dlp', args, { stdio: ['ignore', 'pipe', 'pipe'] });
125
+ let stdout = '';
126
+ let stderr = '';
127
+
128
+ child.stdout.on('data', chunk => { stdout += chunk.toString(); });
129
+ child.stderr.on('data', chunk => { stderr += chunk.toString(); });
130
+ child.on('error', error => {
131
+ reject(new Error(`Failed to start yt-dlp: ${error.message}`));
132
+ });
133
+ child.on('close', code => {
134
+ if (code === 0) return resolve({ stdout, stderr });
135
+ reject(new Error((stderr || stdout || `yt-dlp exited with code ${code}`).trim()));
136
+ });
137
+ });
138
+ }
139
+
140
+ async function findNewestMp4(downloadDir, beforeMs) {
141
+ const entries = await readdir(downloadDir, { withFileTypes: true });
142
+ const files = [];
143
+ for (const entry of entries) {
144
+ if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== '.mp4') continue;
145
+ const filePath = path.join(downloadDir, entry.name);
146
+ const info = await stat(filePath);
147
+ if (info.mtimeMs + 2000 >= beforeMs) {
148
+ files.push({ name: entry.name, path: filePath, mtimeMs: info.mtimeMs });
149
+ }
150
+ }
151
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
152
+ return files[0] || null;
153
+ }
154
+
155
+ async function handleResolve(req, res, config) {
156
+ try {
157
+ const body = await readJsonBody(req);
158
+ const url = String(body.url || '').trim();
159
+ if (!isAllowedStatusUrl(url)) {
160
+ return sendJson(res, 400, { success: false, error: 'Only x.com / twitter.com status URLs are allowed' });
161
+ }
162
+
163
+ await mkdir(config.downloadDir, { recursive: true });
164
+ const startedAt = Date.now();
165
+ await runYtDlp(url, config.downloadDir);
166
+ const file = await findNewestMp4(config.downloadDir, startedAt);
167
+ if (!file) {
168
+ return sendJson(res, 500, { success: false, error: 'Download finished, but no MP4 file was found' });
169
+ }
170
+
171
+ sendJson(res, 200, {
172
+ success: true,
173
+ videoUrl: `http://${config.host}:${config.port}/files/${encodeURIComponent(file.name)}`,
174
+ filePath: file.path,
175
+ fileName: file.name,
176
+ });
177
+ } catch (error) {
178
+ sendJson(res, 500, { success: false, error: error?.message || String(error) });
179
+ }
180
+ }
181
+
182
+ async function handleFile(req, res, pathname, config) {
183
+ const encodedName = pathname.slice('/files/'.length);
184
+ const decodedName = decodeURIComponent(encodedName);
185
+ const fileName = path.basename(decodedName);
186
+ if (!fileName || fileName !== decodedName || path.extname(fileName).toLowerCase() !== '.mp4') {
187
+ return sendJson(res, 400, { success: false, error: 'Invalid filename' });
188
+ }
189
+
190
+ const filePath = path.join(config.downloadDir, fileName);
191
+ try {
192
+ const info = await stat(filePath);
193
+ if (!info.isFile()) throw new Error('Not a file');
194
+ res.writeHead(200, {
195
+ ...corsHeaders(),
196
+ 'content-type': 'video/mp4',
197
+ 'content-length': info.size,
198
+ 'accept-ranges': 'bytes',
199
+ 'cache-control': 'public, max-age=3600',
200
+ });
201
+ createReadStream(filePath).pipe(res);
202
+ } catch {
203
+ sendJson(res, 404, { success: false, error: 'File not found' });
204
+ }
205
+ }
206
+
207
+ function startServer(config) {
208
+ const server = http.createServer(async (req, res) => {
209
+ res.setHeader('Access-Control-Allow-Origin', '*');
210
+ res.setHeader('Access-Control-Allow-Headers', 'content-type');
211
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
212
+
213
+ if (req.method === 'OPTIONS') {
214
+ res.writeHead(204, corsHeaders());
215
+ return res.end();
216
+ }
217
+
218
+ const parsed = new URL(req.url || '/', `http://${req.headers.host || `${config.host}:${config.port}`}`);
219
+ if (req.method === 'GET' && parsed.pathname === '/health') {
220
+ return sendJson(res, 200, {
221
+ success: true,
222
+ status: 'ok',
223
+ service: 'aigc-vault-x-video-service',
224
+ downloadDir: config.downloadDir,
225
+ });
226
+ }
227
+ if (req.method === 'POST' && parsed.pathname === '/resolve-x-video') {
228
+ return handleResolve(req, res, config);
229
+ }
230
+ if (req.method === 'GET' && parsed.pathname.startsWith('/files/')) {
231
+ return handleFile(req, res, parsed.pathname, config);
232
+ }
233
+ sendJson(res, 404, { success: false, error: 'Not Found' });
234
+ });
235
+
236
+ server.listen(config.port, config.host, () => {
237
+ console.log(`AIGC Vault X video service listening at http://${config.host}:${config.port}`);
238
+ console.log(`Download directory: ${config.downloadDir}`);
239
+ });
240
+ }
241
+
242
+ function findExecutable(name) {
243
+ const candidates = [
244
+ `/opt/homebrew/bin/${name}`,
245
+ `/usr/local/bin/${name}`,
246
+ `/usr/bin/${name}`,
247
+ ];
248
+ return candidates.find(existsSync) || name;
249
+ }
250
+
251
+ function installLaunchAgent(config) {
252
+ if (process.platform !== 'darwin') {
253
+ console.error('Auto-start install currently supports macOS only.');
254
+ process.exit(1);
255
+ }
256
+
257
+ const launchAgentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
258
+ const logDir = path.join(os.homedir(), 'Library', 'Logs');
259
+ const plistPath = path.join(launchAgentsDir, `${SERVICE_LABEL}.plist`);
260
+ const logPath = path.join(logDir, 'aigc-vault-x-video-service.log');
261
+ const errPath = path.join(logDir, 'aigc-vault-x-video-service.err.log');
262
+ const nodePath = findExecutable('node');
263
+ const currentScript = new URL(import.meta.url).pathname;
264
+ const command = [
265
+ JSON.stringify(nodePath),
266
+ JSON.stringify(currentScript),
267
+ '--host',
268
+ JSON.stringify(config.host),
269
+ '--port',
270
+ String(config.port),
271
+ '--download-dir',
272
+ JSON.stringify(config.downloadDir),
273
+ ].join(' ');
274
+
275
+ const escapedCommand = command.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
276
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
277
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
278
+ <plist version="1.0">
279
+ <dict>
280
+ <key>Label</key>
281
+ <string>${SERVICE_LABEL}</string>
282
+ <key>ProgramArguments</key>
283
+ <array>
284
+ <string>/bin/zsh</string>
285
+ <string>-lc</string>
286
+ <string>${escapedCommand}</string>
287
+ </array>
288
+ <key>RunAtLoad</key>
289
+ <true/>
290
+ <key>KeepAlive</key>
291
+ <true/>
292
+ <key>StandardOutPath</key>
293
+ <string>${logPath}</string>
294
+ <key>StandardErrorPath</key>
295
+ <string>${errPath}</string>
296
+ <key>EnvironmentVariables</key>
297
+ <dict>
298
+ <key>PATH</key>
299
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
300
+ </dict>
301
+ </dict>
302
+ </plist>
303
+ `;
304
+
305
+ mkdirSync(launchAgentsDir, { recursive: true });
306
+ mkdirSync(logDir, { recursive: true });
307
+ writeFileSync(plistPath, plist);
308
+
309
+ try {
310
+ execFileSync('launchctl', ['bootout', `gui/${process.getuid()}`, plistPath], { stdio: 'ignore' });
311
+ } catch {}
312
+
313
+ execFileSync('launchctl', ['bootstrap', `gui/${process.getuid()}`, plistPath], { stdio: 'inherit' });
314
+ execFileSync('launchctl', ['enable', `gui/${process.getuid()}/${SERVICE_LABEL}`], { stdio: 'inherit' });
315
+ execFileSync('launchctl', ['kickstart', '-k', `gui/${process.getuid()}/${SERVICE_LABEL}`], { stdio: 'inherit' });
316
+
317
+ console.log('AIGC Vault X video service auto-start is installed.');
318
+ console.log(`Service URL: http://${config.host}:${config.port}`);
319
+ console.log(`Config: ${plistPath}`);
320
+ console.log(`Log: ${logPath}`);
321
+ }
322
+
323
+ function uninstallLaunchAgent() {
324
+ if (process.platform !== 'darwin') {
325
+ console.error('Auto-start uninstall currently supports macOS only.');
326
+ process.exit(1);
327
+ }
328
+
329
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
330
+ try {
331
+ execFileSync('launchctl', ['bootout', `gui/${process.getuid()}`, plistPath], { stdio: 'inherit' });
332
+ } catch {}
333
+ try {
334
+ execFileSync('launchctl', ['disable', `gui/${process.getuid()}/${SERVICE_LABEL}`], { stdio: 'ignore' });
335
+ } catch {}
336
+ rmSync(plistPath, { force: true });
337
+ console.log('AIGC Vault X video service auto-start is removed.');
338
+ }
339
+
340
+ const config = parseArgs(process.argv.slice(2));
341
+
342
+ if (config.command === 'help') printHelp();
343
+ else if (config.command === 'install') installLaunchAgent(config);
344
+ else if (config.command === 'uninstall') uninstallLaunchAgent();
345
+ else startServer(config);
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "aigc-vault-x-video-service",
3
+ "version": "1.0.0",
4
+ "description": "Local X/Twitter video resolver service for AIGC Vault.",
5
+ "type": "module",
6
+ "bin": {
7
+ "aigc-vault-x-video-service": "bin/aigc-vault-x-video-service.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "start": "node bin/aigc-vault-x-video-service.mjs",
16
+ "check": "node --check bin/aigc-vault-x-video-service.mjs"
17
+ },
18
+ "keywords": [
19
+ "aigc-vault",
20
+ "x",
21
+ "twitter",
22
+ "yt-dlp",
23
+ "video"
24
+ ],
25
+ "author": "",
26
+ "license": "MIT",
27
+ "engines": {
28
+ "node": ">=18"
29
+ }
30
+ }