videosays 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.
Files changed (3) hide show
  1. package/README.md +65 -0
  2. package/bin/videosays.js +446 -0
  3. package/package.json +32 -0
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # videosays
2
+
3
+ Videosays command-line tool for turning authorized videos into transcript text.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # Use directly
9
+ npx videosays setup
10
+
11
+ # Or install globally
12
+ npm install -g videosays
13
+ videosays setup
14
+ ```
15
+
16
+ Requires Node.js >= 18.
17
+
18
+ ## Quick Start
19
+
20
+ ```bash
21
+ # First use: register or log in, then save API key to ~/.videosays
22
+ videosays setup
23
+
24
+ # Transcribe a video you own or have permission to process
25
+ videosays transcribe "https://www.tiktok.com/@creator/video/123456"
26
+ ```
27
+
28
+ ## Commands
29
+
30
+ ```bash
31
+ videosays setup
32
+ videosays register
33
+ videosays login
34
+ videosays transcribe <video-link-or-share-text> [language]
35
+ videosays status <taskId>
36
+ videosays balance
37
+ videosays history [limit]
38
+ videosays help
39
+ ```
40
+
41
+ ## Configuration
42
+
43
+ The API key is saved to `~/.videosays` by default.
44
+
45
+ Environment variables:
46
+
47
+ ```bash
48
+ export VIDEOSAYS_API_KEY="vs_xxxxx"
49
+ export VIDEOSAYS_API_URL="https://api.videosays.com"
50
+ ```
51
+
52
+ For registration and login, the CLI uses Videosays' public Supabase auth configuration. You can override it if needed:
53
+
54
+ ```bash
55
+ export VIDEOSAYS_SUPABASE_URL="https://your-project.supabase.co"
56
+ export VIDEOSAYS_SUPABASE_ANON_KEY="your_supabase_anon_key"
57
+ ```
58
+
59
+ ## Compliance Note
60
+
61
+ Only submit videos you own, created, or have permission to process. Videosays does not provide video downloading, watermark removal, or redistribution.
62
+
63
+ ## License
64
+
65
+ MIT
@@ -0,0 +1,446 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { createInterface } from 'node:readline';
7
+
8
+ const VERSION = '1.0.0';
9
+ const API_URL = (process.env.VIDEOSAYS_API_URL || 'https://api.videosays.com').replace(/\/$/, '');
10
+ const CONFIG_FILE = join(homedir(), '.videosays');
11
+
12
+ const SUPABASE_URL = process.env.VIDEOSAYS_SUPABASE_URL || 'https://wcedjbnfdlfnomzwtwlq.supabase.co';
13
+ const SUPABASE_ANON_KEY = process.env.VIDEOSAYS_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndjZWRqYm5mZGxmbm9tend0d2xxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzU1NjQ3NTQsImV4cCI6MjA5MTE0MDc1NH0.7yrhVx_huGDMzGllUivfyayEC7MnTrUzHUb5aVLFHLg';
14
+
15
+ const colors = {
16
+ red: (s) => `\x1b[0;31m${s}\x1b[0m`,
17
+ green: (s) => `\x1b[0;32m${s}\x1b[0m`,
18
+ yellow: (s) => `\x1b[1;33m${s}\x1b[0m`,
19
+ cyan: (s) => `\x1b[0;36m${s}\x1b[0m`,
20
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
21
+ };
22
+
23
+ function error(message) {
24
+ console.error(colors.red(`Error: ${message}`));
25
+ process.exit(1);
26
+ }
27
+
28
+ function success(message) {
29
+ console.log(colors.green(message));
30
+ }
31
+
32
+ function info(message) {
33
+ console.log(message);
34
+ }
35
+
36
+ function ask(question, { hidden = false } = {}) {
37
+ return new Promise((resolve) => {
38
+ const rl = createInterface({
39
+ input: process.stdin,
40
+ output: hidden ? undefined : process.stdout,
41
+ terminal: hidden,
42
+ });
43
+
44
+ rl.question(question, (answer) => {
45
+ rl.close();
46
+ resolve(answer.trim());
47
+ });
48
+ });
49
+ }
50
+
51
+ function loadConfig() {
52
+ if (process.env.VIDEOSAYS_API_KEY) return process.env.VIDEOSAYS_API_KEY;
53
+ if (!existsSync(CONFIG_FILE)) return null;
54
+
55
+ try {
56
+ const content = readFileSync(CONFIG_FILE, 'utf-8');
57
+ const match = content.match(/^VIDEOSAYS_API_KEY=(.+)$/m);
58
+ return match ? match[1].trim() : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function saveConfig(apiKey) {
65
+ writeFileSync(CONFIG_FILE, `VIDEOSAYS_API_KEY=${apiKey}\n`, 'utf-8');
66
+ try {
67
+ chmodSync(CONFIG_FILE, 0o600);
68
+ } catch {
69
+ // chmod is not available on every platform.
70
+ }
71
+ }
72
+
73
+ function getApiKey() {
74
+ const apiKey = loadConfig();
75
+ if (!apiKey) {
76
+ error(`Missing API key.
77
+
78
+ Run ${colors.bold('videosays setup')} to register or log in.
79
+ Or set: export VIDEOSAYS_API_KEY="your_api_key"`);
80
+ }
81
+ return apiKey;
82
+ }
83
+
84
+ function requireSupabasePublicConfig() {
85
+ if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
86
+ error('Registration and login require Supabase public configuration.');
87
+ }
88
+ }
89
+
90
+ async function apiCall(method, path, body) {
91
+ const apiKey = getApiKey();
92
+ const options = {
93
+ method,
94
+ headers: {
95
+ 'Content-Type': 'application/json',
96
+ 'X-API-Key': apiKey,
97
+ },
98
+ };
99
+
100
+ if (body) options.body = JSON.stringify(body);
101
+
102
+ const response = await fetch(`${API_URL}${path}`, options).catch(() => {
103
+ error('Request failed. Check your network connection.');
104
+ });
105
+
106
+ const data = await response.json().catch(() => {
107
+ error('Service returned a non-JSON response.');
108
+ });
109
+
110
+ if (!response.ok || data.error) {
111
+ error(data.error || `Request failed (${response.status})`);
112
+ }
113
+
114
+ return data;
115
+ }
116
+
117
+ async function supabaseRequest(endpoint, body) {
118
+ requireSupabasePublicConfig();
119
+
120
+ const response = await fetch(`${SUPABASE_URL}${endpoint}`, {
121
+ method: 'POST',
122
+ headers: {
123
+ apikey: SUPABASE_ANON_KEY,
124
+ 'Content-Type': 'application/json',
125
+ },
126
+ body: JSON.stringify(body),
127
+ }).catch(() => {
128
+ error('Authentication request failed. Check your network connection.');
129
+ });
130
+
131
+ return response.json();
132
+ }
133
+
134
+ async function fetchApiKey(jwt) {
135
+ requireSupabasePublicConfig();
136
+
137
+ const response = await fetch(`${SUPABASE_URL}/rest/v1/profiles?select=api_key`, {
138
+ headers: {
139
+ apikey: SUPABASE_ANON_KEY,
140
+ Authorization: `Bearer ${jwt}`,
141
+ },
142
+ }).catch(() => {
143
+ error('Failed to fetch API key. Check your network connection.');
144
+ });
145
+
146
+ const data = await response.json().catch(() => null);
147
+ return data?.[0]?.api_key || null;
148
+ }
149
+
150
+ async function doRegister(email, password) {
151
+ info('Registering...');
152
+ const response = await supabaseRequest('/auth/v1/signup', { email, password });
153
+
154
+ if (response.msg) error(`Registration failed: ${response.msg}`);
155
+
156
+ if (!response.access_token) {
157
+ success('Registration created. Please confirm your email.');
158
+ info('');
159
+ info(`After confirmation, run: ${colors.bold('videosays login')}`);
160
+ process.exit(0);
161
+ }
162
+
163
+ const apiKey = await fetchApiKey(response.access_token);
164
+ if (!apiKey) error(`Registered, but API key was not ready. Try ${colors.bold('videosays login')} in a moment.`);
165
+
166
+ saveConfig(apiKey);
167
+ success('Registered.');
168
+ success(`API key saved to ${CONFIG_FILE}`);
169
+ return apiKey;
170
+ }
171
+
172
+ async function doLogin(email, password) {
173
+ info('Logging in...');
174
+ const response = await supabaseRequest('/auth/v1/token?grant_type=password', { email, password });
175
+
176
+ if (response.msg) error(`Login failed: ${response.msg}`);
177
+ if (!response.access_token) error('Login failed. No access token returned.');
178
+
179
+ const apiKey = await fetchApiKey(response.access_token);
180
+ if (!apiKey) error('Logged in, but API key was not found.');
181
+
182
+ saveConfig(apiKey);
183
+ success('Logged in.');
184
+ success(`API key saved to ${CONFIG_FILE}`);
185
+ return apiKey;
186
+ }
187
+
188
+ async function cmdSetup() {
189
+ info(colors.bold('videosays - account setup'));
190
+ console.log('');
191
+ console.log(' 1) Register a new account');
192
+ console.log(' 2) Log in to an existing account');
193
+ console.log('');
194
+
195
+ const choice = await ask(colors.cyan('Choose [1/2]: '));
196
+ if (choice === '1') return cmdRegister();
197
+ if (choice === '2') return cmdLogin();
198
+ error('Invalid choice.');
199
+ }
200
+
201
+ async function cmdRegister() {
202
+ const email = await ask('Email: ');
203
+ if (!email) error('Email is required.');
204
+
205
+ const password = await ask('Password (at least 6 characters): ', { hidden: true });
206
+ console.log('');
207
+ if (!password || password.length < 6) error('Password must be at least 6 characters.');
208
+
209
+ const passwordAgain = await ask('Confirm password: ', { hidden: true });
210
+ console.log('');
211
+ if (password !== passwordAgain) error('Passwords do not match.');
212
+
213
+ const apiKey = await doRegister(email, password);
214
+ if (apiKey) {
215
+ console.log('');
216
+ await cmdBalance();
217
+ }
218
+ }
219
+
220
+ async function cmdLogin() {
221
+ const email = await ask('Email: ');
222
+ if (!email) error('Email is required.');
223
+
224
+ const password = await ask('Password: ', { hidden: true });
225
+ console.log('');
226
+ if (!password) error('Password is required.');
227
+
228
+ const apiKey = await doLogin(email, password);
229
+ if (apiKey) {
230
+ console.log('');
231
+ await cmdBalance();
232
+ }
233
+ }
234
+
235
+ function getTaskText(task) {
236
+ return task?.result?.text ?? task?.resultText ?? '';
237
+ }
238
+
239
+ function getTaskError(task) {
240
+ return task?.error?.message ?? task?.errorMessage ?? 'Unknown error';
241
+ }
242
+
243
+ function getTaskDurationMinutes(task) {
244
+ const creditMinutes = task?.billing?.creditMinutes ?? task?.creditCost;
245
+ if (creditMinutes != null) return creditMinutes;
246
+ const durationSeconds = task?.video?.durationSeconds ?? task?.durationSeconds;
247
+ if (durationSeconds != null) return Math.ceil(durationSeconds / 6) / 10;
248
+ return null;
249
+ }
250
+
251
+ function printTaskSummary(task) {
252
+ const video = task.video ?? {};
253
+ if (video.platform) info(` Platform: ${video.platform}`);
254
+ if (video.author) info(` Author: ${video.author}`);
255
+ if (video.title) info(` Title: ${video.title}`);
256
+
257
+ const duration = getTaskDurationMinutes(task);
258
+ if (duration != null) info(` Billed duration: ${duration} minutes`);
259
+ }
260
+
261
+ async function cmdTranscribe(input, language = 'zh-CN') {
262
+ if (!input) {
263
+ error('Please provide a video link or share text.\n Usage: videosays transcribe <video-link-or-share-text> [language]');
264
+ }
265
+
266
+ getApiKey();
267
+
268
+ info(`Submitting transcription: ${colors.yellow(input.substring(0, 80))}`);
269
+ info(` Language: ${language}`);
270
+ info(' Submiting...');
271
+
272
+ const task = await apiCall('POST', '/api/v1/transcribe', { input, language });
273
+ const taskId = task.taskId || task.id;
274
+ if (!taskId) error('Task creation failed. No taskId returned.');
275
+
276
+ const timeoutSeconds = 300;
277
+ const intervalMs = 3000;
278
+ const startedAt = Date.now();
279
+
280
+ info(` Task ID: ${taskId}`);
281
+
282
+ if (task.status === 'completed') {
283
+ console.log('');
284
+ success('Transcription completed.');
285
+ printTaskSummary(task);
286
+ console.log('');
287
+ info(colors.bold('Transcript:'));
288
+ console.log(getTaskText(task) || '(empty)');
289
+ return;
290
+ }
291
+
292
+ while (Date.now() - startedAt < timeoutSeconds * 1000) {
293
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
294
+ const elapsed = Math.round((Date.now() - startedAt) / 1000);
295
+ const poll = await apiCall('GET', `/api/v1/transcribe/${taskId}`);
296
+
297
+ if (poll.status === 'completed') {
298
+ console.log('');
299
+ success('Transcription completed.');
300
+ printTaskSummary(poll);
301
+ console.log('');
302
+ info(colors.bold('Transcript:'));
303
+ console.log(getTaskText(poll) || '(empty)');
304
+ return;
305
+ }
306
+
307
+ if (poll.status === 'failed') {
308
+ error(`Transcription failed: ${getTaskError(poll)}`);
309
+ }
310
+
311
+ info(` Waiting... (${elapsed}s, status: ${poll.status})`);
312
+ }
313
+
314
+ error(`Transcription timed out after ${timeoutSeconds}s. Task ID: ${taskId}`);
315
+ }
316
+
317
+ async function cmdStatus(taskId) {
318
+ if (!taskId) error('Please provide taskId.\n Usage: videosays status <taskId>');
319
+
320
+ const task = await apiCall('GET', `/api/v1/transcribe/${taskId}`);
321
+ success('Task status');
322
+ info(` taskId: ${task.taskId || task.id}`);
323
+ info(` status: ${task.status}`);
324
+ printTaskSummary(task);
325
+
326
+ if (task.error) info(` error: ${getTaskError(task)}`);
327
+
328
+ const text = getTaskText(task);
329
+ if (text) {
330
+ console.log('');
331
+ info(colors.bold('Transcript:'));
332
+ console.log(text);
333
+ }
334
+ }
335
+
336
+ async function cmdBalance() {
337
+ const response = await apiCall('GET', '/api/v1/credits');
338
+ success('Credit balance');
339
+ console.log(` Balance: ${response.balance}`);
340
+ console.log(` Reserved: ${response.reservedBalance ?? 0}`);
341
+ console.log(` Available: ${response.availableBalance ?? response.balance}`);
342
+ console.log(` Purchased: ${response.totalPurchased ?? 0}`);
343
+ console.log(` Used: ${response.totalUsed ?? 0}`);
344
+ }
345
+
346
+ async function cmdHistory(limit = 10) {
347
+ info('Fetching transcription history...');
348
+ const response = await apiCall('GET', '/api/v1/history');
349
+ const tasks = (response.tasks || []).slice(0, Math.max(1, limit));
350
+
351
+ console.log('');
352
+ info(`Showing recent ${tasks.length} task(s):`);
353
+ console.log('');
354
+
355
+ if (tasks.length === 0) {
356
+ info(' No history yet.');
357
+ return;
358
+ }
359
+
360
+ for (const task of tasks) {
361
+ const status = task.status === 'completed' ? colors.green('done') : task.status === 'failed' ? colors.red('fail') : task.status;
362
+ const date = task.createdAt ? new Date(task.createdAt).toLocaleString() : '';
363
+ const title = task.video?.title || task.input || '';
364
+ const oneLineTitle = title.replace(/\s+/g, ' ').substring(0, 48);
365
+ const platform = task.video?.platform ? `[${task.video.platform}]` : '';
366
+ console.log(` ${status.padEnd(14)} ${platform.padEnd(14)} ${oneLineTitle.padEnd(50)} ${date}`);
367
+ }
368
+ }
369
+
370
+ function showHelp() {
371
+ console.log(`videosays v${VERSION}
372
+
373
+ Usage:
374
+ videosays setup
375
+ videosays register
376
+ videosays login
377
+ videosays transcribe <video-link-or-share-text> [language]
378
+ videosays status <taskId>
379
+ videosays balance
380
+ videosays history [limit]
381
+ videosays help
382
+
383
+ Shortcut:
384
+ videosays "<video-link-or-share-text>"
385
+
386
+ Configuration:
387
+ API key file: ~/.videosays
388
+ VIDEOSAYS_API_KEY API key, preferred over config file
389
+ VIDEOSAYS_API_URL API URL (default: https://api.videosays.com)
390
+ VIDEOSAYS_SUPABASE_URL Supabase URL for setup/login
391
+ VIDEOSAYS_SUPABASE_ANON_KEY Supabase anon key for setup/login
392
+
393
+ Examples:
394
+ videosays setup
395
+ videosays transcribe "https://www.tiktok.com/@creator/video/123456"
396
+ videosays transcribe "https://v.douyin.com/xxxxx/" zh-CN
397
+ videosays status 123e4567-e89b-12d3-a456-426614174000
398
+ videosays balance
399
+ videosays history 20
400
+
401
+ Only submit videos you own, created, or have permission to process.
402
+
403
+ Website: https://videosays.com
404
+ API: ${API_URL}`);
405
+ }
406
+
407
+ const args = process.argv.slice(2);
408
+ const command = args[0];
409
+
410
+ switch (command) {
411
+ case 'setup':
412
+ await cmdSetup();
413
+ break;
414
+ case 'register':
415
+ await cmdRegister();
416
+ break;
417
+ case 'login':
418
+ await cmdLogin();
419
+ break;
420
+ case 'transcribe':
421
+ case 'caption':
422
+ await cmdTranscribe(args[1], args[2]);
423
+ break;
424
+ case 'status':
425
+ await cmdStatus(args[1]);
426
+ break;
427
+ case 'balance':
428
+ case 'credits':
429
+ await cmdBalance();
430
+ break;
431
+ case 'history':
432
+ await cmdHistory(parseInt(args[1], 10) || 10);
433
+ break;
434
+ case 'help':
435
+ case '--help':
436
+ case '-h':
437
+ showHelp();
438
+ break;
439
+ default:
440
+ if (command) {
441
+ await cmdTranscribe(command, args[1]);
442
+ } else {
443
+ showHelp();
444
+ }
445
+ break;
446
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "videosays",
3
+ "version": "1.0.0",
4
+ "description": "Videosays CLI - authorized video transcription from the command line",
5
+ "type": "module",
6
+ "bin": {
7
+ "videosays": "./bin/videosays.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18.0.0"
15
+ },
16
+ "keywords": [
17
+ "videosays",
18
+ "video",
19
+ "transcription",
20
+ "speech-to-text",
21
+ "caption",
22
+ "cli"
23
+ ],
24
+ "author": "xwchris",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/xwchris/videosays-agent-tools.git",
29
+ "directory": "packages/cli"
30
+ },
31
+ "homepage": "https://videosays.com"
32
+ }