neoagent 2.3.1-beta.86 → 2.3.1-beta.87

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 (38) hide show
  1. package/docs/capabilities.md +2 -0
  2. package/flutter_app/android/app/src/main/AndroidManifest.xml +14 -0
  3. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +84 -0
  4. package/flutter_app/lib/main_chat.dart +156 -2
  5. package/flutter_app/lib/main_controller.dart +137 -10
  6. package/flutter_app/lib/main_models.dart +69 -0
  7. package/flutter_app/lib/main_operations.dart +248 -0
  8. package/flutter_app/lib/main_runtime.dart +11 -2
  9. package/flutter_app/lib/main_settings.dart +173 -176
  10. package/flutter_app/lib/main_shared.dart +78 -0
  11. package/flutter_app/lib/src/app_launch_bridge.dart +39 -10
  12. package/flutter_app/lib/src/backend_client.dart +28 -0
  13. package/package.json +1 -1
  14. package/server/http/routes.js +1 -0
  15. package/server/public/.last_build_id +1 -1
  16. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  17. package/server/public/flutter_bootstrap.js +1 -1
  18. package/server/public/main.dart.js +69936 -69277
  19. package/server/routes/memory.js +90 -0
  20. package/server/routes/social_video.js +62 -0
  21. package/server/services/ai/systemPrompt.js +1 -0
  22. package/server/services/ai/toolResult.js +20 -0
  23. package/server/services/ai/tools.js +29 -0
  24. package/server/services/manager.js +15 -0
  25. package/server/services/memory/llm_transfer.js +217 -0
  26. package/server/services/social_video/adapters/base.js +26 -0
  27. package/server/services/social_video/adapters/index.js +27 -0
  28. package/server/services/social_video/adapters/instagram.js +17 -0
  29. package/server/services/social_video/adapters/tiktok.js +17 -0
  30. package/server/services/social_video/adapters/x.js +17 -0
  31. package/server/services/social_video/adapters/youtube.js +17 -0
  32. package/server/services/social_video/captions.js +187 -0
  33. package/server/services/social_video/frame.js +42 -0
  34. package/server/services/social_video/index.js +7 -0
  35. package/server/services/social_video/metadata.js +63 -0
  36. package/server/services/social_video/result.js +63 -0
  37. package/server/services/social_video/service.js +576 -0
  38. package/server/services/social_video/url.js +83 -0
