@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.
- package/LICENSE +176 -0
- package/README.md +2 -2
- package/dist-types/app/api/chat/feedback/route.d.ts +4 -0
- package/dist-types/app/api/chat/feedback/route.d.ts.map +1 -0
- package/dist-types/app/api/chat/route.d.ts +3 -0
- package/dist-types/app/api/chat/route.d.ts.map +1 -0
- package/dist-types/app/api/chat/tools.d.ts +67 -0
- package/dist-types/app/api/chat/tools.d.ts.map +1 -0
- package/dist-types/app/api/config/test-embedding/route.d.ts +3 -0
- package/dist-types/app/api/config/test-embedding/route.d.ts.map +1 -0
- package/dist-types/app/api/config/verify/route.d.ts +3 -0
- package/dist-types/app/api/config/verify/route.d.ts.map +1 -0
- package/dist-types/app/api/describe-image/route.d.ts +4 -0
- package/dist-types/app/api/describe-image/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/edit/route.d.ts +10 -0
- package/dist-types/app/api/doc-editor/edit/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/export/route.d.ts +9 -0
- package/dist-types/app/api/doc-editor/export/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/parse/route.d.ts +10 -0
- package/dist-types/app/api/doc-editor/parse/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/restore/route.d.ts +2 -0
- package/dist-types/app/api/doc-editor/restore/route.d.ts.map +1 -0
- package/dist-types/app/api/document/sign/route.d.ts +2 -0
- package/dist-types/app/api/document/sign/route.d.ts.map +1 -0
- package/dist-types/app/api/files/remote/route.d.ts +5 -0
- package/dist-types/app/api/files/remote/route.d.ts.map +1 -0
- package/dist-types/app/api/mcp/[connectionId]/route.d.ts +9 -0
- package/dist-types/app/api/mcp/[connectionId]/route.d.ts.map +1 -0
- package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts +8 -0
- package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts.map +1 -0
- package/dist-types/app/api/mcp/route.d.ts +4 -0
- package/dist-types/app/api/mcp/route.d.ts.map +1 -0
- package/dist-types/app/api/parse-file/route.d.ts +6 -0
- package/dist-types/app/api/parse-file/route.d.ts.map +1 -0
- package/dist-types/app/api/pdf-page/route.d.ts +4 -0
- package/dist-types/app/api/pdf-page/route.d.ts.map +1 -0
- package/dist-types/app/api/proxy/verify/route.d.ts +4 -0
- package/dist-types/app/api/proxy/verify/route.d.ts.map +1 -0
- package/dist-types/app/api/search/bing/route.d.ts +5 -0
- package/dist-types/app/api/search/bing/route.d.ts.map +1 -0
- package/dist-types/app/api/search/brave/route.d.ts +5 -0
- package/dist-types/app/api/search/brave/route.d.ts.map +1 -0
- package/dist-types/app/api/search/exa/route.d.ts +5 -0
- package/dist-types/app/api/search/exa/route.d.ts.map +1 -0
- package/dist-types/app/api/search/google/route.d.ts +30 -0
- package/dist-types/app/api/search/google/route.d.ts.map +1 -0
- package/dist-types/app/api/search/route.d.ts +7 -0
- package/dist-types/app/api/search/route.d.ts.map +1 -0
- package/dist-types/app/api/search/tavily/route.d.ts +5 -0
- package/dist-types/app/api/search/tavily/route.d.ts.map +1 -0
- package/dist-types/app/api/search/verify/route.d.ts +4 -0
- package/dist-types/app/api/search/verify/route.d.ts.map +1 -0
- package/dist-types/app/api/tabular/route.d.ts +9 -0
- package/dist-types/app/api/tabular/route.d.ts.map +1 -0
- package/dist-types/lib/chat/assistant-source-scope.d.ts +10 -0
- package/dist-types/lib/chat/assistant-source-scope.d.ts.map +1 -0
- package/dist-types/lib/chat/conversation-memory.d.ts +80 -0
- package/dist-types/lib/chat/conversation-memory.d.ts.map +1 -0
- package/dist-types/lib/chat/gateway-fallbacks.d.ts +7 -0
- package/dist-types/lib/chat/gateway-fallbacks.d.ts.map +1 -0
- package/dist-types/lib/chat/image-description.d.ts +10 -0
- package/dist-types/lib/chat/image-description.d.ts.map +1 -0
- package/dist-types/lib/chat/media-retrieval-routing.d.ts +14 -0
- package/dist-types/lib/chat/media-retrieval-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/media-source-routing.d.ts +53 -0
- package/dist-types/lib/chat/media-source-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/message-input.d.ts +8 -0
- package/dist-types/lib/chat/message-input.d.ts.map +1 -0
- package/dist-types/lib/chat/model-provider.d.ts +23 -0
- package/dist-types/lib/chat/model-provider.d.ts.map +1 -0
- package/dist-types/lib/chat/response-style.d.ts +2 -0
- package/dist-types/lib/chat/response-style.d.ts.map +1 -0
- package/dist-types/lib/chat/retrieval-ranking.d.ts +32 -0
- package/dist-types/lib/chat/retrieval-ranking.d.ts.map +1 -0
- package/dist-types/lib/chat/retrieval-routing.d.ts +39 -0
- package/dist-types/lib/chat/retrieval-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-dataset-routing.d.ts +15 -0
- package/dist-types/lib/chat/tabular-dataset-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-query-plan.d.ts +12 -0
- package/dist-types/lib/chat/tabular-query-plan.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-routing.d.ts +41 -0
- package/dist-types/lib/chat/tabular-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-sandbox-files.d.ts +33 -0
- package/dist-types/lib/chat/tabular-sandbox-files.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-visualization.d.ts +31 -0
- package/dist-types/lib/chat/tabular-visualization.d.ts.map +1 -0
- package/dist-types/lib/chat/tool-call-repair.d.ts +11 -0
- package/dist-types/lib/chat/tool-call-repair.d.ts.map +1 -0
- package/dist-types/lib/chat/tool-context.d.ts +72 -0
- package/dist-types/lib/chat/tool-context.d.ts.map +1 -0
- package/dist-types/lib/chat/tool-registry.d.ts +3 -0
- package/dist-types/lib/chat/tool-registry.d.ts.map +1 -0
- package/dist-types/lib/chat/video-rag-routing.d.ts +33 -0
- package/dist-types/lib/chat/video-rag-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/visual-routing.d.ts +53 -0
- package/dist-types/lib/chat/visual-routing.d.ts.map +1 -0
- package/dist-types/lib/enterprise-client.d.ts +31 -0
- package/dist-types/lib/enterprise-client.d.ts.map +1 -0
- package/dist-types/lib/marketplace/tool-runtime-config.d.ts +7 -0
- package/dist-types/lib/marketplace/tool-runtime-config.d.ts.map +1 -0
- package/dist-types/lib/media/knowledge.d.ts +47 -0
- package/dist-types/lib/media/knowledge.d.ts.map +1 -0
- package/dist-types/lib/media/pdf-inspection.d.ts +30 -0
- package/dist-types/lib/media/pdf-inspection.d.ts.map +1 -0
- package/dist-types/lib/media/source-identity.d.ts +12 -0
- package/dist-types/lib/media/source-identity.d.ts.map +1 -0
- package/dist-types/lib/media/source-utils.d.ts +157 -0
- package/dist-types/lib/media/source-utils.d.ts.map +1 -0
- package/dist-types/lib/media/video/quick-look.d.ts +60 -0
- package/dist-types/lib/media/video/quick-look.d.ts.map +1 -0
- package/dist-types/lib/media/video-intelligence-adapter.d.ts +521 -0
- package/dist-types/lib/media/video-intelligence-adapter.d.ts.map +1 -0
- package/dist-types/lib/media/video-intelligence-connection.d.ts +32 -0
- package/dist-types/lib/media/video-intelligence-connection.d.ts.map +1 -0
- package/dist-types/lib/remote-files.d.ts +12 -0
- package/dist-types/lib/remote-files.d.ts.map +1 -0
- package/dist-types/lib/search/provider-verification.d.ts +5 -0
- package/dist-types/lib/search/provider-verification.d.ts.map +1 -0
- package/dist-types/runtime-host.d.ts +20 -0
- package/dist-types/runtime-host.d.ts.map +1 -0
- package/package.json +338 -3
- package/src/app/api/chat/feedback/route.ts +77 -0
- package/src/app/api/chat/route.ts +2277 -0
- package/src/app/api/chat/tools.ts +3563 -0
- package/src/app/api/config/test-embedding/route.ts +39 -0
- package/src/app/api/config/verify/route.ts +309 -0
- package/src/app/api/describe-image/route.ts +108 -0
- package/src/app/api/doc-editor/edit/route.ts +87 -0
- package/src/app/api/doc-editor/export/route.ts +32 -0
- package/src/app/api/doc-editor/parse/route.ts +74 -0
- package/src/app/api/doc-editor/restore/route.ts +70 -0
- package/src/app/api/document/sign/route.ts +55 -0
- package/src/app/api/files/remote/route.ts +32 -0
- package/src/app/api/mcp/[connectionId]/route.ts +29 -0
- package/src/app/api/mcp/[connectionId]/test/route.ts +13 -0
- package/src/app/api/mcp/route.ts +22 -0
- package/src/app/api/parse-file/route.ts +86 -0
- package/src/app/api/pdf-page/route.ts +40 -0
- package/src/app/api/proxy/verify/route.ts +78 -0
- package/src/app/api/search/bing/route.ts +86 -0
- package/src/app/api/search/brave/route.ts +88 -0
- package/src/app/api/search/exa/route.ts +100 -0
- package/src/app/api/search/google/route.ts +147 -0
- package/src/app/api/search/route.ts +28 -0
- package/src/app/api/search/tavily/route.ts +93 -0
- package/src/app/api/search/verify/route.ts +27 -0
- package/src/app/api/tabular/route.ts +68 -0
- package/src/lib/chat/assistant-source-scope.ts +32 -0
- package/src/lib/chat/conversation-memory.ts +621 -0
- package/src/lib/chat/gateway-fallbacks.ts +12 -0
- package/src/lib/chat/image-description.ts +21 -0
- package/src/lib/chat/media-retrieval-routing.ts +37 -0
- package/src/lib/chat/media-source-routing.ts +100 -0
- package/src/lib/chat/message-input.ts +48 -0
- package/src/lib/chat/model-provider.ts +157 -0
- package/src/lib/chat/response-style.ts +3 -0
- package/src/lib/chat/retrieval-ranking.ts +126 -0
- package/src/lib/chat/retrieval-routing.ts +221 -0
- package/src/lib/chat/tabular-dataset-routing.ts +93 -0
- package/src/lib/chat/tabular-query-plan.ts +195 -0
- package/src/lib/chat/tabular-routing.ts +122 -0
- package/src/lib/chat/tabular-sandbox-files.ts +78 -0
- package/src/lib/chat/tabular-visualization.ts +165 -0
- package/src/lib/chat/tool-call-repair.ts +84 -0
- package/src/lib/chat/tool-context.ts +1222 -0
- package/src/lib/chat/tool-registry.ts +6 -0
- package/src/lib/chat/video-rag-routing.ts +149 -0
- package/src/lib/chat/visual-routing.ts +225 -0
- package/src/lib/enterprise-client.ts +96 -0
- package/src/lib/marketplace/tool-runtime-config.ts +155 -0
- package/src/lib/media/knowledge.ts +489 -0
- package/src/lib/media/pdf-inspection.ts +280 -0
- package/src/lib/media/source-identity.ts +50 -0
- package/src/lib/media/source-utils.ts +1156 -0
- package/src/lib/media/video/quick-look.ts +368 -0
- package/src/lib/media/video-intelligence-adapter.ts +1946 -0
- package/src/lib/media/video-intelligence-connection.ts +151 -0
- package/src/lib/remote-files.ts +182 -0
- package/src/lib/search/provider-verification.ts +95 -0
- 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
|
+
}
|