@larkup/workspace-server 0.0.0-stage → 0.2.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 (180) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +2 -2
  3. package/dist-types/app/api/chat/feedback/route.d.ts +4 -0
  4. package/dist-types/app/api/chat/feedback/route.d.ts.map +1 -0
  5. package/dist-types/app/api/chat/route.d.ts +3 -0
  6. package/dist-types/app/api/chat/route.d.ts.map +1 -0
  7. package/dist-types/app/api/chat/tools.d.ts +67 -0
  8. package/dist-types/app/api/chat/tools.d.ts.map +1 -0
  9. package/dist-types/app/api/config/test-embedding/route.d.ts +3 -0
  10. package/dist-types/app/api/config/test-embedding/route.d.ts.map +1 -0
  11. package/dist-types/app/api/config/verify/route.d.ts +3 -0
  12. package/dist-types/app/api/config/verify/route.d.ts.map +1 -0
  13. package/dist-types/app/api/describe-image/route.d.ts +4 -0
  14. package/dist-types/app/api/describe-image/route.d.ts.map +1 -0
  15. package/dist-types/app/api/doc-editor/edit/route.d.ts +10 -0
  16. package/dist-types/app/api/doc-editor/edit/route.d.ts.map +1 -0
  17. package/dist-types/app/api/doc-editor/export/route.d.ts +9 -0
  18. package/dist-types/app/api/doc-editor/export/route.d.ts.map +1 -0
  19. package/dist-types/app/api/doc-editor/parse/route.d.ts +10 -0
  20. package/dist-types/app/api/doc-editor/parse/route.d.ts.map +1 -0
  21. package/dist-types/app/api/doc-editor/restore/route.d.ts +2 -0
  22. package/dist-types/app/api/doc-editor/restore/route.d.ts.map +1 -0
  23. package/dist-types/app/api/document/sign/route.d.ts +2 -0
  24. package/dist-types/app/api/document/sign/route.d.ts.map +1 -0
  25. package/dist-types/app/api/files/remote/route.d.ts +5 -0
  26. package/dist-types/app/api/files/remote/route.d.ts.map +1 -0
  27. package/dist-types/app/api/mcp/[connectionId]/route.d.ts +9 -0
  28. package/dist-types/app/api/mcp/[connectionId]/route.d.ts.map +1 -0
  29. package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts +8 -0
  30. package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts.map +1 -0
  31. package/dist-types/app/api/mcp/route.d.ts +4 -0
  32. package/dist-types/app/api/mcp/route.d.ts.map +1 -0
  33. package/dist-types/app/api/parse-file/route.d.ts +6 -0
  34. package/dist-types/app/api/parse-file/route.d.ts.map +1 -0
  35. package/dist-types/app/api/pdf-page/route.d.ts +4 -0
  36. package/dist-types/app/api/pdf-page/route.d.ts.map +1 -0
  37. package/dist-types/app/api/proxy/verify/route.d.ts +4 -0
  38. package/dist-types/app/api/proxy/verify/route.d.ts.map +1 -0
  39. package/dist-types/app/api/search/bing/route.d.ts +5 -0
  40. package/dist-types/app/api/search/bing/route.d.ts.map +1 -0
  41. package/dist-types/app/api/search/brave/route.d.ts +5 -0
  42. package/dist-types/app/api/search/brave/route.d.ts.map +1 -0
  43. package/dist-types/app/api/search/exa/route.d.ts +5 -0
  44. package/dist-types/app/api/search/exa/route.d.ts.map +1 -0
  45. package/dist-types/app/api/search/google/route.d.ts +30 -0
  46. package/dist-types/app/api/search/google/route.d.ts.map +1 -0
  47. package/dist-types/app/api/search/route.d.ts +7 -0
  48. package/dist-types/app/api/search/route.d.ts.map +1 -0
  49. package/dist-types/app/api/search/tavily/route.d.ts +5 -0
  50. package/dist-types/app/api/search/tavily/route.d.ts.map +1 -0
  51. package/dist-types/app/api/search/verify/route.d.ts +4 -0
  52. package/dist-types/app/api/search/verify/route.d.ts.map +1 -0
  53. package/dist-types/app/api/tabular/route.d.ts +9 -0
  54. package/dist-types/app/api/tabular/route.d.ts.map +1 -0
  55. package/dist-types/lib/chat/assistant-source-scope.d.ts +10 -0
  56. package/dist-types/lib/chat/assistant-source-scope.d.ts.map +1 -0
  57. package/dist-types/lib/chat/conversation-memory.d.ts +80 -0
  58. package/dist-types/lib/chat/conversation-memory.d.ts.map +1 -0
  59. package/dist-types/lib/chat/gateway-fallbacks.d.ts +7 -0
  60. package/dist-types/lib/chat/gateway-fallbacks.d.ts.map +1 -0
  61. package/dist-types/lib/chat/image-description.d.ts +10 -0
  62. package/dist-types/lib/chat/image-description.d.ts.map +1 -0
  63. package/dist-types/lib/chat/media-retrieval-routing.d.ts +14 -0
  64. package/dist-types/lib/chat/media-retrieval-routing.d.ts.map +1 -0
  65. package/dist-types/lib/chat/media-source-routing.d.ts +53 -0
  66. package/dist-types/lib/chat/media-source-routing.d.ts.map +1 -0
  67. package/dist-types/lib/chat/message-input.d.ts +8 -0
  68. package/dist-types/lib/chat/message-input.d.ts.map +1 -0
  69. package/dist-types/lib/chat/model-provider.d.ts +23 -0
  70. package/dist-types/lib/chat/model-provider.d.ts.map +1 -0
  71. package/dist-types/lib/chat/response-style.d.ts +2 -0
  72. package/dist-types/lib/chat/response-style.d.ts.map +1 -0
  73. package/dist-types/lib/chat/retrieval-ranking.d.ts +32 -0
  74. package/dist-types/lib/chat/retrieval-ranking.d.ts.map +1 -0
  75. package/dist-types/lib/chat/retrieval-routing.d.ts +39 -0
  76. package/dist-types/lib/chat/retrieval-routing.d.ts.map +1 -0
  77. package/dist-types/lib/chat/tabular-dataset-routing.d.ts +15 -0
  78. package/dist-types/lib/chat/tabular-dataset-routing.d.ts.map +1 -0
  79. package/dist-types/lib/chat/tabular-query-plan.d.ts +12 -0
  80. package/dist-types/lib/chat/tabular-query-plan.d.ts.map +1 -0
  81. package/dist-types/lib/chat/tabular-routing.d.ts +41 -0
  82. package/dist-types/lib/chat/tabular-routing.d.ts.map +1 -0
  83. package/dist-types/lib/chat/tabular-sandbox-files.d.ts +33 -0
  84. package/dist-types/lib/chat/tabular-sandbox-files.d.ts.map +1 -0
  85. package/dist-types/lib/chat/tabular-visualization.d.ts +31 -0
  86. package/dist-types/lib/chat/tabular-visualization.d.ts.map +1 -0
  87. package/dist-types/lib/chat/tool-call-repair.d.ts +11 -0
  88. package/dist-types/lib/chat/tool-call-repair.d.ts.map +1 -0
  89. package/dist-types/lib/chat/tool-context.d.ts +72 -0
  90. package/dist-types/lib/chat/tool-context.d.ts.map +1 -0
  91. package/dist-types/lib/chat/tool-registry.d.ts +3 -0
  92. package/dist-types/lib/chat/tool-registry.d.ts.map +1 -0
  93. package/dist-types/lib/chat/video-rag-routing.d.ts +33 -0
  94. package/dist-types/lib/chat/video-rag-routing.d.ts.map +1 -0
  95. package/dist-types/lib/chat/visual-routing.d.ts +53 -0
  96. package/dist-types/lib/chat/visual-routing.d.ts.map +1 -0
  97. package/dist-types/lib/enterprise-client.d.ts +31 -0
  98. package/dist-types/lib/enterprise-client.d.ts.map +1 -0
  99. package/dist-types/lib/marketplace/tool-runtime-config.d.ts +7 -0
  100. package/dist-types/lib/marketplace/tool-runtime-config.d.ts.map +1 -0
  101. package/dist-types/lib/media/knowledge.d.ts +47 -0
  102. package/dist-types/lib/media/knowledge.d.ts.map +1 -0
  103. package/dist-types/lib/media/pdf-inspection.d.ts +30 -0
  104. package/dist-types/lib/media/pdf-inspection.d.ts.map +1 -0
  105. package/dist-types/lib/media/source-identity.d.ts +12 -0
  106. package/dist-types/lib/media/source-identity.d.ts.map +1 -0
  107. package/dist-types/lib/media/source-utils.d.ts +157 -0
  108. package/dist-types/lib/media/source-utils.d.ts.map +1 -0
  109. package/dist-types/lib/media/video/quick-look.d.ts +60 -0
  110. package/dist-types/lib/media/video/quick-look.d.ts.map +1 -0
  111. package/dist-types/lib/media/video-intelligence-adapter.d.ts +521 -0
  112. package/dist-types/lib/media/video-intelligence-adapter.d.ts.map +1 -0
  113. package/dist-types/lib/media/video-intelligence-connection.d.ts +32 -0
  114. package/dist-types/lib/media/video-intelligence-connection.d.ts.map +1 -0
  115. package/dist-types/lib/remote-files.d.ts +12 -0
  116. package/dist-types/lib/remote-files.d.ts.map +1 -0
  117. package/dist-types/lib/search/provider-verification.d.ts +5 -0
  118. package/dist-types/lib/search/provider-verification.d.ts.map +1 -0
  119. package/dist-types/runtime-host.d.ts +20 -0
  120. package/dist-types/runtime-host.d.ts.map +1 -0
  121. package/package.json +338 -3
  122. package/src/app/api/chat/feedback/route.ts +77 -0
  123. package/src/app/api/chat/route.ts +2277 -0
  124. package/src/app/api/chat/tools.ts +3563 -0
  125. package/src/app/api/config/test-embedding/route.ts +39 -0
  126. package/src/app/api/config/verify/route.ts +309 -0
  127. package/src/app/api/describe-image/route.ts +108 -0
  128. package/src/app/api/doc-editor/edit/route.ts +87 -0
  129. package/src/app/api/doc-editor/export/route.ts +32 -0
  130. package/src/app/api/doc-editor/parse/route.ts +74 -0
  131. package/src/app/api/doc-editor/restore/route.ts +70 -0
  132. package/src/app/api/document/sign/route.ts +55 -0
  133. package/src/app/api/files/remote/route.ts +32 -0
  134. package/src/app/api/mcp/[connectionId]/route.ts +29 -0
  135. package/src/app/api/mcp/[connectionId]/test/route.ts +13 -0
  136. package/src/app/api/mcp/route.ts +22 -0
  137. package/src/app/api/parse-file/route.ts +86 -0
  138. package/src/app/api/pdf-page/route.ts +40 -0
  139. package/src/app/api/proxy/verify/route.ts +78 -0
  140. package/src/app/api/search/bing/route.ts +86 -0
  141. package/src/app/api/search/brave/route.ts +88 -0
  142. package/src/app/api/search/exa/route.ts +100 -0
  143. package/src/app/api/search/google/route.ts +147 -0
  144. package/src/app/api/search/route.ts +28 -0
  145. package/src/app/api/search/tavily/route.ts +93 -0
  146. package/src/app/api/search/verify/route.ts +27 -0
  147. package/src/app/api/tabular/route.ts +68 -0
  148. package/src/lib/chat/assistant-source-scope.ts +32 -0
  149. package/src/lib/chat/conversation-memory.ts +621 -0
  150. package/src/lib/chat/gateway-fallbacks.ts +12 -0
  151. package/src/lib/chat/image-description.ts +21 -0
  152. package/src/lib/chat/media-retrieval-routing.ts +37 -0
  153. package/src/lib/chat/media-source-routing.ts +100 -0
  154. package/src/lib/chat/message-input.ts +48 -0
  155. package/src/lib/chat/model-provider.ts +157 -0
  156. package/src/lib/chat/response-style.ts +3 -0
  157. package/src/lib/chat/retrieval-ranking.ts +126 -0
  158. package/src/lib/chat/retrieval-routing.ts +221 -0
  159. package/src/lib/chat/tabular-dataset-routing.ts +93 -0
  160. package/src/lib/chat/tabular-query-plan.ts +195 -0
  161. package/src/lib/chat/tabular-routing.ts +122 -0
  162. package/src/lib/chat/tabular-sandbox-files.ts +78 -0
  163. package/src/lib/chat/tabular-visualization.ts +165 -0
  164. package/src/lib/chat/tool-call-repair.ts +84 -0
  165. package/src/lib/chat/tool-context.ts +1222 -0
  166. package/src/lib/chat/tool-registry.ts +6 -0
  167. package/src/lib/chat/video-rag-routing.ts +149 -0
  168. package/src/lib/chat/visual-routing.ts +225 -0
  169. package/src/lib/enterprise-client.ts +96 -0
  170. package/src/lib/marketplace/tool-runtime-config.ts +155 -0
  171. package/src/lib/media/knowledge.ts +489 -0
  172. package/src/lib/media/pdf-inspection.ts +280 -0
  173. package/src/lib/media/source-identity.ts +50 -0
  174. package/src/lib/media/source-utils.ts +1156 -0
  175. package/src/lib/media/video/quick-look.ts +368 -0
  176. package/src/lib/media/video-intelligence-adapter.ts +1946 -0
  177. package/src/lib/media/video-intelligence-connection.ts +151 -0
  178. package/src/lib/remote-files.ts +182 -0
  179. package/src/lib/search/provider-verification.ts +95 -0
  180. package/src/runtime-host.ts +31 -0