@@ -0,0 +1,576 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const fsp = require('fs/promises');
5
+ const path = require('path');
6
+ const { randomUUID } = require('crypto');
7
+
8
+ const { DATA_DIR } = require('../../../runtime/paths');
9
+ const { CLIExecutor } = require('../cli/executor');
10
+ const { isDeepgramConfigured, transcribeChunkWithDeepgram } = require('../recordings/deepgram');
11
+ const { getAdapterForPlatform } = require('./adapters');
12
+ const { decideTranscriptPath, parseCaptionText, pickCaptionTrack } = require('./captions');
13
+ const { inferImageContentType, pickDeterministicFrameSecond } = require('./frame');
14
+ const { extractPublicMetadataFromHtml } = require('./metadata');
15
+ const { shapeSocialVideoResult } = require('./result');
16
+ const { normalizeAndDetectPlatform } = require('./url');
17
+
18
+ const SOCIAL_VIDEO_TMP_DIR = path.join(DATA_DIR, 'social-video-temp');
19
+ fs.mkdirSync(SOCIAL_VIDEO_TMP_DIR, { recursive: true });
20
+
21
+ const HEALTH_CACHE_TTL_MS = 5 * 60 * 1000;
22
+
23
+ function shellEscape(value) {
24
+ const text = String(value ?? '');
25
+ if (!text.length) return process.platform === 'win32' ? '""' : "''";
26
+ if (process.platform === 'win32') {
27
+ return `"${text
28
+ .replace(/(["^&|<>])/g, '^$1')
29
+ .replace(/%/g, '%%')}"`;
30
+ }
31
+ return `'${text.replace(/'/g, `'\\''`)}'`;
32
+ }
33
+
34
+ function detectMimeFromFile(filePath) {
35
+ const ext = path.extname(String(filePath || '')).toLowerCase();
36
+ if (ext === '.mp3') return 'audio/mpeg';
37
+ if (ext === '.m4a') return 'audio/mp4';
38
+ if (ext === '.wav') return 'audio/wav';
39
+ if (ext === '.webm') return 'audio/webm';
40
+ if (ext === '.opus') return 'audio/opus';
41
+ if (ext === '.ogg') return 'audio/ogg';
42
+ return 'application/octet-stream';
43
+ }
44
+
45
+ function pickBestThumbnail(thumbnails = []) {
46
+ const candidates = Array.isArray(thumbnails)
47
+ ? thumbnails.filter((item) => item && typeof item === 'object' && item.url)
48
+ : [];
49
+ if (candidates.length === 0) return null;
50
+ const scored = candidates.map((thumb, index) => {
51
+ const width = Number(thumb.width) || 0;
52
+ const height = Number(thumb.height) || 0;
53
+ return {
54
+ index,
55
+ thumb,
56
+ area: width * height,
57
+ };
58
+ });
59
+ scored.sort((left, right) => {
60
+ if (right.area !== left.area) return right.area - left.area;
61
+ return left.index - right.index;
62
+ });
63
+ return scored[0]?.thumb || null;
64
+ }
65
+
66
+ function unwrapBrowserExtractValue(payload) {
67
+ if (payload == null) return '';
68
+ if (typeof payload === 'string') return payload;
69
+ if (typeof payload?.result === 'string') return payload.result;
70
+ return '';
71
+ }
72
+
73
+ function fileExists(filePath) {
74
+ try {
75
+ return fs.statSync(filePath).isFile();
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ function firstFileMatching(dirPath, startsWith) {
82
+ const items = fs.readdirSync(dirPath);
83
+ const match = items
84
+ .filter((name) => name.startsWith(startsWith))
85
+ .sort()[0];
86
+ if (!match) return null;
87
+ return path.join(dirPath, match);
88
+ }
89
+
90
+ function classifyExtractionError(error) {
91
+ const message = String(error?.message || error || '').trim();
92
+ const normalized = message.toLowerCase();
93
+ if (/unsupported social video url|unsupported url/.test(normalized)) {
94
+ return { code: 'unsupported_url', message };
95
+ }
96
+ if (/private|login required|sign in to confirm/.test(normalized)) {
97
+ return { code: 'private_or_auth_required', message };
98
+ }
99
+ if (/403|forbidden|blocked/.test(normalized)) {
100
+ return { code: 'blocked_or_unavailable', message };
101
+ }
102
+ return { code: 'social_video_extract_failed', message };
103
+ }
104
+
105
+ function buildInstallHint(binaryName) {
106
+ const name = String(binaryName || '').trim().toLowerCase();
107
+ if (process.platform === 'darwin') {
108
+ if (name === 'ffmpeg') return 'Install with: brew install ffmpeg';
109
+ if (name === 'yt-dlp' || name === 'yt_dlp') return 'Install with: brew install yt-dlp';
110
+ }
111
+ if (process.platform === 'linux') {
112
+ if (name === 'ffmpeg') return 'Install with your package manager, for example: sudo apt-get install -y ffmpeg';
113
+ if (name === 'yt-dlp' || name === 'yt_dlp') return 'Install with your package manager or pipx, for example: pipx install yt-dlp';
114
+ }
115
+ if (process.platform === 'win32') {
116
+ if (name === 'ffmpeg') return 'Install ffmpeg and ensure ffmpeg.exe is on PATH.';
117
+ if (name === 'yt-dlp' || name === 'yt_dlp') return 'Install yt-dlp and ensure yt-dlp.exe is on PATH.';
118
+ }
119
+ return `Install ${binaryName} and ensure it is available on PATH.`;
120
+ }
121
+
122
+ class SocialVideoService {
123
+ constructor(options = {}) {
124
+ this.artifactStore = options.artifactStore || null;
125
+ this.runtimeManager = options.runtimeManager || null;
126
+ this.cliExecutor = options.cliExecutor || new CLIExecutor();
127
+ this.ytDlpBin = String(process.env.YT_DLP_BIN || 'yt-dlp').trim() || 'yt-dlp';
128
+ this.ffmpegBin = String(process.env.FFMPEG_BIN || 'ffmpeg').trim() || 'ffmpeg';
129
+ this._healthCache = {
130
+ ts: 0,
131
+ value: null,
132
+ };
133
+ }
134
+
135
+ async getHealthStatus(options = {}) {
136
+ const forceRefresh = options.forceRefresh === true;
137
+ const now = Date.now();
138
+ if (!forceRefresh && this._healthCache.value && (now - this._healthCache.ts) < HEALTH_CACHE_TTL_MS) {
139
+ return this._healthCache.value;
140
+ }
141
+
142
+ const [ytDlp, ffmpeg] = await Promise.all([
143
+ this.#probeBinary(this.ytDlpBin, '--version'),
144
+ this.#probeBinary(this.ffmpegBin, '-version'),
145
+ ]);
146
+
147
+ const health = {
148
+ ready: ytDlp.available && ffmpeg.available,
149
+ dependencies: [ytDlp, ffmpeg],
150
+ speechToText: {
151
+ configured: isDeepgramConfigured(),
152
+ note: isDeepgramConfigured()
153
+ ? 'Deepgram is configured for speech-to-text fallback.'
154
+ : 'DEEPGRAM_API_KEY is not configured. Extraction still works when platform captions are available.',
155
+ },
156
+ checkedAt: new Date().toISOString(),
157
+ };
158
+
159
+ this._healthCache = {
160
+ ts: now,
161
+ value: health,
162
+ };
163
+ return health;
164
+ }
165
+
166
+ async extractFromUrl(userId, sourceUrl, options = {}) {
167
+ const warnings = [];
168
+ const errors = [];
169
+ const source = String(sourceUrl || '').trim();
170
+ let jobDir = null;
171
+
172
+ try {
173
+ const health = await this.getHealthStatus();
174
+ if (!health.ready) {
175
+ const missing = health.dependencies.filter((item) => !item.available).map((item) => item.name);
176
+ throw new Error(`Missing required dependency: ${missing.join(', ')}`);
177
+ }
178
+
179
+ const { platform, normalizedUrl } = normalizeAndDetectPlatform(source);
180
+ const adapter = getAdapterForPlatform(platform);
181
+ if (!adapter) {
182
+ throw new Error(`No adapter registered for platform: ${platform}`);
183
+ }
184
+
185
+ const pageMetadata = await this.#resolvePageMetadata(userId, normalizedUrl, warnings);
186
+ jobDir = await fsp.mkdtemp(path.join(SOCIAL_VIDEO_TMP_DIR, `${platform}-${Date.now()}-`));
187
+
188
+ const mediaInfo = await this.#readMediaInfo(normalizedUrl, jobDir);
189
+ const baseTitle = String(pageMetadata.title || mediaInfo.title || '').trim();
190
+ const baseDescription = String(pageMetadata.description || mediaInfo.description || '').trim();
191
+ const resolvedUrl = String(pageMetadata.resolvedUrl || mediaInfo.webpage_url || normalizedUrl).trim();
192
+ const canonicalUrl = String(pageMetadata.canonicalUrl || mediaInfo.webpage_url || normalizedUrl).trim();
193
+
194
+ const subtitles = mediaInfo.subtitles || {};
195
+ const automaticCaptions = mediaInfo.automatic_captions || {};
196
+ const preferredLanguages = adapter.getCaptionLanguagePreferences();
197
+ const subtitleTrack = pickCaptionTrack(subtitles, preferredLanguages);
198
+ const autoTrack = pickCaptionTrack(automaticCaptions, preferredLanguages);
199
+ const captionTrack = subtitleTrack || autoTrack;
200
+ const transcriptDecision = decideTranscriptPath({
201
+ forceStt: options.forceStt === true,
202
+ captionTrack,
203
+ });
204
+
205
+ const transcriptResolution = await this.#resolveTranscript({
206
+ sourceUrl: normalizedUrl,
207
+ mediaInfo,
208
+ captionTrack,
209
+ transcriptDecision,
210
+ jobDir,
211
+ warnings,
212
+ });
213
+
214
+ const frameImage = options.includeFrame === false
215
+ ? null
216
+ : await this.#resolveFrameImage({
217
+ userId,
218
+ sourceUrl: normalizedUrl,
219
+ mediaInfo,
220
+ jobDir,
221
+ warnings,
222
+ });
223
+
224
+ return shapeSocialVideoResult({
225
+ sourceUrl: source,
226
+ resolvedUrl,
227
+ canonicalUrl,
228
+ platform,
229
+ title: baseTitle,
230
+ description: baseDescription,
231
+ transcript: transcriptResolution.text,
232
+ transcriptSource: transcriptResolution.source,
233
+ frameImage,
234
+ metadata: {
235
+ provider: 'yt-dlp',
236
+ durationSeconds: Number(mediaInfo.duration) || null,
237
+ videoId: mediaInfo.id || null,
238
+ },
239
+ setup: health,
240
+ warnings,
241
+ errors,
242
+ });
243
+ } catch (error) {
244
+ const health = await this.getHealthStatus().catch(() => null);
245
+ errors.push(classifyExtractionError(error));
246
+ return shapeSocialVideoResult({
247
+ sourceUrl: source,
248
+ resolvedUrl: source,
249
+ platform: 'unknown',
250
+ title: '',
251
+ description: '',
252
+ transcript: '',
253
+ transcriptSource: 'unavailable',
254
+ frameImage: null,
255
+ setup: health,
256
+ warnings,
257
+ errors,
258
+ });
259
+ } finally {
260
+ if (jobDir) {
261
+ await fsp.rm(jobDir, { recursive: true, force: true }).catch(() => {});
262
+ }
263
+ }
264
+ }
265
+
266
+ async #runCommand(command, options = {}) {
267
+ const result = await this.cliExecutor.execute(command, {
268
+ cwd: options.cwd || process.cwd(),
269
+ timeout: options.timeout || 10 * 60 * 1000,
270
+ env: options.env,
271
+ });
272
+ if (result.exitCode !== 0) {
273
+ throw new Error(result.stderr || result.stdout || `Command failed: ${command}`);
274
+ }
275
+ return result;
276
+ }
277
+
278
+ async #probeBinary(binary, versionFlag) {
279
+ const name = String(binary || '').trim();
280
+ const fallback = {
281
+ name,
282
+ available: false,
283
+ version: null,
284
+ installHint: buildInstallHint(name),
285
+ error: 'Binary probe failed.',
286
+ };
287
+ if (!name) {
288
+ return {
289
+ ...fallback,
290
+ error: 'Binary name is empty.',
291
+ };
292
+ }
293
+
294
+ try {
295
+ const command = `${shellEscape(name)} ${versionFlag}`;
296
+ const result = await this.cliExecutor.execute(command, {
297
+ timeout: 8 * 1000,
298
+ });
299
+ if (result.exitCode !== 0) {
300
+ return {
301
+ ...fallback,
302
+ error: result.stderr || result.stdout || `Exit code ${result.exitCode}`,
303
+ };
304
+ }
305
+ const output = String(result.stdout || result.stderr || '').trim();
306
+ const firstLine = output.split(/\r?\n/)[0] || null;
307
+ return {
308
+ name,
309
+ available: true,
310
+ version: firstLine,
311
+ installHint: null,
312
+ error: null,
313
+ };
314
+ } catch (error) {
315
+ return {
316
+ ...fallback,
317
+ error: error.message || String(error),
318
+ };
319
+ }
320
+ }
321
+
322
+ async #resolvePageMetadata(userId, normalizedUrl, warnings) {
323
+ const browserMetadata = await this.#resolvePageMetadataViaBrowser(userId, normalizedUrl).catch((error) => {
324
+ warnings.push(`Browser metadata resolve failed: ${error.message}`);
325
+ return null;
326
+ });
327
+ if (browserMetadata) {
328
+ return browserMetadata;
329
+ }
330
+
331
+ const response = await fetch(normalizedUrl, { redirect: 'follow' });
332
+ const html = await response.text();
333
+ const metadata = extractPublicMetadataFromHtml(html, response.url || normalizedUrl);
334
+ return {
335
+ ...metadata,
336
+ resolvedUrl: String(response.url || normalizedUrl),
337
+ };
338
+ }
339
+
340
+ async #resolvePageMetadataViaBrowser(userId, normalizedUrl) {
341
+ if (!this.runtimeManager || typeof this.runtimeManager.getBrowserProviderForUser !== 'function') {
342
+ throw new Error('Runtime browser provider is unavailable.');
343
+ }
344
+
345
+ const browser = await this.runtimeManager.getBrowserProviderForUser(userId);
346
+ if (!browser || typeof browser.navigate !== 'function' || typeof browser.extract !== 'function') {
347
+ throw new Error('Runtime browser provider does not support metadata extraction.');
348
+ }
349
+
350
+ const nav = await browser.navigate(normalizedUrl, {
351
+ screenshot: false,
352
+ waitUntil: 'domcontentloaded',
353
+ });
354
+ if (nav?.error) {
355
+ throw new Error(nav.error);
356
+ }
357
+
358
+ const [canonicalRaw, descriptionRaw, ogDescriptionRaw, titleTagRaw] = await Promise.all([
359
+ browser.extract('link[rel="canonical"]', 'href', false).catch(() => ''),
360
+ browser.extract('meta[name="description"]', 'content', false).catch(() => ''),
361
+ browser.extract('meta[property="og:description"]', 'content', false).catch(() => ''),
362
+ browser.extract('meta[property="og:title"]', 'content', false).catch(() => ''),
363
+ ]);
364
+ const canonical = unwrapBrowserExtractValue(canonicalRaw);
365
+ const description = unwrapBrowserExtractValue(descriptionRaw);
366
+ const ogDescription = unwrapBrowserExtractValue(ogDescriptionRaw);
367
+ const titleTag = unwrapBrowserExtractValue(titleTagRaw);
368
+
369
+ return {
370
+ title: String(titleTag || nav.title || '').trim(),
371
+ description: String(description || ogDescription || '').trim(),
372
+ canonicalUrl: String(canonical || nav.url || normalizedUrl).trim(),
373
+ resolvedUrl: String(nav.url || normalizedUrl).trim(),
374
+ };
375
+ }
376
+
377
+ async #readMediaInfo(normalizedUrl, jobDir) {
378
+ const infoPath = path.join(jobDir, 'media-info.json');
379
+ const command = `${shellEscape(this.ytDlpBin)} --no-playlist --skip-download --dump-single-json -- ${shellEscape(normalizedUrl)}`;
380
+ const result = await this.#runCommand(command, { cwd: jobDir, timeout: 4 * 60 * 1000 });
381
+ const raw = String(result.stdout || '').trim();
382
+ if (!raw) {
383
+ throw new Error('yt-dlp returned empty media metadata output.');
384
+ }
385
+ await fsp.writeFile(infoPath, `${raw}\n`, 'utf8');
386
+ let parsed;
387
+ try {
388
+ parsed = JSON.parse(raw);
389
+ } catch (error) {
390
+ throw new Error(`Failed to parse media metadata JSON: ${error.message}`);
391
+ }
392
+ return parsed;
393
+ }
394
+
395
+ async #resolveTranscript(context) {
396
+ if (context.transcriptDecision.mode === 'captions' && context.captionTrack) {
397
+ const captionText = await this.#readTranscriptFromCaption(context.captionTrack).catch((error) => {
398
+ context.warnings.push(`Caption transcript failed: ${error.message}`);
399
+ return '';
400
+ });
401
+ if (captionText) {
402
+ return {
403
+ text: captionText,
404
+ source: 'captions',
405
+ };
406
+ }
407
+ context.warnings.push('Caption track was present but transcript text was empty. Falling back to speech-to-text.');
408
+ }
409
+
410
+ if (!isDeepgramConfigured()) {
411
+ context.warnings.push('Captions unavailable and DEEPGRAM_API_KEY is not configured; transcript could not be generated.');
412
+ return {
413
+ text: '',
414
+ source: 'unavailable',
415
+ };
416
+ }
417
+
418
+ const transcript = await this.#transcribeViaStt(context.sourceUrl, context.jobDir);
419
+ return {
420
+ text: transcript,
421
+ source: transcript ? 'stt' : 'unavailable',
422
+ };
423
+ }
424
+
425
+ async #readTranscriptFromCaption(captionTrack) {
426
+ const response = await fetch(captionTrack.url, { redirect: 'follow' });
427
+ if (!response.ok) {
428
+ throw new Error(`Caption request failed (${response.status}).`);
429
+ }
430
+ const raw = await response.text();
431
+ return parseCaptionText(raw, captionTrack.ext);
432
+ }
433
+
434
+ async #transcribeViaStt(sourceUrl, jobDir) {
435
+ const template = path.join(jobDir, 'audio.%(ext)s');
436
+ const command = `${shellEscape(this.ytDlpBin)} --no-playlist -f bestaudio -- ${shellEscape(sourceUrl)} -o ${shellEscape(template)}`;
437
+ await this.#runCommand(command, { cwd: jobDir, timeout: 10 * 60 * 1000 });
438
+
439
+ const audioPath = firstFileMatching(jobDir, 'audio.');
440
+ if (!audioPath || !fileExists(audioPath)) {
441
+ throw new Error('Audio download succeeded but no audio file was created.');
442
+ }
443
+
444
+ const audioBytes = await fsp.readFile(audioPath);
445
+ const deepgramResult = await transcribeChunkWithDeepgram({
446
+ audioBytes,
447
+ mimeType: detectMimeFromFile(audioPath),
448
+ });
449
+ const transcript = deepgramResult?.results?.channels?.[0]?.alternatives?.[0]?.transcript;
450
+ return String(transcript || '').trim();
451
+ }
452
+
453
+ async #resolveFrameImage(context) {
454
+ const downloadedFrame = await this.#extractFrameFromVideo(context).catch((error) => {
455
+ context.warnings.push(`Frame extraction failed: ${error.message}`);
456
+ return null;
457
+ });
458
+ if (downloadedFrame) {
459
+ return downloadedFrame;
460
+ }
461
+
462
+ const thumbnail = pickBestThumbnail(context.mediaInfo.thumbnails);
463
+ if (!thumbnail?.url) {
464
+ context.warnings.push('No thumbnail fallback was available after frame extraction failed.');
465
+ return null;
466
+ }
467
+ return this.#downloadThumbnailArtifact(context.userId, thumbnail.url);
468
+ }
469
+
470
+ async #extractFrameFromVideo(context) {
471
+ const template = path.join(context.jobDir, 'video.%(ext)s');
472
+ const downloadCommand = `${shellEscape(this.ytDlpBin)} --no-playlist -f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best" --merge-output-format mp4 -- ${shellEscape(context.sourceUrl)} -o ${shellEscape(template)}`;
473
+ await this.#runCommand(downloadCommand, { cwd: context.jobDir, timeout: 14 * 60 * 1000 });
474
+
475
+ const videoPath = firstFileMatching(context.jobDir, 'video.');
476
+ if (!videoPath || !fileExists(videoPath)) {
477
+ throw new Error('Video download succeeded but no playable file was created.');
478
+ }
479
+
480
+ const framePath = path.join(context.jobDir, 'frame.jpg');
481
+ const frameSecond = pickDeterministicFrameSecond(context.mediaInfo.duration);
482
+ const frameCommand = `${shellEscape(this.ffmpegBin)} -y -hide_banner -loglevel error -ss ${frameSecond} -i ${shellEscape(videoPath)} -frames:v 1 -q:v 2 ${shellEscape(framePath)}`;
483
+ await this.#runCommand(frameCommand, { cwd: context.jobDir, timeout: 2 * 60 * 1000 });
484
+
485
+ if (!fileExists(framePath)) {
486
+ throw new Error('ffmpeg did not produce a frame image.');
487
+ }
488
+ return this.#saveImageArtifact(context.userId, framePath, 'frame');
489
+ }
490
+
491
+ async #downloadThumbnailArtifact(userId, thumbnailUrl) {
492
+ const response = await fetch(thumbnailUrl, { redirect: 'follow' });
493
+ if (!response.ok) {
494
+ throw new Error(`Thumbnail request failed (${response.status}).`);
495
+ }
496
+ const buffer = Buffer.from(await response.arrayBuffer());
497
+ const guessedExtension = path.extname(new URL(response.url || thumbnailUrl).pathname).replace('.', '') || 'jpg';
498
+ const mimeType = String(response.headers.get('content-type') || '').trim() || `image/${guessedExtension}`;
499
+ if (!this.artifactStore || userId == null) {
500
+ return {
501
+ url: null,
502
+ artifactId: null,
503
+ mimeType,
504
+ byteSize: buffer.length,
505
+ source: 'thumbnail',
506
+ };
507
+ }
508
+ const allocation = this.artifactStore.allocateFile(userId, {
509
+ kind: 'social-video-frame',
510
+ extension: guessedExtension,
511
+ contentType: mimeType,
512
+ filenameBase: `social-video-thumbnail-${randomUUID().slice(0, 8)}`,
513
+ metadata: {
514
+ source: 'social-video-thumbnail',
515
+ },
516
+ });
517
+ await fsp.writeFile(allocation.storagePath, buffer);
518
+ const finalized = this.artifactStore.finalizeFile(allocation.artifactId, allocation.storagePath);
519
+ return {
520
+ url: finalized.url,
521
+ artifactId: finalized.artifactId,
522
+ mimeType,
523
+ byteSize: finalized.byteSize,
524
+ source: 'thumbnail',
525
+ };
526
+ }
527
+
528
+ async #saveImageArtifact(userId, imagePath, source) {
529
+ const mimeType = inferImageContentType(imagePath);
530
+ if (!this.artifactStore || userId == null) {
531
+ const byteSize = (await fsp.stat(imagePath)).size;
532
+ return {
533
+ url: imagePath,
534
+ artifactId: null,
535
+ mimeType,
536
+ byteSize,
537
+ source,
538
+ };
539
+ }
540
+
541
+ const extension = path.extname(imagePath).replace(/^\./, '') || 'jpg';
542
+ const allocation = await Promise.resolve(this.artifactStore.allocateFile(userId, {
543
+ kind: 'social-video-frame',
544
+ extension,
545
+ contentType: mimeType,
546
+ filenameBase: `social-video-${source}`,
547
+ metadata: {
548
+ source,
549
+ },
550
+ }));
551
+ await fsp.copyFile(imagePath, allocation.storagePath);
552
+ const finalized = await Promise.resolve(
553
+ this.artifactStore.finalizeFile(allocation.artifactId, allocation.storagePath),
554
+ );
555
+ return {
556
+ url: finalized.url,
557
+ artifactId: finalized.artifactId,
558
+ mimeType,
559
+ byteSize: finalized.byteSize,
560
+ source,
561
+ };
562
+ }
563
+ }
564
+
565
+ module.exports = {
566
+ SOCIAL_VIDEO_TMP_DIR,
567
+ SocialVideoService,
568
+ buildInstallHint,
569
+ HEALTH_CACHE_TTL_MS,
570
+ detectMimeFromFile,
571
+ fileExists,
572
+ firstFileMatching,
573
+ pickBestThumbnail,
574
+ classifyExtractionError,
575
+ shellEscape,
576
+ };
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ const SUPPORTED_PLATFORMS = new Set(['youtube', 'tiktok', 'instagram', 'x']);
4
+
5
+ function normalizeInputUrl(input) {
6
+ const raw = String(input || '').trim();
7
+ if (!raw) {
8
+ throw new Error('url is required.');
9
+ }
10
+ const withScheme = /^[a-z]+:\/\//i.test(raw) ? raw : `https://${raw}`;
11
+ const parsed = new URL(withScheme);
12
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
13
+ throw new Error('Only http and https URLs are supported.');
14
+ }
15
+ parsed.hash = '';
16
+ for (const key of [...parsed.searchParams.keys()]) {
17
+ if (key.startsWith('utm_') || key === 'si') {
18
+ parsed.searchParams.delete(key);
19
+ }
20
+ }
21
+ return parsed;
22
+ }
23
+
24
+ function detectPlatformFromHostname(hostname) {
25
+ const host = String(hostname || '').toLowerCase();
26
+ if (host === 'youtu.be' || host.endsWith('.youtube.com') || host === 'youtube.com') {
27
+ return 'youtube';
28
+ }
29
+ if (host.endsWith('.tiktok.com') || host === 'tiktok.com') {
30
+ return 'tiktok';
31
+ }
32
+ if (host.endsWith('.instagram.com') || host === 'instagram.com' || host === 'instagr.am') {
33
+ return 'instagram';
34
+ }
35
+ if (host === 'x.com' || host.endsWith('.x.com') || host === 'twitter.com' || host.endsWith('.twitter.com')) {
36
+ return 'x';
37
+ }
38
+ return 'unknown';
39
+ }
40
+
41
+ function canonicalizeByPlatform(parsed, platform) {
42
+ if (platform === 'youtube') {
43
+ if (parsed.hostname.toLowerCase() === 'youtu.be') {
44
+ const id = parsed.pathname.replace(/^\/+/, '').split('/')[0];
45
+ parsed.hostname = 'www.youtube.com';
46
+ parsed.pathname = '/watch';
47
+ if (id) {
48
+ parsed.searchParams.set('v', id);
49
+ }
50
+ return parsed;
51
+ }
52
+ parsed.hostname = 'www.youtube.com';
53
+ return parsed;
54
+ }
55
+
56
+ if (platform === 'x') {
57
+ parsed.hostname = 'x.com';
58
+ return parsed;
59
+ }
60
+
61
+ return parsed;
62
+ }
63
+
64
+ function normalizeAndDetectPlatform(inputUrl) {
65
+ const parsed = normalizeInputUrl(inputUrl);
66
+ const platform = detectPlatformFromHostname(parsed.hostname);
67
+ if (!SUPPORTED_PLATFORMS.has(platform)) {
68
+ throw new Error('Unsupported social video URL. Supported platforms: YouTube, TikTok, Instagram, and X.');
69
+ }
70
+ const canonical = canonicalizeByPlatform(parsed, platform);
71
+ return {
72
+ platform,
73
+ normalizedUrl: canonical.toString(),
74
+ };
75
+ }
76
+
77
+ module.exports = {
78
+ SUPPORTED_PLATFORMS,
79
+ canonicalizeByPlatform,
80
+ detectPlatformFromHostname,
81
+ normalizeAndDetectPlatform,
82
+ normalizeInputUrl,
83
+ };