@hasna/microservices 0.0.2 → 0.0.4
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/bin/index.js +70 -0
- package/bin/mcp.js +71 -1
- package/dist/index.js +70 -0
- package/microservices/microservice-ads/package.json +27 -0
- package/microservices/microservice-ads/src/cli/index.ts +407 -0
- package/microservices/microservice-ads/src/db/campaigns.ts +493 -0
- package/microservices/microservice-ads/src/db/database.ts +93 -0
- package/microservices/microservice-ads/src/db/migrations.ts +60 -0
- package/microservices/microservice-ads/src/index.ts +39 -0
- package/microservices/microservice-ads/src/mcp/index.ts +320 -0
- package/microservices/microservice-contracts/package.json +27 -0
- package/microservices/microservice-contracts/src/cli/index.ts +383 -0
- package/microservices/microservice-contracts/src/db/contracts.ts +496 -0
- package/microservices/microservice-contracts/src/db/database.ts +93 -0
- package/microservices/microservice-contracts/src/db/migrations.ts +58 -0
- package/microservices/microservice-contracts/src/index.ts +43 -0
- package/microservices/microservice-contracts/src/mcp/index.ts +308 -0
- package/microservices/microservice-domains/package.json +27 -0
- package/microservices/microservice-domains/src/cli/index.ts +438 -0
- package/microservices/microservice-domains/src/db/database.ts +93 -0
- package/microservices/microservice-domains/src/db/domains.ts +551 -0
- package/microservices/microservice-domains/src/db/migrations.ts +60 -0
- package/microservices/microservice-domains/src/index.ts +44 -0
- package/microservices/microservice-domains/src/mcp/index.ts +368 -0
- package/microservices/microservice-hiring/package.json +27 -0
- package/microservices/microservice-hiring/src/cli/index.ts +431 -0
- package/microservices/microservice-hiring/src/db/database.ts +93 -0
- package/microservices/microservice-hiring/src/db/hiring.ts +582 -0
- package/microservices/microservice-hiring/src/db/migrations.ts +68 -0
- package/microservices/microservice-hiring/src/index.ts +51 -0
- package/microservices/microservice-hiring/src/mcp/index.ts +464 -0
- package/microservices/microservice-payments/package.json +27 -0
- package/microservices/microservice-payments/src/cli/index.ts +357 -0
- package/microservices/microservice-payments/src/db/database.ts +93 -0
- package/microservices/microservice-payments/src/db/migrations.ts +63 -0
- package/microservices/microservice-payments/src/db/payments.ts +652 -0
- package/microservices/microservice-payments/src/index.ts +51 -0
- package/microservices/microservice-payments/src/mcp/index.ts +460 -0
- package/microservices/microservice-payroll/package.json +27 -0
- package/microservices/microservice-payroll/src/cli/index.ts +374 -0
- package/microservices/microservice-payroll/src/db/database.ts +93 -0
- package/microservices/microservice-payroll/src/db/migrations.ts +69 -0
- package/microservices/microservice-payroll/src/db/payroll.ts +741 -0
- package/microservices/microservice-payroll/src/index.ts +48 -0
- package/microservices/microservice-payroll/src/mcp/index.ts +420 -0
- package/microservices/microservice-shipping/package.json +27 -0
- package/microservices/microservice-shipping/src/cli/index.ts +398 -0
- package/microservices/microservice-shipping/src/db/database.ts +93 -0
- package/microservices/microservice-shipping/src/db/migrations.ts +61 -0
- package/microservices/microservice-shipping/src/db/shipping.ts +643 -0
- package/microservices/microservice-shipping/src/index.ts +53 -0
- package/microservices/microservice-shipping/src/mcp/index.ts +385 -0
- package/microservices/microservice-social/package.json +27 -0
- package/microservices/microservice-social/src/cli/index.ts +447 -0
- package/microservices/microservice-social/src/db/database.ts +93 -0
- package/microservices/microservice-social/src/db/migrations.ts +55 -0
- package/microservices/microservice-social/src/db/social.ts +672 -0
- package/microservices/microservice-social/src/index.ts +46 -0
- package/microservices/microservice-social/src/mcp/index.ts +435 -0
- package/microservices/microservice-subscriptions/package.json +27 -0
- package/microservices/microservice-subscriptions/src/cli/index.ts +400 -0
- package/microservices/microservice-subscriptions/src/db/database.ts +93 -0
- package/microservices/microservice-subscriptions/src/db/migrations.ts +57 -0
- package/microservices/microservice-subscriptions/src/db/subscriptions.ts +692 -0
- package/microservices/microservice-subscriptions/src/index.ts +41 -0
- package/microservices/microservice-subscriptions/src/mcp/index.ts +365 -0
- package/microservices/microservice-transcriber/package.json +28 -0
- package/microservices/microservice-transcriber/src/cli/index.ts +1347 -0
- package/microservices/microservice-transcriber/src/db/annotations.ts +37 -0
- package/microservices/microservice-transcriber/src/db/database.ts +82 -0
- package/microservices/microservice-transcriber/src/db/migrations.ts +72 -0
- package/microservices/microservice-transcriber/src/db/transcripts.ts +395 -0
- package/microservices/microservice-transcriber/src/index.ts +43 -0
- package/microservices/microservice-transcriber/src/lib/config.ts +77 -0
- package/microservices/microservice-transcriber/src/lib/diff.ts +91 -0
- package/microservices/microservice-transcriber/src/lib/downloader.ts +570 -0
- package/microservices/microservice-transcriber/src/lib/feeds.ts +62 -0
- package/microservices/microservice-transcriber/src/lib/live.ts +94 -0
- package/microservices/microservice-transcriber/src/lib/notion.ts +129 -0
- package/microservices/microservice-transcriber/src/lib/providers.ts +713 -0
- package/microservices/microservice-transcriber/src/lib/summarizer.ts +147 -0
- package/microservices/microservice-transcriber/src/lib/translator.ts +75 -0
- package/microservices/microservice-transcriber/src/lib/webhook.ts +37 -0
- package/microservices/microservice-transcriber/src/mcp/index.ts +1070 -0
- package/microservices/microservice-transcriber/src/server/index.ts +199 -0
- package/package.json +1 -1
- package/microservices/microservice-invoices/dashboard/dist/assets/index-Bngq7FNM.css +0 -1
- package/microservices/microservice-invoices/dashboard/dist/assets/index-aHW4ARZR.js +0 -124
- package/microservices/microservice-invoices/dashboard/dist/index.html +0 -13
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio downloader — detects source type and extracts audio for transcription.
|
|
3
|
+
* Uses yt-dlp for YouTube, Vimeo, Wistia, and other URL-based sources.
|
|
4
|
+
* Uses ffmpeg for local file trimming.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { homedir, tmpdir } from "node:os";
|
|
10
|
+
import type { TranscriptSourceType } from "../db/transcripts.js";
|
|
11
|
+
|
|
12
|
+
export interface VideoChapter {
|
|
13
|
+
title: string;
|
|
14
|
+
start_time: number;
|
|
15
|
+
end_time: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DownloadResult {
|
|
19
|
+
filePath: string;
|
|
20
|
+
sourceType: TranscriptSourceType;
|
|
21
|
+
videoTitle: string | null;
|
|
22
|
+
chapters: VideoChapter[];
|
|
23
|
+
cleanup: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TrimOptions {
|
|
27
|
+
start?: number; // seconds
|
|
28
|
+
end?: number; // seconds
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detect source type from a URL or file path.
|
|
33
|
+
*/
|
|
34
|
+
export function detectSourceType(source: string): TranscriptSourceType {
|
|
35
|
+
if (!source.startsWith("http://") && !source.startsWith("https://")) {
|
|
36
|
+
return "file";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const lower = source.toLowerCase();
|
|
40
|
+
if (lower.includes("youtube.com") || lower.includes("youtu.be")) return "youtube";
|
|
41
|
+
if (lower.includes("vimeo.com")) return "vimeo";
|
|
42
|
+
if (lower.includes("wistia.com") || lower.includes("wi.st") || lower.includes("wistia.net")) return "wistia";
|
|
43
|
+
return "url";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Download or locate audio from a source (URL or file path).
|
|
48
|
+
* Optionally trims to a time range using --start / --end (seconds).
|
|
49
|
+
*
|
|
50
|
+
* For URLs: yt-dlp --download-sections handles the trim (avoids downloading unused parts).
|
|
51
|
+
* For local files: ffmpeg -ss / -to handles the trim.
|
|
52
|
+
*/
|
|
53
|
+
export async function prepareAudio(source: string, trim?: TrimOptions): Promise<DownloadResult> {
|
|
54
|
+
const sourceType = detectSourceType(source);
|
|
55
|
+
|
|
56
|
+
if (sourceType === "file") {
|
|
57
|
+
if (!existsSync(source)) {
|
|
58
|
+
throw new Error(`File not found: ${source}`);
|
|
59
|
+
}
|
|
60
|
+
if (trim?.start !== undefined || trim?.end !== undefined) {
|
|
61
|
+
const trimmed = await trimLocalFile(source, trim);
|
|
62
|
+
return {
|
|
63
|
+
filePath: trimmed,
|
|
64
|
+
sourceType,
|
|
65
|
+
videoTitle: null,
|
|
66
|
+
chapters: [],
|
|
67
|
+
cleanup: () => {
|
|
68
|
+
try { if (existsSync(trimmed)) unlinkSync(trimmed); } catch {}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
filePath: source,
|
|
74
|
+
sourceType,
|
|
75
|
+
videoTitle: null,
|
|
76
|
+
chapters: [],
|
|
77
|
+
cleanup: () => {},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Remote source — download full audio and fetch metadata in parallel
|
|
82
|
+
const tempId = crypto.randomUUID();
|
|
83
|
+
const outputTemplate = join(tmpdir(), `transcriber-${tempId}.%(ext)s`);
|
|
84
|
+
|
|
85
|
+
const [, info] = await Promise.all([
|
|
86
|
+
runYtDlp(source, outputTemplate),
|
|
87
|
+
getVideoInfo(source).catch(() => null),
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
const downloadedPath = findDownloadedFile(tmpdir(), `transcriber-${tempId}`);
|
|
91
|
+
if (!downloadedPath) {
|
|
92
|
+
throw new Error(`yt-dlp did not produce an output file for: ${source}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Trim locally with ffmpeg if start/end provided (more reliable than --download-sections)
|
|
96
|
+
if (trim?.start !== undefined || trim?.end !== undefined) {
|
|
97
|
+
const trimmedPath = await trimLocalFile(downloadedPath, trim);
|
|
98
|
+
try { unlinkSync(downloadedPath); } catch {} // clean up full download
|
|
99
|
+
return {
|
|
100
|
+
filePath: trimmedPath,
|
|
101
|
+
sourceType,
|
|
102
|
+
videoTitle: info?.title ?? null,
|
|
103
|
+
chapters: info?.chapters ?? [],
|
|
104
|
+
cleanup: () => {
|
|
105
|
+
try { if (existsSync(trimmedPath)) unlinkSync(trimmedPath); } catch {}
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
filePath: downloadedPath,
|
|
112
|
+
sourceType,
|
|
113
|
+
videoTitle: info?.title ?? null,
|
|
114
|
+
chapters: info?.chapters ?? [],
|
|
115
|
+
cleanup: () => {
|
|
116
|
+
try { if (existsSync(downloadedPath)) unlinkSync(downloadedPath); } catch {}
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Quick title fetch using yt-dlp --print — no download, very fast.
|
|
123
|
+
*/
|
|
124
|
+
async function fetchVideoTitle(url: string): Promise<string | null> {
|
|
125
|
+
try {
|
|
126
|
+
const proc = Bun.spawn(
|
|
127
|
+
[ytdlp(), "--print", "%(title)s", "--no-download", url],
|
|
128
|
+
{ stdout: "pipe", stderr: "pipe" }
|
|
129
|
+
);
|
|
130
|
+
const [exitCode, stdout] = await Promise.all([
|
|
131
|
+
proc.exited,
|
|
132
|
+
new Response(proc.stdout).text(),
|
|
133
|
+
]);
|
|
134
|
+
if (exitCode !== 0) return null;
|
|
135
|
+
const title = stdout.trim();
|
|
136
|
+
return title.length > 0 && title !== "NA" ? title : null;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resolve yt-dlp binary — prefers homebrew version (usually newer) over pip.
|
|
144
|
+
*/
|
|
145
|
+
function getYtDlpBinary(): string {
|
|
146
|
+
const home = process.env["HOME"] ?? "";
|
|
147
|
+
const candidates = [`${home}/.local/bin/yt-dlp-nightly`, "/opt/homebrew/bin/yt-dlp", "yt-dlp"];
|
|
148
|
+
for (const bin of candidates) {
|
|
149
|
+
try {
|
|
150
|
+
const proc = Bun.spawnSync([bin, "--version"], { stdout: "pipe", stderr: "pipe" });
|
|
151
|
+
if (proc.exitCode === 0) return bin;
|
|
152
|
+
} catch {}
|
|
153
|
+
}
|
|
154
|
+
return "yt-dlp";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let _ytdlpBin: string | null = null;
|
|
158
|
+
function ytdlp(): string {
|
|
159
|
+
if (!_ytdlpBin) _ytdlpBin = getYtDlpBinary();
|
|
160
|
+
return _ytdlpBin;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function runYtDlp(url: string, outputTemplate: string): Promise<void> {
|
|
164
|
+
const args = [ytdlp(), "-x", "--audio-format", "mp3", "--audio-quality", "0", "-o", outputTemplate, url];
|
|
165
|
+
|
|
166
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
167
|
+
const exitCode = await proc.exited;
|
|
168
|
+
if (exitCode !== 0) {
|
|
169
|
+
const stderr = await new Response(proc.stderr).text();
|
|
170
|
+
throw new Error(`yt-dlp failed (exit ${exitCode}): ${stderr.trim()}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function trimLocalFile(filePath: string, trim: TrimOptions): Promise<string> {
|
|
175
|
+
const tempId = crypto.randomUUID();
|
|
176
|
+
const ext = filePath.split(".").pop() ?? "mp3";
|
|
177
|
+
const outPath = join(tmpdir(), `transcriber-trim-${tempId}.${ext}`);
|
|
178
|
+
|
|
179
|
+
const args = ["ffmpeg", "-y", "-i", filePath];
|
|
180
|
+
if (trim.start !== undefined) args.push("-ss", String(trim.start));
|
|
181
|
+
if (trim.end !== undefined) args.push("-to", String(trim.end));
|
|
182
|
+
args.push("-c", "copy", outPath);
|
|
183
|
+
|
|
184
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
185
|
+
const exitCode = await proc.exited;
|
|
186
|
+
if (exitCode !== 0) {
|
|
187
|
+
const stderr = await new Response(proc.stderr).text();
|
|
188
|
+
throw new Error(`ffmpeg trim failed (exit ${exitCode}): ${stderr.trim()}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return outPath;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function findDownloadedFile(dir: string, prefix: string): string | null {
|
|
195
|
+
const extensions = ["mp3", "m4a", "ogg", "opus", "wav", "webm", "flac"];
|
|
196
|
+
for (const ext of extensions) {
|
|
197
|
+
const candidate = join(dir, `${prefix}.${ext}`);
|
|
198
|
+
if (existsSync(candidate)) return candidate;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface VideoInfo {
|
|
204
|
+
title: string | null;
|
|
205
|
+
duration: number | null; // seconds
|
|
206
|
+
uploader: string | null;
|
|
207
|
+
platform: string | null;
|
|
208
|
+
description: string | null;
|
|
209
|
+
thumbnail: string | null;
|
|
210
|
+
upload_date: string | null; // YYYYMMDD
|
|
211
|
+
view_count: number | null;
|
|
212
|
+
chapters: Array<{ title: string; start_time: number; end_time: number }>;
|
|
213
|
+
formats: Array<{ format_id: string; ext: string; resolution: string | null }>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Fetch video metadata without downloading. Uses yt-dlp --dump-json.
|
|
218
|
+
* Only works for URLs — returns null for local files.
|
|
219
|
+
*/
|
|
220
|
+
export async function getVideoInfo(url: string): Promise<VideoInfo> {
|
|
221
|
+
const proc = Bun.spawn(
|
|
222
|
+
[ytdlp(), "--dump-json", "--no-download", url],
|
|
223
|
+
{ stdout: "pipe", stderr: "pipe" }
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
227
|
+
proc.exited,
|
|
228
|
+
new Response(proc.stdout).text(),
|
|
229
|
+
new Response(proc.stderr).text(),
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
if (exitCode !== 0) {
|
|
233
|
+
throw new Error(`yt-dlp info failed (exit ${exitCode}): ${stderr.trim()}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let raw: Record<string, unknown>;
|
|
237
|
+
try {
|
|
238
|
+
raw = JSON.parse(stdout);
|
|
239
|
+
} catch {
|
|
240
|
+
throw new Error(`yt-dlp returned invalid JSON: ${stdout.slice(0, 200)}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const chapters = Array.isArray(raw["chapters"])
|
|
244
|
+
? (raw["chapters"] as Array<{ title: string; start_time: number; end_time: number }>).map((c) => ({
|
|
245
|
+
title: c.title,
|
|
246
|
+
start_time: c.start_time,
|
|
247
|
+
end_time: c.end_time,
|
|
248
|
+
}))
|
|
249
|
+
: [];
|
|
250
|
+
|
|
251
|
+
const formats = Array.isArray(raw["formats"])
|
|
252
|
+
? (raw["formats"] as Array<{ format_id: string; ext: string; resolution?: string | null }>)
|
|
253
|
+
.slice(-10) // last 10 formats (usually best quality last)
|
|
254
|
+
.map((f) => ({ format_id: f.format_id, ext: f.ext, resolution: f.resolution ?? null }))
|
|
255
|
+
: [];
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
title: (raw["title"] as string) ?? null,
|
|
259
|
+
duration: typeof raw["duration"] === "number" ? raw["duration"] : null,
|
|
260
|
+
uploader: (raw["uploader"] as string) ?? (raw["channel"] as string) ?? null,
|
|
261
|
+
platform: (raw["extractor_key"] as string) ?? (raw["ie_key"] as string) ?? null,
|
|
262
|
+
description: typeof raw["description"] === "string" ? raw["description"].slice(0, 500) : null,
|
|
263
|
+
thumbnail: (raw["thumbnail"] as string) ?? null,
|
|
264
|
+
upload_date: (raw["upload_date"] as string) ?? null,
|
|
265
|
+
view_count: typeof raw["view_count"] === "number" ? raw["view_count"] : null,
|
|
266
|
+
chapters,
|
|
267
|
+
formats,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface DownloadAudioOptions {
|
|
272
|
+
format?: "mp3" | "m4a" | "wav";
|
|
273
|
+
outputPath?: string; // explicit output file path (overrides auto-naming)
|
|
274
|
+
trim?: TrimOptions;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface DownloadAudioResult {
|
|
278
|
+
filePath: string;
|
|
279
|
+
sourceType: TranscriptSourceType;
|
|
280
|
+
title: string | null;
|
|
281
|
+
duration: number | null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Resolve the base audio output directory:
|
|
286
|
+
* .microservices/microservice-transcriber/audio/ (walks up from cwd, or falls back to home).
|
|
287
|
+
*/
|
|
288
|
+
export function getAudioOutputDir(): string {
|
|
289
|
+
if (process.env["MICROSERVICES_DIR"]) {
|
|
290
|
+
return join(process.env["MICROSERVICES_DIR"], "microservice-transcriber", "audio");
|
|
291
|
+
}
|
|
292
|
+
let dir = resolve(process.cwd());
|
|
293
|
+
while (true) {
|
|
294
|
+
const msDir = join(dir, ".microservices");
|
|
295
|
+
if (existsSync(msDir)) return join(msDir, "microservice-transcriber", "audio");
|
|
296
|
+
const parent = dirname(dir);
|
|
297
|
+
if (parent === dir) break;
|
|
298
|
+
dir = parent;
|
|
299
|
+
}
|
|
300
|
+
return join(homedir(), ".microservices", "microservice-transcriber", "audio");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Download video (not just audio) from a URL for clip extraction.
|
|
305
|
+
* Returns path to temp video file.
|
|
306
|
+
*/
|
|
307
|
+
export async function downloadVideo(url: string): Promise<{ path: string; cleanup: () => void }> {
|
|
308
|
+
const tempId = crypto.randomUUID();
|
|
309
|
+
const outTemplate = join(tmpdir(), `transcriber-vid-${tempId}.%(ext)s`);
|
|
310
|
+
|
|
311
|
+
const proc = Bun.spawn(
|
|
312
|
+
[ytdlp(), "-f", "best[ext=mp4]/best", "-o", outTemplate, url],
|
|
313
|
+
{ stdout: "pipe", stderr: "pipe" }
|
|
314
|
+
);
|
|
315
|
+
const exitCode = await proc.exited;
|
|
316
|
+
if (exitCode !== 0) {
|
|
317
|
+
const stderr = await new Response(proc.stderr).text();
|
|
318
|
+
throw new Error(`yt-dlp video download failed: ${stderr.trim()}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const extensions = ["mp4", "webm", "mkv", "avi"];
|
|
322
|
+
for (const ext of extensions) {
|
|
323
|
+
const candidate = join(tmpdir(), `transcriber-vid-${tempId}.${ext}`);
|
|
324
|
+
if (existsSync(candidate)) {
|
|
325
|
+
return { path: candidate, cleanup: () => { try { unlinkSync(candidate); } catch {} } };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
throw new Error("yt-dlp did not produce a video file");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Create a video/audio clip with optional burned-in subtitles using ffmpeg.
|
|
333
|
+
*/
|
|
334
|
+
export async function createClip(options: {
|
|
335
|
+
videoPath: string;
|
|
336
|
+
start: number;
|
|
337
|
+
end: number;
|
|
338
|
+
subtitlePath?: string; // ASS file path to burn in
|
|
339
|
+
outputPath: string;
|
|
340
|
+
}): Promise<void> {
|
|
341
|
+
const args = ["ffmpeg", "-y", "-i", options.videoPath, "-ss", String(options.start), "-to", String(options.end)];
|
|
342
|
+
|
|
343
|
+
if (options.subtitlePath) {
|
|
344
|
+
// Burn in subtitles — need to escape path for ffmpeg filter
|
|
345
|
+
const escaped = options.subtitlePath.replace(/[\\:]/g, "\\$&");
|
|
346
|
+
args.push("-vf", `subtitles=${escaped}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
args.push("-c:a", "aac", options.outputPath);
|
|
350
|
+
|
|
351
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
352
|
+
const exitCode = await proc.exited;
|
|
353
|
+
if (exitCode !== 0) {
|
|
354
|
+
const stderr = await new Response(proc.stderr).text();
|
|
355
|
+
throw new Error(`ffmpeg clip failed: ${stderr.trim().slice(-200)}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Detect whether a URL is a playlist (YouTube, etc.).
|
|
361
|
+
*/
|
|
362
|
+
export function isPlaylistUrl(url: string): boolean {
|
|
363
|
+
return url.includes("list=") || url.includes("/playlist");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Extract individual video URLs from a playlist using yt-dlp --flat-playlist.
|
|
368
|
+
*/
|
|
369
|
+
export async function getPlaylistUrls(url: string): Promise<Array<{ url: string; title: string | null }>> {
|
|
370
|
+
const proc = Bun.spawn(
|
|
371
|
+
[ytdlp(), "--flat-playlist", "--dump-json", "--no-download", url],
|
|
372
|
+
{ stdout: "pipe", stderr: "pipe" }
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const [exitCode, stdout] = await Promise.all([
|
|
376
|
+
proc.exited,
|
|
377
|
+
new Response(proc.stdout).text(),
|
|
378
|
+
]);
|
|
379
|
+
|
|
380
|
+
if (exitCode !== 0) {
|
|
381
|
+
const stderr = await new Response(proc.stderr).text();
|
|
382
|
+
throw new Error(`yt-dlp playlist failed (exit ${exitCode}): ${stderr.trim()}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Each line is a JSON object for one video
|
|
386
|
+
return stdout
|
|
387
|
+
.trim()
|
|
388
|
+
.split("\n")
|
|
389
|
+
.filter(Boolean)
|
|
390
|
+
.map((line) => {
|
|
391
|
+
try {
|
|
392
|
+
const entry = JSON.parse(line);
|
|
393
|
+
const videoUrl = entry.url
|
|
394
|
+
? (entry.url.startsWith("http") ? entry.url : `https://www.youtube.com/watch?v=${entry.id || entry.url}`)
|
|
395
|
+
: `https://www.youtube.com/watch?v=${entry.id}`;
|
|
396
|
+
return { url: videoUrl, title: entry.title ?? null };
|
|
397
|
+
} catch {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
.filter(Boolean) as Array<{ url: string; title: string | null }>;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Generate a 6-character random alphanumeric suffix for collision avoidance.
|
|
406
|
+
*/
|
|
407
|
+
function nanoSuffix(): string {
|
|
408
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
409
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(6)))
|
|
410
|
+
.map((b) => chars[b % chars.length])
|
|
411
|
+
.join("");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Normalize a title into a safe, lowercase, hyphenated filename stem.
|
|
416
|
+
*
|
|
417
|
+
* Rules:
|
|
418
|
+
* - Lowercase
|
|
419
|
+
* - & → "and"
|
|
420
|
+
* - Spaces and underscores → hyphens
|
|
421
|
+
* - Strip everything except alphanumeric, hyphens, dots
|
|
422
|
+
* - Collapse consecutive hyphens
|
|
423
|
+
* - Strip leading/trailing hyphens
|
|
424
|
+
* - Max 80 chars
|
|
425
|
+
* - Append 6-char nanoid suffix for collision avoidance
|
|
426
|
+
*
|
|
427
|
+
* Examples:
|
|
428
|
+
* "My Awesome Video! (2024)" → "my-awesome-video-2024-a3k9mz"
|
|
429
|
+
* "C++ Tutorial: Part 1/3" → "c-tutorial-part-1-3-b7xq2p"
|
|
430
|
+
* "Cats & Dogs Forever" → "cats-and-dogs-forever-m4nk8r"
|
|
431
|
+
*/
|
|
432
|
+
export function normalizeFilename(title: string): string {
|
|
433
|
+
let s = title.toLowerCase();
|
|
434
|
+
s = s.replace(/&/g, "and"); // & → and
|
|
435
|
+
s = s.replace(/[/_:,;|.]+/g, "-"); // separators → hyphens (/, :, _, ., etc.)
|
|
436
|
+
s = s.replace(/[_\s]+/g, "-"); // spaces/underscores → hyphens
|
|
437
|
+
s = s.replace(/[^a-z0-9-]/g, ""); // strip everything else (!, ?, +, (, ), etc.)
|
|
438
|
+
s = s.replace(/-{2,}/g, "-"); // collapse multiple hyphens
|
|
439
|
+
s = s.replace(/^-+|-+$/g, ""); // strip leading/trailing hyphens
|
|
440
|
+
s = s.slice(0, 80); // max 80 chars
|
|
441
|
+
s = s.replace(/-+$/, ""); // clean trailing hyphens after slice
|
|
442
|
+
const suffix = nanoSuffix();
|
|
443
|
+
return s.length > 0 ? `${s}-${suffix}` : suffix;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Download audio from a URL and save to the audio library.
|
|
448
|
+
* Does NOT transcribe — just extracts audio.
|
|
449
|
+
*/
|
|
450
|
+
export async function downloadAudio(
|
|
451
|
+
url: string,
|
|
452
|
+
options: DownloadAudioOptions = {}
|
|
453
|
+
): Promise<DownloadAudioResult> {
|
|
454
|
+
const sourceType = detectSourceType(url);
|
|
455
|
+
if (sourceType === "file") {
|
|
456
|
+
throw new Error("Use a URL for download. Local files don't need downloading.");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const format = options.format ?? "mp3";
|
|
460
|
+
|
|
461
|
+
// Fetch title and duration in parallel before downloading
|
|
462
|
+
const info = await getVideoInfo(url).catch(() => null);
|
|
463
|
+
const title = info?.title ?? null;
|
|
464
|
+
const duration = info?.duration ?? null;
|
|
465
|
+
|
|
466
|
+
// Determine output path
|
|
467
|
+
let outPath: string;
|
|
468
|
+
if (options.outputPath) {
|
|
469
|
+
outPath = options.outputPath;
|
|
470
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
471
|
+
} else {
|
|
472
|
+
const platform = sourceType; // already lowercase alphanum (file/youtube/vimeo/etc.)
|
|
473
|
+
const fileName = title ? normalizeFilename(title) : nanoSuffix();
|
|
474
|
+
const audioDir = join(getAudioOutputDir(), platform);
|
|
475
|
+
mkdirSync(audioDir, { recursive: true });
|
|
476
|
+
outPath = join(audioDir, `${fileName}.${format}`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const args = [ytdlp(), "-x", "--audio-format", format, "--audio-quality", "0", "-o", outPath, url];
|
|
480
|
+
|
|
481
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
482
|
+
const exitCode = await proc.exited;
|
|
483
|
+
if (exitCode !== 0) {
|
|
484
|
+
const stderr = await new Response(proc.stderr).text();
|
|
485
|
+
throw new Error(`yt-dlp failed (exit ${exitCode}): ${stderr.trim()}`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// yt-dlp may adjust extension; find actual file
|
|
489
|
+
let actualPath = existsSync(outPath) ? outPath : findDownloadedFile(dirname(outPath), outPath.replace(/\.[^.]+$/, "").split("/").pop()!) ?? outPath;
|
|
490
|
+
|
|
491
|
+
// Trim locally after download (more reliable than --download-sections)
|
|
492
|
+
if (options.trim?.start !== undefined || options.trim?.end !== undefined) {
|
|
493
|
+
const trimmedPath = await trimLocalFile(actualPath, options.trim);
|
|
494
|
+
try { unlinkSync(actualPath); } catch {}
|
|
495
|
+
actualPath = trimmedPath;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return { filePath: actualPath, sourceType, title, duration };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Get audio file duration in seconds using ffprobe.
|
|
503
|
+
*/
|
|
504
|
+
export async function getAudioDuration(filePath: string): Promise<number> {
|
|
505
|
+
const proc = Bun.spawn(
|
|
506
|
+
["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", filePath],
|
|
507
|
+
{ stdout: "pipe", stderr: "pipe" }
|
|
508
|
+
);
|
|
509
|
+
const [exitCode, stdout] = await Promise.all([
|
|
510
|
+
proc.exited,
|
|
511
|
+
new Response(proc.stdout).text(),
|
|
512
|
+
]);
|
|
513
|
+
if (exitCode !== 0) throw new Error("ffprobe failed to get audio duration");
|
|
514
|
+
const duration = parseFloat(stdout.trim());
|
|
515
|
+
return isNaN(duration) ? 0 : duration;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Split an audio file into chunks of `chunkDurationSec` seconds.
|
|
520
|
+
* Returns array of temp file paths + their start offsets.
|
|
521
|
+
*/
|
|
522
|
+
export async function splitAudioIntoChunks(
|
|
523
|
+
filePath: string,
|
|
524
|
+
chunkDurationSec = 600 // 10 minutes default
|
|
525
|
+
): Promise<Array<{ path: string; startOffset: number }>> {
|
|
526
|
+
const totalDuration = await getAudioDuration(filePath);
|
|
527
|
+
if (totalDuration <= chunkDurationSec) {
|
|
528
|
+
return [{ path: filePath, startOffset: 0 }];
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const chunks: Array<{ path: string; startOffset: number }> = [];
|
|
532
|
+
const ext = filePath.split(".").pop() ?? "mp3";
|
|
533
|
+
let offset = 0;
|
|
534
|
+
|
|
535
|
+
while (offset < totalDuration) {
|
|
536
|
+
const chunkId = crypto.randomUUID();
|
|
537
|
+
const chunkPath = join(tmpdir(), `transcriber-chunk-${chunkId}.${ext}`);
|
|
538
|
+
const args = [
|
|
539
|
+
"ffmpeg", "-y", "-i", filePath,
|
|
540
|
+
"-ss", String(offset),
|
|
541
|
+
"-t", String(chunkDurationSec),
|
|
542
|
+
"-c", "copy", chunkPath,
|
|
543
|
+
];
|
|
544
|
+
|
|
545
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
546
|
+
const exitCode = await proc.exited;
|
|
547
|
+
if (exitCode !== 0) {
|
|
548
|
+
const stderr = await new Response(proc.stderr).text();
|
|
549
|
+
throw new Error(`ffmpeg chunk split failed at ${offset}s: ${stderr.trim()}`);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
chunks.push({ path: chunkPath, startOffset: offset });
|
|
553
|
+
offset += chunkDurationSec;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return chunks;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Check whether yt-dlp is available on the system.
|
|
561
|
+
*/
|
|
562
|
+
export async function checkYtDlp(): Promise<boolean> {
|
|
563
|
+
try {
|
|
564
|
+
const proc = Bun.spawn([ytdlp(), "--version"], { stdout: "pipe", stderr: "pipe" });
|
|
565
|
+
const exitCode = await proc.exited;
|
|
566
|
+
return exitCode === 0;
|
|
567
|
+
} catch {
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RSS feed parsing for podcast auto-transcription.
|
|
3
|
+
* Fetches RSS XML, extracts audio enclosure URLs for new episodes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface FeedEpisode {
|
|
7
|
+
title: string | null;
|
|
8
|
+
url: string;
|
|
9
|
+
published: string | null;
|
|
10
|
+
duration: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Feed {
|
|
14
|
+
url: string;
|
|
15
|
+
title: string | null;
|
|
16
|
+
lastChecked: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fetch and parse an RSS feed, returning audio episodes.
|
|
21
|
+
*/
|
|
22
|
+
export async function fetchFeedEpisodes(feedUrl: string): Promise<{ feedTitle: string | null; episodes: FeedEpisode[] }> {
|
|
23
|
+
const res = await fetch(feedUrl);
|
|
24
|
+
if (!res.ok) throw new Error(`Failed to fetch feed: ${res.status} ${res.statusText}`);
|
|
25
|
+
const xml = await res.text();
|
|
26
|
+
return parseRss(xml);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Simple RSS XML parser — extracts items with audio enclosures.
|
|
31
|
+
* No XML library needed — uses regex for the simple RSS structure.
|
|
32
|
+
*/
|
|
33
|
+
function parseRss(xml: string): { feedTitle: string | null; episodes: FeedEpisode[] } {
|
|
34
|
+
// Feed title
|
|
35
|
+
const channelTitleMatch = xml.match(/<channel>[\s\S]*?<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/);
|
|
36
|
+
const feedTitle = channelTitleMatch?.[1]?.trim() ?? null;
|
|
37
|
+
|
|
38
|
+
// Extract items
|
|
39
|
+
const items = xml.match(/<item>[\s\S]*?<\/item>/g) ?? [];
|
|
40
|
+
const episodes: FeedEpisode[] = [];
|
|
41
|
+
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
// Find audio enclosure
|
|
44
|
+
const enclosureMatch = item.match(/<enclosure[^>]+url=["']([^"']+)["'][^>]*type=["']audio\/[^"']+["']/i)
|
|
45
|
+
|| item.match(/<enclosure[^>]+type=["']audio\/[^"']+["'][^>]*url=["']([^"']+)["']/i);
|
|
46
|
+
if (!enclosureMatch) continue;
|
|
47
|
+
|
|
48
|
+
const url = enclosureMatch[1];
|
|
49
|
+
const titleMatch = item.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/);
|
|
50
|
+
const pubDateMatch = item.match(/<pubDate>(.*?)<\/pubDate>/);
|
|
51
|
+
const durationMatch = item.match(/<itunes:duration>(.*?)<\/itunes:duration>/);
|
|
52
|
+
|
|
53
|
+
episodes.push({
|
|
54
|
+
title: titleMatch?.[1]?.trim() ?? null,
|
|
55
|
+
url,
|
|
56
|
+
published: pubDateMatch?.[1]?.trim() ?? null,
|
|
57
|
+
duration: durationMatch?.[1]?.trim() ?? null,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { feedTitle, episodes };
|
|
62
|
+
}
|