@@ -0,0 +1,1156 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { lookup } from 'node:dns/promises';
4
+ import { chmodSync, createWriteStream, promises as fs } from 'node:fs';
5
+ import { isIP } from 'node:net';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import { pipeline } from 'node:stream/promises';
9
+ import { Readable } from 'node:stream';
10
+ import bundledFfprobe from '@ffprobe-installer/ffprobe';
11
+
12
+ export type RemoteMediaType = 'audio' | 'video' | 'unknown';
13
+
14
+ export interface ImportedMedia {
15
+ path: string;
16
+ originalUrl: string;
17
+ title: string;
18
+ mimeType?: string;
19
+ mediaType: RemoteMediaType;
20
+ sourceTranscript?: {
21
+ chunks: Array<{ text: string; startSecs: number; endSecs: number }>;
22
+ fullText: string;
23
+ durationSecs: number;
24
+ language?: string;
25
+ origin: { kind: 'youtube-caption'; provider: 'youtube'; language?: string };
26
+ };
27
+ }
28
+
29
+ export interface UrlInspection {
30
+ originalUrl: string;
31
+ title?: string;
32
+ mimeType?: string;
33
+ mediaType: RemoteMediaType;
34
+ contentLength?: number;
35
+ durationSecs?: number;
36
+ singleItemDurationSecs?: number;
37
+ singleItemUrl?: string;
38
+ entryCount?: number;
39
+ isYouTube: boolean;
40
+ }
41
+
42
+ /** Download a public URL after applying the same DNS and redirect checks as media imports. */
43
+ export async function readPublicUrl(
44
+ url: string,
45
+ maxBytes = 25 * 1024 * 1024,
46
+ ): Promise<{ bytes: Uint8Array; mimeType?: string; finalUrl: string }> {
47
+ const response = await fetchPublic(url);
48
+ if (!response.ok || !response.body) {
49
+ await response.body?.cancel();
50
+ throw new Error(`Media download failed (${response.status}).`);
51
+ }
52
+ const declared = Number(response.headers.get('content-length'));
53
+ if (Number.isFinite(declared) && declared > maxBytes)
54
+ throw new Error(`Media exceeds download limit of ${maxBytes} bytes.`);
55
+ const reader = response.body.getReader();
56
+ const chunks: Uint8Array[] = [];
57
+ let size = 0;
58
+ while (true) {
59
+ const { done, value } = await reader.read();
60
+ if (done) break;
61
+ size += value.byteLength;
62
+ if (size > maxBytes) {
63
+ await reader.cancel();
64
+ throw new Error(`Media exceeds download limit of ${maxBytes} bytes.`);
65
+ }
66
+ chunks.push(value);
67
+ }
68
+ const bytes = new Uint8Array(size);
69
+ let offset = 0;
70
+ for (const chunk of chunks) {
71
+ bytes.set(chunk, offset);
72
+ offset += chunk.byteLength;
73
+ }
74
+ return {
75
+ bytes,
76
+ mimeType: response.headers.get('content-type')?.split(';')[0] || undefined,
77
+ finalUrl: response.url || url,
78
+ };
79
+ }
80
+
81
+ /** Checking a pasted non-YouTube URL must stay interactive, not block on a slow origin. */
82
+ export const MEDIA_URL_INSPECTION_TIMEOUT_MS = 7_000;
83
+
84
+ interface YouTubeInspectionData {
85
+ title?: string;
86
+ duration?: number;
87
+ language?: string;
88
+ ext?: string;
89
+ vcodec?: string;
90
+ acodec?: string;
91
+ subtitles?: Record<string, unknown>;
92
+ automatic_captions?: Record<string, unknown>;
93
+ entries?: Array<{
94
+ id?: string;
95
+ url?: string;
96
+ webpage_url?: string;
97
+ duration?: number;
98
+ }>;
99
+ }
100
+
101
+ function positiveDuration(value: unknown): number | undefined {
102
+ const duration = Number(value);
103
+ return Number.isFinite(duration) && duration > 0 ? duration : undefined;
104
+ }
105
+
106
+ function youtubeVideoId(value: string | undefined): string | undefined {
107
+ if (!value) return undefined;
108
+ try {
109
+ const parsed = new URL(value);
110
+ if (parsed.hostname === 'youtu.be') return parsed.pathname.split('/').filter(Boolean)[0];
111
+ return parsed.searchParams.get('v') ?? undefined;
112
+ } catch {
113
+ return /^[\w-]{6,}$/.test(value) ? value : undefined;
114
+ }
115
+ }
116
+
117
+ export function inspectYouTubeMetadata(url: string, data: YouTubeInspectionData): UrlInspection {
118
+ const entries = data.entries ?? [];
119
+ const requestedId = youtubeVideoId(url);
120
+ const selectedEntry =
121
+ entries.find(
122
+ (entry) =>
123
+ requestedId &&
124
+ [entry.id, youtubeVideoId(entry.url), youtubeVideoId(entry.webpage_url)].includes(
125
+ requestedId,
126
+ ),
127
+ ) ?? entries[0];
128
+ const selectedId =
129
+ selectedEntry?.id ??
130
+ youtubeVideoId(selectedEntry?.webpage_url) ??
131
+ youtubeVideoId(selectedEntry?.url) ??
132
+ requestedId;
133
+ const playlistDuration = entries.reduce(
134
+ (total, entry) => total + (positiveDuration(entry.duration) ?? 0),
135
+ 0,
136
+ );
137
+ const durationSecs = positiveDuration(data.duration) ?? (playlistDuration || undefined);
138
+ const singleItemDurationSecs =
139
+ positiveDuration(selectedEntry?.duration) ?? (entries.length <= 1 ? durationSecs : undefined);
140
+
141
+ return {
142
+ originalUrl: url,
143
+ title: data.title,
144
+ durationSecs,
145
+ singleItemDurationSecs,
146
+ singleItemUrl: selectedId
147
+ ? `https://www.youtube.com/watch?v=${encodeURIComponent(selectedId)}`
148
+ : undefined,
149
+ entryCount: Math.max(1, entries.length),
150
+ mediaType: 'video',
151
+ isYouTube: true,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * A public watch URL is enough to stage one video even when an interactive
157
+ * serverless IP is temporarily challenged by YouTube. The durable worker will
158
+ * still perform the real download and media probe before indexing, so this
159
+ * fallback does not claim a title, duration, or byte size it could not verify.
160
+ */
161
+ export function inspectYouTubeWatchUrl(url: string): UrlInspection | undefined {
162
+ const id = youtubeVideoId(url);
163
+ if (!id) return undefined;
164
+ return {
165
+ originalUrl: url,
166
+ singleItemUrl: `https://www.youtube.com/watch?v=${encodeURIComponent(id)}`,
167
+ entryCount: 1,
168
+ mediaType: 'video',
169
+ isYouTube: true,
170
+ };
171
+ }
172
+
173
+ export interface MediaProbe {
174
+ durationSecs: number;
175
+ containerDurationSecs: number;
176
+ videoDurationSecs?: number;
177
+ audioDurationSecs?: number;
178
+ hasVideo: boolean;
179
+ hasAudio: boolean;
180
+ hasCorruptionSignals: boolean;
181
+ }
182
+
183
+ export interface MediaImportProgress {
184
+ percent?: number;
185
+ current?: number;
186
+ total?: number;
187
+ unit?: string;
188
+ elapsedSeconds?: number;
189
+ estimatedRemainingSeconds?: number;
190
+ message: string;
191
+ }
192
+
193
+ export function createArtifactCacheKey(input: {
194
+ contentHash: string;
195
+ operation: string;
196
+ provider?: string;
197
+ model?: string;
198
+ promptVersion?: string;
199
+ schemaVersion?: string;
200
+ configuration?: Record<string, string | number | boolean | undefined>;
201
+ }) {
202
+ const configuration = Object.entries(input.configuration ?? {})
203
+ .filter(([, value]) => value !== undefined)
204
+ .sort(([left], [right]) => left.localeCompare(right));
205
+ return createHash('sha256')
206
+ .update(
207
+ JSON.stringify({
208
+ ...input,
209
+ configuration,
210
+ }),
211
+ )
212
+ .digest('hex');
213
+ }
214
+
215
+ export function extractRunningState(text: string) {
216
+ return text.replace(/\s+/g, ' ').trim().slice(-500);
217
+ }
218
+
219
+ export function deriveAudioSignals(
220
+ chunks: Array<{ text: string; startSecs: number; endSecs: number }>,
221
+ silenceGapSecs = 2,
222
+ ) {
223
+ const result: Array<{
224
+ timestampSecs: number;
225
+ transcriptChange: number;
226
+ silenceBoundary: boolean;
227
+ }> = [];
228
+ let previous: (typeof chunks)[number] | undefined;
229
+ for (const chunk of [...chunks].sort((left, right) => left.startSecs - right.startSecs)) {
230
+ const previousWords = new Set(
231
+ (previous?.text ?? '').toLocaleLowerCase().match(/[\p{Letter}\p{Number}]+/gu) ?? [],
232
+ );
233
+ const words = new Set(chunk.text.toLocaleLowerCase().match(/[\p{Letter}\p{Number}]+/gu) ?? []);
234
+ const novel = [...words].filter((word) => !previousWords.has(word)).length;
235
+ result.push({
236
+ timestampSecs: chunk.startSecs,
237
+ transcriptChange: words.size === 0 ? 0 : Math.min(1, novel / words.size),
238
+ silenceBoundary: Boolean(previous && chunk.startSecs - previous.endSecs >= silenceGapSecs),
239
+ });
240
+ previous = chunk;
241
+ }
242
+ return result;
243
+ }
244
+
245
+ /**
246
+ * The desktop/CLI process does not necessarily inherit a terminal's PATH.
247
+ * Keep probing self-contained, while allowing managed deployments to override it.
248
+ */
249
+ export function ffprobeExecutable() {
250
+ const executable = process.env.LARKUP_FFPROBE_PATH?.trim() || bundledFfprobe.path;
251
+ if (executable === bundledFfprobe.path) {
252
+ try {
253
+ // Ensure the bundled binary is executable (npm global installs might strip +x if not in bin field)
254
+ chmodSync(executable, 0o755);
255
+ } catch {
256
+ // Ignore errors (e.g. read-only filesystem)
257
+ }
258
+ }
259
+ return executable;
260
+ }
261
+
262
+ /** Read local media metadata with a bundled ffprobe binary. */
263
+ export async function probeMedia(mediaPath: string): Promise<MediaProbe> {
264
+ const output = await runProcess(ffprobeExecutable(), [
265
+ '-v',
266
+ 'error',
267
+ '-show_entries',
268
+ 'format=duration:format_tags=probe_score:stream=codec_type,duration',
269
+ '-of',
270
+ 'json',
271
+ mediaPath,
272
+ ]);
273
+ const data = JSON.parse(output) as {
274
+ format?: { duration?: string; tags?: { probe_score?: string } };
275
+ streams?: Array<{ codec_type?: string; duration?: string }>;
276
+ };
277
+ // A malformed or padded audio track can stretch the MP4 container duration
278
+ // far beyond its visible timeline. Video references, frame timestamps, and
279
+ // the player all need the actual video-stream duration as their canonical
280
+ // clock; audio-only files still use the audio stream or container fallback.
281
+ const videoDuration = streamDuration(data.streams, 'video');
282
+ const audioDuration = streamDuration(data.streams, 'audio');
283
+ const containerDurationSecs = Number(data.format?.duration ?? 0);
284
+ const durationSecs = videoDuration ?? audioDuration ?? containerDurationSecs;
285
+ return {
286
+ durationSecs: Number.isFinite(durationSecs) ? Math.max(0, durationSecs) : 0,
287
+ containerDurationSecs: Number.isFinite(containerDurationSecs)
288
+ ? Math.max(0, containerDurationSecs)
289
+ : 0,
290
+ videoDurationSecs: videoDuration,
291
+ audioDurationSecs: audioDuration,
292
+ hasVideo: data.streams?.some((stream) => stream.codec_type === 'video') ?? false,
293
+ hasAudio: data.streams?.some((stream) => stream.codec_type === 'audio') ?? false,
294
+ hasCorruptionSignals: !Number.isFinite(durationSecs) || durationSecs <= 0,
295
+ };
296
+ }
297
+
298
+ function streamDuration(
299
+ streams: Array<{ codec_type?: string; duration?: string }> | undefined,
300
+ type: 'video' | 'audio',
301
+ ) {
302
+ return streams
303
+ ?.filter((stream) => stream.codec_type === type)
304
+ .map((stream) => positiveDuration(stream.duration))
305
+ .find((duration) => duration !== undefined);
306
+ }
307
+
308
+ /** Whether a browser-visible container clock is materially longer than its video. */
309
+ export function hasInflatedPlaybackTimeline(probe: MediaProbe) {
310
+ const videoDuration = probe.videoDurationSecs;
311
+ if (!videoDuration || !probe.hasAudio) return false;
312
+ const longestReported = Math.max(probe.containerDurationSecs, probe.audioDurationSecs ?? 0);
313
+ return longestReported - videoDuration > Math.max(5, videoDuration * 0.03);
314
+ }
315
+
316
+ export async function inspectMediaUrl(url: string): Promise<UrlInspection> {
317
+ const parsed = validHttpUrl(url);
318
+ await assertPublicHost(parsed.hostname);
319
+ if (isYouTube(parsed)) {
320
+ let output: string;
321
+ try {
322
+ output = await runYtDlp(['--dump-single-json', '--simulate', '--flat-playlist', url]);
323
+ } catch (error) {
324
+ const fallback = inspectYouTubeWatchUrl(url);
325
+ if (fallback) return fallback;
326
+ throw error;
327
+ }
328
+ const inspection = inspectYouTubeMetadata(url, JSON.parse(output) as YouTubeInspectionData);
329
+ if (
330
+ (inspection.entryCount ?? 1) > 1 &&
331
+ !inspection.singleItemDurationSecs &&
332
+ inspection.singleItemUrl
333
+ ) {
334
+ const selectedOutput = await runYtDlp([
335
+ '--dump-single-json',
336
+ '--simulate',
337
+ '--no-playlist',
338
+ inspection.singleItemUrl,
339
+ ]);
340
+ const selected = JSON.parse(selectedOutput) as YouTubeInspectionData;
341
+ inspection.singleItemDurationSecs = positiveDuration(selected.duration);
342
+ }
343
+ return inspection;
344
+ }
345
+
346
+ let response: Response | undefined;
347
+ let directInspectionError: unknown;
348
+ try {
349
+ response = await inspectPublicMediaUrl(url, { method: 'HEAD' });
350
+ if (response.status === 405 || response.status === 501) {
351
+ response = await inspectPublicMediaUrl(url, { headers: { Range: 'bytes=0-0' } });
352
+ }
353
+ } catch (error) {
354
+ directInspectionError = error;
355
+ }
356
+ if (response?.ok) {
357
+ const headerMime = response.headers.get('content-type')?.split(';')[0];
358
+ const mimeType =
359
+ mediaTypeFromMime(headerMime) === 'unknown'
360
+ ? mimeFromExtension(path.extname(parsed.pathname).slice(1))
361
+ : headerMime;
362
+ const rangeTotal = response.headers.get('content-range')?.match(/\/(\d+)$/)?.[1];
363
+ await response.body?.cancel();
364
+ if (mediaTypeFromMime(mimeType) !== 'unknown') {
365
+ return {
366
+ originalUrl: url,
367
+ mimeType,
368
+ mediaType: mediaTypeFromMime(mimeType),
369
+ contentLength: Number(rangeTotal ?? response.headers.get('content-length')) || undefined,
370
+ durationSecs: Number(response.headers.get('content-duration')) || undefined,
371
+ entryCount: 1,
372
+ isYouTube: false,
373
+ };
374
+ }
375
+ } else if (response) {
376
+ await response.body?.cancel();
377
+ }
378
+
379
+ // Provider pages return HTML rather than media bytes. Inspect those through
380
+ // the same extractor used for YouTube; direct files stay on the fast path.
381
+ try {
382
+ const output = await runYtDlp(['--dump-single-json', '--simulate', '--no-playlist', url]);
383
+ const data = JSON.parse(output) as YouTubeInspectionData;
384
+ const mediaType: RemoteMediaType =
385
+ data.vcodec === 'none' && data.acodec && data.acodec !== 'none' ? 'audio' : 'video';
386
+ return {
387
+ originalUrl: url,
388
+ title: data.title,
389
+ mimeType: mimeFromExtension(data.ext ?? ''),
390
+ mediaType,
391
+ durationSecs: positiveDuration(data.duration),
392
+ singleItemDurationSecs: positiveDuration(data.duration),
393
+ singleItemUrl: url,
394
+ entryCount: 1,
395
+ isYouTube: false,
396
+ };
397
+ } catch (error) {
398
+ if (response && !response.ok)
399
+ throw new Error(`Unable to inspect media URL (${response.status}).`);
400
+ if (directInspectionError instanceof Error) throw directInspectionError;
401
+ throw new Error(
402
+ `The URL is not a direct media file or a supported video provider (${error instanceof Error ? error.message : 'inspection failed'}).`,
403
+ );
404
+ }
405
+ }
406
+
407
+ async function inspectPublicMediaUrl(url: string, init: RequestInit): Promise<Response> {
408
+ try {
409
+ return await fetchPublic(url, {
410
+ ...init,
411
+ signal: AbortSignal.timeout(MEDIA_URL_INSPECTION_TIMEOUT_MS),
412
+ });
413
+ } catch (error) {
414
+ if (error instanceof DOMException && error.name === 'TimeoutError') {
415
+ throw new Error(
416
+ 'The media URL did not respond within 7 seconds. Check that it is public and try again.',
417
+ );
418
+ }
419
+ throw error;
420
+ }
421
+ }
422
+
423
+ export async function importMediaUrl(
424
+ url: string,
425
+ options: {
426
+ outputDir: string;
427
+ maxBytes?: number;
428
+ playlistMax?: number;
429
+ onProgress?: (progress: MediaImportProgress) => void;
430
+ },
431
+ ): Promise<ImportedMedia[]> {
432
+ const parsed = validHttpUrl(url);
433
+ await fs.mkdir(options.outputDir, { recursive: true });
434
+ if (isYouTube(parsed)) {
435
+ return importExtractorMediaUrl(url, options, true);
436
+ }
437
+
438
+ const downloadStartedAt = Date.now();
439
+ options.onProgress?.({ message: 'Connecting to the media source…', elapsedSeconds: 0 });
440
+ const connectingHeartbeat = options.onProgress
441
+ ? setInterval(() => {
442
+ const elapsedSeconds = Math.floor((Date.now() - downloadStartedAt) / 1_000);
443
+ options.onProgress?.({
444
+ message: `Connecting to the media source · ${elapsedSeconds}s`,
445
+ elapsedSeconds,
446
+ });
447
+ }, 1_000)
448
+ : undefined;
449
+ let response: Response;
450
+ try {
451
+ response = await fetchPublic(url);
452
+ } catch (directDownloadError) {
453
+ try {
454
+ return await importExtractorMediaUrl(url, options, false);
455
+ } catch {
456
+ throw directDownloadError;
457
+ }
458
+ } finally {
459
+ if (connectingHeartbeat) clearInterval(connectingHeartbeat);
460
+ }
461
+ if (!response.ok || !response.body) {
462
+ await response.body?.cancel();
463
+ try {
464
+ return await importExtractorMediaUrl(url, options, false);
465
+ } catch {
466
+ throw new Error(`Media download failed (${response.status}).`);
467
+ }
468
+ }
469
+ const maxBytes = options.maxBytes ?? 500 * 1024 * 1024;
470
+ const declared = Number(response.headers.get('content-length'));
471
+ if (declared > maxBytes) throw new Error(`Media exceeds download limit of ${maxBytes} bytes.`);
472
+ const rawName = path.basename(new URL(response.url).pathname) || 'download';
473
+ const name = rawName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 180) || 'download';
474
+ const headerMime = response.headers.get('content-type')?.split(';')[0];
475
+ const mimeType =
476
+ mediaTypeFromMime(headerMime) === 'unknown'
477
+ ? mimeFromExtension(path.extname(name).slice(1))
478
+ : headerMime;
479
+ if (mediaTypeFromMime(mimeType) === 'unknown') {
480
+ await response.body.cancel();
481
+ return importExtractorMediaUrl(url, options, false);
482
+ }
483
+ const outputPath = path.join(options.outputDir, `${randomUUID()}-${name}`);
484
+ let bytes = 0;
485
+ let lastProgressAt = 0;
486
+ const hasKnownTotal = Number.isFinite(declared) && declared > 0;
487
+ const body = Readable.fromWeb(response.body as never);
488
+ body.on('data', (chunk: Buffer) => {
489
+ bytes += chunk.length;
490
+ if (bytes > maxBytes)
491
+ body.destroy(new Error(`Media exceeds download limit of ${maxBytes} bytes.`));
492
+ const now = Date.now();
493
+ const finished = hasKnownTotal && bytes >= declared;
494
+ if (!options.onProgress || (now - lastProgressAt < 500 && !finished)) return;
495
+ lastProgressAt = now;
496
+ const elapsedSeconds = Math.max(0, Math.floor((now - downloadStartedAt) / 1_000));
497
+ const bytesPerSecond = bytes / Math.max(1, (now - downloadStartedAt) / 1_000);
498
+ const estimatedRemainingSeconds = hasKnownTotal
499
+ ? Math.max(0, Math.round((declared - bytes) / Math.max(1, bytesPerSecond)))
500
+ : undefined;
501
+ options.onProgress({
502
+ ...(hasKnownTotal
503
+ ? { percent: Math.min(99, (bytes / declared) * 100), total: declared }
504
+ : {}),
505
+ current: bytes,
506
+ unit: 'bytes',
507
+ elapsedSeconds,
508
+ estimatedRemainingSeconds,
509
+ message: [
510
+ `Downloading media · ${formatByteCount(bytes)}${
511
+ hasKnownTotal ? ` / ${formatByteCount(declared)}` : ''
512
+ }`,
513
+ `${formatByteCount(bytesPerSecond)}/s`,
514
+ ].join(' · '),
515
+ });
516
+ });
517
+ try {
518
+ await pipeline(body, createWriteStream(outputPath, { flags: 'wx' }));
519
+ } catch (error) {
520
+ await fs.rm(outputPath, { force: true });
521
+ throw error;
522
+ }
523
+ return [
524
+ {
525
+ path: outputPath,
526
+ originalUrl: url,
527
+ title: name,
528
+ mimeType,
529
+ mediaType: mediaTypeFromMime(mimeType),
530
+ },
531
+ ];
532
+ }
533
+
534
+ async function importExtractorMediaUrl(
535
+ url: string,
536
+ options: {
537
+ outputDir: string;
538
+ playlistMax?: number;
539
+ onProgress?: (progress: MediaImportProgress) => void;
540
+ },
541
+ includeYouTubeCaptions: boolean,
542
+ ): Promise<ImportedMedia[]> {
543
+ const template = path.join(options.outputDir, '%(title).120B [%(id)s].%(ext)s');
544
+ let subtitleArgs: string[] = [];
545
+ if (includeYouTubeCaptions) {
546
+ try {
547
+ const metadata = JSON.parse(
548
+ await runYtDlp(['--dump-single-json', '--simulate', '--no-playlist', url]),
549
+ ) as YouTubeInspectionData;
550
+ const language = selectYoutubeCaptionLanguage(metadata);
551
+ if (language) {
552
+ subtitleArgs = [
553
+ '--write-subs',
554
+ '--write-auto-subs',
555
+ '--sub-langs',
556
+ language,
557
+ '--sub-format',
558
+ 'json3',
559
+ ];
560
+ }
561
+ } catch {
562
+ // The media download can still succeed and use speech-to-text + OCR.
563
+ }
564
+ }
565
+ const output = await runYtDlp(
566
+ [
567
+ '--no-playlist',
568
+ '--playlist-end',
569
+ String(options.playlistMax ?? 10),
570
+ '--socket-timeout',
571
+ '20',
572
+ '--retries',
573
+ '2',
574
+ '--newline',
575
+ '--format',
576
+ youtubeDownloadFormat(),
577
+ '--merge-output-format',
578
+ 'mp4',
579
+ ...subtitleArgs,
580
+ '-o',
581
+ template,
582
+ '--print',
583
+ `after_move:${ytDlpImportPrintTemplate()}`,
584
+ url,
585
+ ],
586
+ options.onProgress,
587
+ );
588
+ return Promise.all(
589
+ output
590
+ .split('\n')
591
+ .filter(Boolean)
592
+ .map(async (line) => {
593
+ const item = JSON.parse(line) as {
594
+ filepath: string;
595
+ title: string;
596
+ webpage_url?: string;
597
+ ext: string;
598
+ language?: string;
599
+ requested_subtitles?: Record<string, { filepath?: string }> | null;
600
+ };
601
+ const resolvedMediaPath = await resolveYtDlpMediaPath(item.filepath, options.outputDir);
602
+ const mediaPath = await normalizeExtractorPlaybackTimeline(resolvedMediaPath);
603
+ const mimeType =
604
+ mimeFromExtension(path.extname(mediaPath).slice(1)) ?? mimeFromExtension(item.ext);
605
+ const sourceTranscript = includeYouTubeCaptions
606
+ ? await readYoutubeSourceTranscript(
607
+ options.outputDir,
608
+ item.requested_subtitles,
609
+ item.language,
610
+ )
611
+ : undefined;
612
+ return {
613
+ path: mediaPath,
614
+ title: item.title,
615
+ originalUrl: item.webpage_url || url,
616
+ mimeType,
617
+ mediaType: mediaTypeFromMime(mimeType),
618
+ ...(sourceTranscript ? { sourceTranscript } : {}),
619
+ };
620
+ }),
621
+ );
622
+ }
623
+
624
+ /**
625
+ * Serialize the selected downloader fields as one object. Per-field templates
626
+ * use yt-dlp's bare `NA` placeholder for missing values, which is not JSON.
627
+ */
628
+ export function ytDlpImportPrintTemplate() {
629
+ return '%(.{filepath,title,webpage_url,ext,language,requested_subtitles})j';
630
+ }
631
+
632
+ export function selectYoutubeCaptionLanguage(data: YouTubeInspectionData): string | undefined {
633
+ const preferred = data.language?.toLowerCase();
634
+ const manual = Object.keys(data.subtitles ?? {}).filter((language) => language !== 'live_chat');
635
+ const automatic = Object.keys(data.automatic_captions ?? {}).filter(
636
+ (language) => language !== 'live_chat',
637
+ );
638
+ const exactOrPrefix = (languages: string[]) =>
639
+ languages.find((language) => language.toLowerCase() === preferred) ??
640
+ languages.find((language) => language.toLowerCase().startsWith(`${preferred}-`));
641
+ // Human-authored captions are the most reliable source transcript. When
642
+ // none exist, prefer YouTube's original-language automatic track instead
643
+ // of downloading every translated caption variant.
644
+ return (
645
+ (preferred ? exactOrPrefix(manual) : undefined) ??
646
+ (preferred
647
+ ? exactOrPrefix(automatic.filter((language) => language.endsWith('-orig')))
648
+ : undefined) ??
649
+ automatic.find((language) => language.endsWith('-orig')) ??
650
+ (preferred ? exactOrPrefix(automatic) : undefined) ??
651
+ manual[0] ??
652
+ automatic[0]
653
+ );
654
+ }
655
+
656
+ /**
657
+ * Some provider manifests contain a padded audio track (or stale container
658
+ * duration) that makes browsers report nearly twice the visible runtime.
659
+ * Remux only those demonstrably inconsistent files, using the video stream as
660
+ * the canonical clock and retaining the first audio stream when present.
661
+ */
662
+ async function normalizeExtractorPlaybackTimeline(mediaPath: string): Promise<string> {
663
+ let probe: MediaProbe;
664
+ try {
665
+ probe = await probeMedia(mediaPath);
666
+ } catch {
667
+ return mediaPath;
668
+ }
669
+ const videoDuration = probe.videoDurationSecs;
670
+ if (!videoDuration || !hasInflatedPlaybackTimeline(probe)) return mediaPath;
671
+
672
+ const extension = path.extname(mediaPath) || '.mp4';
673
+ const normalizedPath = `${mediaPath}.${randomUUID()}.normalized${extension}`;
674
+ const backupPath = `${mediaPath}.${randomUUID()}.original${extension}`;
675
+ const ffmpeg = process.env.LARKUP_FFMPEG_PATH?.trim() || 'ffmpeg';
676
+ try {
677
+ await runProcess(
678
+ ffmpeg,
679
+ [
680
+ '-nostdin',
681
+ '-hide_banner',
682
+ '-loglevel',
683
+ 'error',
684
+ '-i',
685
+ mediaPath,
686
+ '-map',
687
+ '0:v:0',
688
+ '-map',
689
+ '0:a:0?',
690
+ '-c',
691
+ 'copy',
692
+ '-t',
693
+ String(videoDuration),
694
+ '-avoid_negative_ts',
695
+ 'make_zero',
696
+ ...(extension.toLowerCase() === '.mp4' ? ['-movflags', '+faststart'] : []),
697
+ normalizedPath,
698
+ ],
699
+ undefined,
700
+ 30 * 60_000,
701
+ );
702
+ // Renaming over an existing file is not portable to Windows. Move the
703
+ // original aside first and restore it if the final move cannot complete.
704
+ await fs.rename(mediaPath, backupPath);
705
+ try {
706
+ await fs.rename(normalizedPath, mediaPath);
707
+ await fs.rm(backupPath, { force: true });
708
+ } catch (error) {
709
+ await fs.rename(backupPath, mediaPath).catch(() => undefined);
710
+ throw error;
711
+ }
712
+ } catch (error) {
713
+ await fs.rm(normalizedPath, { force: true }).catch(() => undefined);
714
+ let playableFileExists = false;
715
+ try {
716
+ await fs.access(mediaPath);
717
+ playableFileExists = true;
718
+ } catch {
719
+ try {
720
+ await fs.rename(backupPath, mediaPath);
721
+ playableFileExists = true;
722
+ } catch {
723
+ // Preserve the backup for the caller's isolated import cleanup rather
724
+ // than deleting the only remaining copy after a filesystem failure.
725
+ }
726
+ }
727
+ if (playableFileExists) await fs.rm(backupPath, { force: true }).catch(() => undefined);
728
+ console.warn('[media] Could not normalize an inconsistent provider timeline:', error);
729
+ }
730
+ return mediaPath;
731
+ }
732
+
733
+ interface YoutubeJson3 {
734
+ events?: Array<{
735
+ tStartMs?: number;
736
+ dDurationMs?: number;
737
+ segs?: Array<{ utf8?: string }>;
738
+ }>;
739
+ }
740
+
741
+ export function parseYoutubeJson3Transcript(data: YoutubeJson3) {
742
+ return (data.events ?? [])
743
+ .map((event) => {
744
+ const text = (event.segs ?? [])
745
+ .map((segment) => segment.utf8 ?? '')
746
+ .join('')
747
+ .replace(/\s+/g, ' ')
748
+ .trim();
749
+ const startSecs = Math.max(0, Number(event.tStartMs ?? 0) / 1_000);
750
+ const durationSecs = Math.max(0.001, Number(event.dDurationMs ?? 0) / 1_000);
751
+ return { text, startSecs, endSecs: startSecs + durationSecs };
752
+ })
753
+ .filter((chunk) => chunk.text);
754
+ }
755
+
756
+ async function readYoutubeSourceTranscript(
757
+ outputDir: string,
758
+ requestedSubtitles: Record<string, { filepath?: string }> | null | undefined,
759
+ preferredLanguage?: string,
760
+ ): Promise<ImportedMedia['sourceTranscript'] | undefined> {
761
+ const root = path.resolve(outputDir);
762
+ const requested = Object.entries(requestedSubtitles ?? {})
763
+ .map(([language, subtitle]) => ({ language, filepath: subtitle.filepath }))
764
+ .filter((item): item is { language: string; filepath: string } => Boolean(item.filepath))
765
+ .filter((item) => isPathWithinDirectory(path.resolve(item.filepath), root));
766
+ const scanned = (await fs.readdir(root))
767
+ .filter((name) => name.endsWith('.json3'))
768
+ .map((name) => ({
769
+ language: name.split('.').at(-2) || preferredLanguage || 'unknown',
770
+ filepath: path.join(root, name),
771
+ }));
772
+ const candidates = requested.length ? requested : scanned;
773
+ candidates.sort((left, right) => {
774
+ const preferred = preferredLanguage?.toLowerCase();
775
+ return (
776
+ Number(right.language.toLowerCase().startsWith(preferred ?? '')) -
777
+ Number(left.language.toLowerCase().startsWith(preferred ?? ''))
778
+ );
779
+ });
780
+ for (const candidate of candidates) {
781
+ try {
782
+ const chunks = parseYoutubeJson3Transcript(
783
+ JSON.parse(await fs.readFile(candidate.filepath, 'utf8')) as YoutubeJson3,
784
+ );
785
+ if (!chunks.length) continue;
786
+ return {
787
+ chunks,
788
+ fullText: chunks.map((chunk) => chunk.text).join(' '),
789
+ durationSecs: Math.max(...chunks.map((chunk) => chunk.endSecs)),
790
+ language: candidate.language,
791
+ origin: { kind: 'youtube-caption', provider: 'youtube', language: candidate.language },
792
+ };
793
+ } catch {
794
+ // A broken caption track must not block speech-to-text and frame OCR.
795
+ }
796
+ }
797
+ return undefined;
798
+ }
799
+
800
+ /**
801
+ * Prefer a browser-safe H.264 MP4 video plus M4A audio stream. `best` can
802
+ * otherwise select an adaptive video-only representation on some YouTube
803
+ * responses, leaving the browser with a volume control but no audio track.
804
+ * The combined-format fallbacks keep older and audio-less public videos usable.
805
+ */
806
+ export function youtubeDownloadFormat() {
807
+ return [
808
+ 'bestvideo*[height<=360][ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]',
809
+ 'best[height<=360][ext=mp4][vcodec!=none][acodec!=none]',
810
+ 'bestvideo*[height<=360]+bestaudio',
811
+ 'best[height<=360][vcodec!=none][acodec!=none]',
812
+ // Never silently persist a video-only fallback. If the source offers no
813
+ // audio-bearing format, surface that import failure instead of rendering
814
+ // a player with a misleading volume control and no sound.
815
+ 'best[vcodec!=none][acodec!=none]',
816
+ ].join('/');
817
+ }
818
+
819
+ /**
820
+ * `after_move:filepath` normally names yt-dlp's final file, but some releases
821
+ * can print a path that no longer exists after a format merge. The import
822
+ * directory is newly created for this one download, so a media file bearing the
823
+ * reported video id is a safe fallback without accepting paths outside it.
824
+ */
825
+ export async function resolveYtDlpMediaPath(reportedPath: string, outputDir: string) {
826
+ const root = path.resolve(outputDir);
827
+ const expected = path.resolve(reportedPath);
828
+ if (!isPathWithinDirectory(expected, root)) {
829
+ throw new Error('Video downloader returned a file outside its import directory.');
830
+ }
831
+ try {
832
+ const stat = await fs.stat(expected);
833
+ if (stat.isFile()) return expected;
834
+ } catch (error) {
835
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
836
+ }
837
+
838
+ const videoId = path.basename(reportedPath).match(/\[([\w-]+)\](?:\.[^.]+)?$/)?.[1];
839
+ const candidates = await fs.readdir(root, { withFileTypes: true });
840
+ const availableMedia = candidates
841
+ .filter(
842
+ (candidate) =>
843
+ candidate.isFile() && Boolean(mimeFromExtension(path.extname(candidate.name).slice(1))),
844
+ )
845
+ .map((candidate) => path.join(root, candidate.name))
846
+ .filter((candidate) => isPathWithinDirectory(candidate, root));
847
+ if (!availableMedia.length) {
848
+ throw new Error('Video download completed but did not produce a readable media file.');
849
+ }
850
+
851
+ const expectedExtension = path.extname(reportedPath).toLowerCase();
852
+ return availableMedia.sort(
853
+ (left, right) =>
854
+ scoreYtDlpMediaCandidate(right, videoId, expectedExtension) -
855
+ scoreYtDlpMediaCandidate(left, videoId, expectedExtension),
856
+ )[0];
857
+ }
858
+
859
+ function isPathWithinDirectory(candidate: string, directory: string) {
860
+ const relative = path.relative(directory, candidate);
861
+ return (
862
+ relative !== '' &&
863
+ !relative.startsWith(`..${path.sep}`) &&
864
+ relative !== '..' &&
865
+ !path.isAbsolute(relative)
866
+ );
867
+ }
868
+
869
+ function scoreYtDlpMediaCandidate(
870
+ candidate: string,
871
+ videoId: string | undefined,
872
+ expectedExtension: string,
873
+ ) {
874
+ const fileName = path.basename(candidate);
875
+ const extension = path.extname(fileName).toLowerCase();
876
+ const mimeType = mimeFromExtension(extension.slice(1));
877
+ return (
878
+ (videoId && fileName.includes(`[${videoId}]`) ? 100 : 0) +
879
+ (mimeType?.startsWith('video/') ? 20 : 0) +
880
+ (extension === expectedExtension ? 10 : 0) +
881
+ // yt-dlp names adaptive intermediate streams `.f137.mp4`, `.f140.m4a`,
882
+ // etc. Keep a final mux ahead of its intermediates, but never penalize a
883
+ // real video stream so heavily that an audio-only `.m4a` wins when a stale
884
+ // after-move path forces recovery from the import directory.
885
+ (/\.f\d+\.[^.]+$/i.test(fileName) ? -15 : 0)
886
+ );
887
+ }
888
+
889
+ async function runYtDlp(args: string[], onProgress?: (progress: MediaImportProgress) => void) {
890
+ const executable = process.env.LARKUP_YTDLP_PATH?.trim() || managedYtDlpPath();
891
+ const preparationStartedAt = Date.now();
892
+ onProgress?.({ message: 'Preparing the video downloader…', elapsedSeconds: 0 });
893
+ const preparationHeartbeat = onProgress
894
+ ? setInterval(() => {
895
+ const elapsedSeconds = Math.floor((Date.now() - preparationStartedAt) / 1_000);
896
+ onProgress({
897
+ message: `Preparing the video downloader · ${elapsedSeconds}s`,
898
+ elapsedSeconds,
899
+ });
900
+ }, 1_000)
901
+ : undefined;
902
+ try {
903
+ await ensureYtDlp(executable);
904
+ } finally {
905
+ if (preparationHeartbeat) clearInterval(preparationHeartbeat);
906
+ }
907
+ onProgress?.({ message: 'Connecting to the video source…', elapsedSeconds: 0 });
908
+ return runProcess(
909
+ executable,
910
+ ['--js-runtimes', 'node:node', ...args],
911
+ onProgress,
912
+ args.includes('--simulate') ? 120_000 : 6 * 60 * 60_000,
913
+ );
914
+ }
915
+
916
+ function managedYtDlpPath() {
917
+ const toolsRoot =
918
+ process.env.VERCEL || process.env.AWS_LAMBDA_FUNCTION_NAME
919
+ ? path.join(os.tmpdir(), 'larkup-tools')
920
+ : path.join(process.cwd(), '.larkup', 'tools');
921
+ return path.join(toolsRoot, 'bin', process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp');
922
+ }
923
+
924
+ async function ensureYtDlp(executable: string) {
925
+ if (process.env.LARKUP_YTDLP_PATH?.trim()) return;
926
+ try {
927
+ await fs.access(executable);
928
+ return;
929
+ } catch {
930
+ // Download the official standalone binary once; no optional npm package is involved.
931
+ }
932
+ const asset =
933
+ process.platform === 'darwin'
934
+ ? 'yt-dlp_macos'
935
+ : process.platform === 'win32'
936
+ ? 'yt-dlp.exe'
937
+ : process.arch === 'arm64'
938
+ ? 'yt-dlp_linux_aarch64'
939
+ : 'yt-dlp_linux';
940
+ const temporary = `${executable}.${randomUUID()}.download`;
941
+ try {
942
+ await fs.mkdir(path.dirname(executable), { recursive: true });
943
+ const response = await fetch(
944
+ `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`,
945
+ {
946
+ redirect: 'follow',
947
+ signal: AbortSignal.timeout(120_000),
948
+ },
949
+ );
950
+ if (!response.ok) throw new Error(`download returned HTTP ${response.status}`);
951
+ await fs.writeFile(temporary, Buffer.from(await response.arrayBuffer()), { mode: 0o755 });
952
+ if (process.platform !== 'win32') await fs.chmod(temporary, 0o755);
953
+ await fs.rename(temporary, executable);
954
+ } catch (error) {
955
+ await fs.rm(temporary, { force: true }).catch(() => undefined);
956
+ const message = error instanceof Error ? error.message : String(error);
957
+ throw new Error(`Could not prepare the built-in YouTube downloader (${message}).`);
958
+ }
959
+ }
960
+
961
+ function runProcess(
962
+ command: string,
963
+ args: string[],
964
+ onProgress?: (progress: MediaImportProgress) => void,
965
+ timeoutMs = 120_000,
966
+ ) {
967
+ return new Promise<string>((resolve, reject) => {
968
+ const child = spawn(command, args, { shell: false });
969
+ let stdout = '';
970
+ let stderr = '';
971
+ let progressBuffer = '';
972
+ const startedAt = Date.now();
973
+ let latestProgress: MediaImportProgress | undefined;
974
+ const timeout = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
975
+ const activityHeartbeat = onProgress
976
+ ? setInterval(() => {
977
+ const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1_000);
978
+ onProgress({
979
+ ...latestProgress,
980
+ elapsedSeconds,
981
+ message: latestProgress?.percent
982
+ ? `${latestProgress.message} · active ${elapsedSeconds}s`
983
+ : `Connecting to the video source · ${elapsedSeconds}s`,
984
+ });
985
+ }, 1_000)
986
+ : undefined;
987
+ child.stdout.on('data', (data) => (stdout += String(data)));
988
+ child.stderr.on('data', (data) => {
989
+ const text = String(data);
990
+ stderr += text;
991
+ progressBuffer += text;
992
+ const lines = progressBuffer.split(/[\r\n]+/);
993
+ progressBuffer = lines.pop() ?? '';
994
+ for (const line of lines) {
995
+ const progress = parseYtDlpProgress(line, Math.floor((Date.now() - startedAt) / 1_000));
996
+ if (!progress) continue;
997
+ latestProgress = progress;
998
+ onProgress?.(progress);
999
+ }
1000
+ });
1001
+ child.on('error', (error) => {
1002
+ clearTimeout(timeout);
1003
+ if (activityHeartbeat) clearInterval(activityHeartbeat);
1004
+ reject(error);
1005
+ });
1006
+ child.on('close', (code) => {
1007
+ clearTimeout(timeout);
1008
+ if (activityHeartbeat) clearInterval(activityHeartbeat);
1009
+ const finalProgress = parseYtDlpProgress(
1010
+ progressBuffer,
1011
+ Math.floor((Date.now() - startedAt) / 1_000),
1012
+ );
1013
+ if (finalProgress) onProgress?.(finalProgress);
1014
+ if (code === 0) resolve(stdout.trim());
1015
+ else reject(new Error(`${path.basename(command)} failed (${code}): ${stderr.trim()}`));
1016
+ });
1017
+ });
1018
+ }
1019
+
1020
+ export function parseYtDlpProgress(line: string, elapsedSeconds = 0): MediaImportProgress | null {
1021
+ const percentMatch = line.match(/(\d+(?:\.\d+)?)%/);
1022
+ if (!percentMatch) return null;
1023
+ const percent = Math.min(99, Math.max(0, Number(percentMatch[1])));
1024
+ if (!Number.isFinite(percent)) return null;
1025
+
1026
+ const totalMatch = line.match(/\bof\s+~?\s*([\d.]+)\s*(KiB|MiB|GiB|KB|MB|GB)\b/i);
1027
+ const total = totalMatch ? byteCount(Number(totalMatch[1]), totalMatch[2]) : undefined;
1028
+ const current = total ? (total * percent) / 100 : undefined;
1029
+ const speed = line.match(/\bat\s+([^\s]+\/s)\b/i)?.[1];
1030
+ const etaText = line.match(/\bETA\s+(\d{1,2}:\d{2}(?::\d{2})?)\b/i)?.[1];
1031
+ const estimatedRemainingSeconds = etaText ? durationTextSeconds(etaText) : undefined;
1032
+
1033
+ return {
1034
+ percent,
1035
+ ...(current !== undefined ? { current } : {}),
1036
+ ...(total !== undefined ? { total, unit: 'bytes' } : {}),
1037
+ elapsedSeconds,
1038
+ estimatedRemainingSeconds,
1039
+ message: [
1040
+ `Downloading video · ${formatProgressPercent(percent)}%`,
1041
+ speed,
1042
+ etaText ? `${etaText} left` : undefined,
1043
+ ]
1044
+ .filter(Boolean)
1045
+ .join(' · '),
1046
+ };
1047
+ }
1048
+
1049
+ function durationTextSeconds(value: string): number | undefined {
1050
+ const parts = value.split(':').map(Number);
1051
+ if (parts.some((part) => !Number.isFinite(part))) return undefined;
1052
+ if (parts.length === 2) return parts[0] * 60 + parts[1];
1053
+ if (parts.length === 3) return parts[0] * 3_600 + parts[1] * 60 + parts[2];
1054
+ return undefined;
1055
+ }
1056
+
1057
+ function byteCount(value: number, unit: string): number | undefined {
1058
+ if (!Number.isFinite(value) || value < 0) return undefined;
1059
+ const exponent = ({ KiB: 1, MiB: 2, GiB: 3, KB: 1, MB: 2, GB: 3 } as Record<string, number>)[
1060
+ unit
1061
+ ];
1062
+ if (exponent === undefined) return undefined;
1063
+ const base = unit.endsWith('iB') ? 1_024 : 1_000;
1064
+ return value * base ** exponent;
1065
+ }
1066
+
1067
+ function formatByteCount(bytes: number): string {
1068
+ if (!Number.isFinite(bytes) || bytes < 1_024) return `${Math.max(0, Math.round(bytes))} B`;
1069
+ if (bytes < 1_024 ** 2) return `${(bytes / 1_024).toFixed(1)} KiB`;
1070
+ if (bytes < 1_024 ** 3) return `${(bytes / 1_024 ** 2).toFixed(1)} MiB`;
1071
+ return `${(bytes / 1_024 ** 3).toFixed(2)} GiB`;
1072
+ }
1073
+
1074
+ function formatProgressPercent(percent: number): string {
1075
+ const rounded = Math.round(percent * 10) / 10;
1076
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
1077
+ }
1078
+
1079
+ function validHttpUrl(value: string) {
1080
+ const url = new URL(value);
1081
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
1082
+ throw new Error('Only http(s) media URLs are supported.');
1083
+ return url;
1084
+ }
1085
+
1086
+ async function fetchPublic(url: string, init: RequestInit = {}) {
1087
+ let current = validHttpUrl(url);
1088
+ for (let redirects = 0; redirects <= 5; redirects++) {
1089
+ await assertPublicHost(current.hostname);
1090
+ const response = await fetch(current, { ...init, redirect: 'manual' });
1091
+ if (![301, 302, 303, 307, 308].includes(response.status)) return response;
1092
+ const location = response.headers.get('location');
1093
+ if (!location) throw new Error('Media URL redirected without a location.');
1094
+ current = validHttpUrl(new URL(location, current).toString());
1095
+ }
1096
+ throw new Error('Media URL redirected too many times.');
1097
+ }
1098
+
1099
+ async function assertPublicHost(hostname: string) {
1100
+ const addresses = isIP(hostname)
1101
+ ? [{ address: hostname }]
1102
+ : await lookup(hostname, { all: true, verbatim: true });
1103
+ if (!addresses.length || addresses.some(({ address }) => isPrivateAddress(address))) {
1104
+ throw new Error('Private or local media URLs are not supported.');
1105
+ }
1106
+ }
1107
+
1108
+ function isPrivateAddress(address: string) {
1109
+ const normalized = address.toLowerCase();
1110
+ if (
1111
+ normalized === '::1' ||
1112
+ normalized === '::' ||
1113
+ normalized.startsWith('fe80:') ||
1114
+ normalized.startsWith('fc') ||
1115
+ normalized.startsWith('fd')
1116
+ )
1117
+ return true;
1118
+ const parts = (normalized.startsWith('::ffff:') ? normalized.slice(7) : normalized)
1119
+ .split('.')
1120
+ .map(Number);
1121
+ if (parts.length !== 4 || parts.some(Number.isNaN)) return false;
1122
+ const [a, b] = parts;
1123
+ return (
1124
+ a === 0 ||
1125
+ a === 10 ||
1126
+ a === 127 ||
1127
+ (a === 169 && b === 254) ||
1128
+ (a === 172 && b >= 16 && b <= 31) ||
1129
+ (a === 192 && b === 168) ||
1130
+ a >= 224
1131
+ );
1132
+ }
1133
+
1134
+ function isYouTube(url: URL) {
1135
+ return /(^|\.)youtube\.com$|(^|\.)youtu\.be$/.test(url.hostname);
1136
+ }
1137
+
1138
+ function mediaTypeFromMime(mime?: string): RemoteMediaType {
1139
+ return mime?.startsWith('video/') ? 'video' : mime?.startsWith('audio/') ? 'audio' : 'unknown';
1140
+ }
1141
+
1142
+ function mimeFromExtension(extension: string) {
1143
+ return (
1144
+ {
1145
+ mp4: 'video/mp4',
1146
+ webm: 'video/webm',
1147
+ mkv: 'video/x-matroska',
1148
+ mov: 'video/quicktime',
1149
+ mp3: 'audio/mpeg',
1150
+ m4a: 'audio/mp4',
1151
+ wav: 'audio/wav',
1152
+ ogg: 'audio/ogg',
1153
+ flac: 'audio/flac',
1154
+ } as Record<string, string>
1155
+ )[extension.toLowerCase()];
1156
+ }