Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
src/services/voicepeak.service.ts
공개 가능
1
import { execFile } from 'child_process';
2
import fs from 'fs';
3
import path from 'path';
4
import { promisify } from 'util';
5
import { randomBytes } from 'crypto';
6
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
7
8
const execFileAsync = promisify(execFile);
9
10
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
11
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
12
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
13
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
14
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
15
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
16
const MAX_TEXT_LENGTH = 12000;
17
const MAX_SENTENCES = 80;
18
19
export type VoicepeakSentence = {
20
index: number;
21
text: string;
22
audioUrl?: string;
23
};
24
25
export type VoicepeakJobStatus = 'pending' | 'waiting' | 'processing' | 'done' | 'error';
26
27
export type VoicepeakJobProgress = {
28
current: number;
29
total: number;
30
message: string;
31
};
32
33
export type VoicepeakJob = {
34
id: string;
35
title: string;
36
status: VoicepeakJobStatus;
37
createdAt: number;
38
updatedAt: number;
39
expiresAt: number;
40
sentences: VoicepeakSentence[];
41
fullAudioUrl?: string;
42
downloadUrl?: string;
43
error?: string;
44
progress?: VoicepeakJobProgress;
45
};
46
47
const jobs = new Map<string, VoicepeakJob>();
48
const cancelledJobs = new Set<string>();
49
const runningJobs = new Set<string>();
50
const queuedJobs = new Set<string>();
51
let queueActive = false;
52
let cleanupTimer: NodeJS.Timeout | null = null;
53
54
function makeJobId(): string {
55
return `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
56
}
57
58
function ensureCacheRoot(): void {
59
fs.mkdirSync(CACHE_ROOT, { recursive: true, mode: 0o700 });
60
}
61
62
function getJobDir(jobId: string): string {
63
return path.join(CACHE_ROOT, jobId);
64
}
65
66
function isSafeJobId(value: string): boolean {
67
return /^[a-z0-9-]{12,64}$/i.test(value);
68
}
69
70
function normalizeTitle(value: unknown): string {
71
return String(value || '')
72
.replace(/\s+/g, ' ')
73
.trim()
74
.slice(0, 80) || '무제 음성';
75
}
76
77
function findFfmpegBin(): string | null {
78
const candidates = [
79
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
80
'/opt/homebrew/bin/ffmpeg',
81
'/usr/local/bin/ffmpeg',
82
'/usr/bin/ffmpeg'
83
].filter(Boolean) as string[];
84
85
for (const candidate of candidates) {
86
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
87
}
88
89
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
90
const candidate = path.join(dir, 'ffmpeg');
91
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
92
}
93
94
return null;
95
}
96
97
function getToolingStatus(): { ok: true; ffmpegBin: string } | { ok: false; message: string } {
98
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
99
return { ok: false, message: 'Voicepeak CLI를 찾을 수 없어 대기 중입니다.' };
100
}
101
102
const ffmpegBin = findFfmpegBin();
103
if (!ffmpegBin) {
104
return { ok: false, message: 'ffmpeg를 찾을 수 없어 대기 중입니다.' };
105
}
106
107
return { ok: true, ffmpegBin };
108
}
109
110
export function splitJapaneseSentences(input: string): string[] {
111
const normalized = input
112
.replace(/\r\n?/g, '\n')
113
.replace(/[ \t]+/g, ' ')
114
.replace(/\n{2,}/g, '\n')
115
.trim();
116
117
if (!normalized) return [];
118
119
const sentences: string[] = [];
120
let buffer = '';
121
122
for (const char of normalized) {
123
buffer += char;
124
125
if ('。!?!?'.includes(char) || char === '\n') {
126
const sentence = buffer.trim();
127
if (sentence) sentences.push(sentence);
128
buffer = '';
129
}
130
}
131
132
const tail = buffer.trim();
133
if (tail) sentences.push(tail);
134
135
return sentences
136
.map((sentence) => sentence.replace(/\s*\n+\s*/g, ' ').trim())
137
.filter(Boolean)
138
.slice(0, MAX_SENTENCES);
139
}
140
141
async function assertTooling(): Promise<string> {
142
const tooling = getToolingStatus();
143
if (tooling.ok === false) throw new Error(tooling.message);
144
return tooling.ffmpegBin;
145
}
146
147
async function synthesizeSentence(jobDir: string, sentence: VoicepeakSentence, ffmpegBin: string): Promise<string> {
148
const sentenceDir = path.join(jobDir, `sentence-${String(sentence.index + 1).padStart(2, '0')}`);
149
fs.mkdirSync(sentenceDir, { recursive: true, mode: 0o700 });
150
151
const textPath = path.join(sentenceDir, 'source.txt');
152
const wavPath = path.join(sentenceDir, 'audio.wav');
153
const mp3Path = path.join(sentenceDir, 'audio.mp3');
154
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
155
156
await execFileAsync(VOICEPEAK_BIN, [
157
'--text', textPath,
158
'--out', wavPath,
159
'--narrator', VOICEPEAK_NARRATOR
160
], { timeout: 120000, maxBuffer: 1024 * 1024 });
161
162
await execFileAsync(ffmpegBin, [
163
'-y',
164
'-i', wavPath,
165
'-codec:a', 'libmp3lame',
166
'-q:a', '2',
167
mp3Path
168
], { timeout: 120000, maxBuffer: 2 * 1024 * 1024 });
169
170
fs.rmSync(wavPath, { force: true });
171
return mp3Path;
172
}
173
174
async function mergeMp3(ffmpegBin: string, audioPaths: string[], outputPath: string): Promise<void> {
175
const args: string[] = ['-y'];
176
audioPaths.forEach((audioPath, index) => {
177
args.push('-i', audioPath);
178
if (index < audioPaths.length - 1) {
179
args.push('-f', 'lavfi', '-t', '0.5', '-i', 'anullsrc=r=44100:cl=stereo');
180
}
181
});
182
183
const segmentCount = audioPaths.length * 2 - 1;
184
const filters: string[] = [];
185
for (let i = 0; i < segmentCount; i += 1) {
186
filters.push(`[${i}:a]aresample=44100,aformat=sample_fmts=fltp:channel_layouts=stereo[a${i}]`);
187
}
188
189
const concatInputs = Array.from({ length: segmentCount }, (_, i) => `[a${i}]`).join('');
190
filters.push(`${concatInputs}concat=n=${segmentCount}:v=0:a=1[aout]`);
191
192
args.push(
193
'-filter_complex', filters.join(';'),
194
'-map', '[aout]',
195
'-codec:a', 'libmp3lame',
196
'-q:a', '2',
197
outputPath
198
);
199
200
await execFileAsync(ffmpegBin, args, { timeout: 120000, maxBuffer: 2 * 1024 * 1024 });
201
}
202
203
function writeMetadata(jobDir: string, job: VoicepeakJob): void {
204
if (cancelledJobs.has(job.id)) return;
205
fs.mkdirSync(jobDir, { recursive: true, mode: 0o700 });
206
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
207
}
208
209
function updateJobProgress(job: VoicepeakJob, message: string, current: number = 0): void {
210
const jobDir = getJobDir(job.id);
211
job.progress = {
212
current: Math.max(0, Math.min(current, job.sentences.length)),
213
total: job.sentences.length,
214
message
215
};
216
job.updatedAt = Date.now();
217
writeMetadata(jobDir, job);
218
}
219
220
function normalizeJobAudioToMp3(job: VoicepeakJob): void {
221
const ffmpegBin = findFfmpegBin();
222
const jobDir = getJobDir(job.id);
223
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
224
225
job.sentences.forEach((sentence) => {
226
const sentenceDir = path.join(jobDir, `sentence-${String(sentence.index + 1).padStart(2, '0')}`);
227
const wavPath = path.join(sentenceDir, 'audio.wav');
228
const mp3Path = path.join(sentenceDir, 'audio.mp3');
229
230
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
231
execFileSyncQuiet(ffmpegBin, ['-y', '-i', wavPath, '-codec:a', 'libmp3lame', '-q:a', '2', mp3Path]);
232
}
233
234
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
235
fs.rmSync(wavPath, { force: true });
236
sentence.audioUrl = `/hinana/voicepeak/files/${job.id}/sentence-${sentence.index + 1}.mp3`;
237
}
238
});
239
240
if (job.fullAudioUrl) job.fullAudioUrl = `/hinana/voicepeak/files/${job.id}/combined.mp3`;
241
if (job.downloadUrl) job.downloadUrl = `${job.fullAudioUrl}?download=1`;
242
writeMetadata(jobDir, job);
243
}
244
245
async function runJob(job: VoicepeakJob): Promise<void> {
246
if (runningJobs.has(job.id)) return;
247
runningJobs.add(job.id);
248
249
const jobDir = getJobDir(job.id);
250
251
try {
252
if (cancelledJobs.has(job.id)) return;
253
254
const tooling = getToolingStatus();
255
if (tooling.ok === false) {
256
job.status = 'waiting';
257
job.error = tooling.message;
258
job.progress = {
259
current: 0,
260
total: job.sentences.length,
261
message: tooling.message
262
};
263
job.expiresAt = Date.now() + WAITING_TTL_MS;
264
job.updatedAt = Date.now();
265
writeMetadata(jobDir, job);
266
return;
267
}
268
269
job.status = 'processing';
270
job.error = undefined;
271
updateJobProgress(job, '음성 생성 준비 중', 0);
272
273
const ffmpegBin = await assertTooling();
274
const audioPaths: string[] = [];
275
276
for (const sentence of job.sentences) {
277
if (cancelledJobs.has(job.id)) return;
278
updateJobProgress(job, `${sentence.index + 1}번째 문장 생성 중`, sentence.index);
279
const audioPath = await synthesizeSentence(jobDir, sentence, ffmpegBin);
280
if (cancelledJobs.has(job.id)) {
281
fs.rmSync(jobDir, { recursive: true, force: true });
282
return;
283
}
284
audioPaths.push(audioPath);
285
sentence.audioUrl = `/hinana/voicepeak/files/${job.id}/sentence-${sentence.index + 1}.mp3`;
286
updateJobProgress(job, `${sentence.index + 1}번째 문장 완료`, sentence.index + 1);
287
}
288
289
const outputPath = path.join(jobDir, 'combined.mp3');
290
updateJobProgress(job, '전체 mp3 병합 중', job.sentences.length);
291
await mergeMp3(ffmpegBin, audioPaths, outputPath);
292
if (cancelledJobs.has(job.id)) {
293
fs.rmSync(jobDir, { recursive: true, force: true });
294
return;
295
}
296
297
job.status = 'done';
298
job.fullAudioUrl = `/hinana/voicepeak/files/${job.id}/combined.mp3`;
299
job.downloadUrl = `${job.fullAudioUrl}?download=1`;
300
job.progress = {
301
current: job.sentences.length,
302
total: job.sentences.length,
303
message: '완료'
304
};
305
job.expiresAt = Date.now() + CACHE_TTL_MS;
306
job.updatedAt = Date.now();
307
writeMetadata(jobDir, job);
308
} catch (error) {
309
job.status = 'error';
310
job.error = error instanceof Error ? error.message : '음성 생성 중 오류가 발생했습니다.';
311
job.progress = {
312
current: job.progress?.current || 0,
313
total: job.sentences.length,
314
message: job.error
315
};
316
job.updatedAt = Date.now();
317
writeMetadata(jobDir, job);
318
console.error('Voicepeak job failed:', job.id, job.error);
319
} finally {
320
runningJobs.delete(job.id);
321
}
322
}
323
324
function enqueueVoicepeakJob(job: VoicepeakJob): boolean {
325
if (runningJobs.has(job.id) || queuedJobs.has(job.id)) return false;
326
cancelledJobs.delete(job.id);
327
queuedJobs.add(job.id);
328
processVoicepeakQueue();
329
return true;
330
}
331
332
async function processVoicepeakQueue(): Promise<void> {
333
if (queueActive) return;
334
queueActive = true;
335
336
try {
337
while (queuedJobs.size > 0) {
338
const nextId = queuedJobs.values().next().value as string | undefined;
339
if (!nextId) break;
340
queuedJobs.delete(nextId);
341
342
const job = getVoicepeakJob(nextId);
343
if (!job || cancelledJobs.has(nextId)) continue;
344
await runJob(job);
345
}
346
} finally {
347
queueActive = false;
348
if (queuedJobs.size > 0) processVoicepeakQueue();
349
}
350
}
351
352
export function createVoicepeakJob(sourceText: string, title?: string): VoicepeakJob {
353
const normalized = String(sourceText || '').slice(0, MAX_TEXT_LENGTH).trim();
354
const sentences = splitJapaneseSentences(normalized);
355
356
if (!sentences.length) {
357
throw new Error('변환할 문장이 없습니다.');
358
}
359
360
ensureCacheRoot();
361
362
const id = makeJobId();
363
cancelledJobs.delete(id);
364
const now = Date.now();
365
const jobDir = getJobDir(id);
366
fs.mkdirSync(jobDir, { recursive: true, mode: 0o700 });
367
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
368
369
const job: VoicepeakJob = {
370
id,
371
title: normalizeTitle(title),
372
status: 'pending',
373
createdAt: now,
374
updatedAt: now,
375
expiresAt: now + CACHE_TTL_MS,
376
sentences: sentences.map((text, index) => ({ index, text })),
377
progress: {
378
current: 0,
379
total: sentences.length,
380
message: '대기 중'
381
}
382
};
383
384
jobs.set(id, job);
385
writeMetadata(jobDir, job);
386
enqueueVoicepeakJob(job);
387
388
return job;
389
}
390
391
export function getVoicepeakJob(id: string): VoicepeakJob | null {
392
if (!isSafeJobId(id)) return null;
393
const inMemory = jobs.get(id);
394
if (inMemory) return inMemory;
395
396
const metadataPath = path.join(getJobDir(id), 'metadata.json');
397
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
398
399
try {
400
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
401
if (parsed.expiresAt <= Date.now()) return null;
402
parsed.title = normalizeTitle(parsed.title);
403
jobs.set(id, parsed);
404
return parsed;
405
} catch {
406
return null;
407
}
408
}
409
410
export type VoicepeakJobSummary = {
411
id: string;
412
title: string;
413
status: VoicepeakJobStatus;
414
createdAt: number;
415
updatedAt: number;
416
expiresAt: number;
417
sentenceCount: number;
418
fullAudioUrl?: string;
419
downloadUrl?: string;
420
progress?: VoicepeakJobProgress;
421
};
422
423
function summarizeJob(job: VoicepeakJob): VoicepeakJobSummary {
424
return {
425
id: job.id,
426
title: normalizeTitle(job.title),
427
status: job.status,
428
createdAt: job.createdAt,
429
updatedAt: job.updatedAt,
430
expiresAt: job.expiresAt,
431
sentenceCount: job.sentences.length,
432
fullAudioUrl: job.fullAudioUrl,
433
downloadUrl: job.downloadUrl,
434
progress: job.progress
435
};
436
}
437
438
export function listVoicepeakJobs(): VoicepeakJobSummary[] {
439
ensureCacheRoot();
440
cleanupVoicepeakCache();
441
442
const summaries: VoicepeakJobSummary[] = [];
443
const seen = new Set<string>();
444
445
for (const job of jobs.values()) {
446
summaries.push(summarizeJob(job));
447
seen.add(job.id);
448
}
449
450
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
451
if (!entry.isDirectory() || seen.has(entry.name) || !isSafeJobId(entry.name)) continue;
452
453
const job = getVoicepeakJob(entry.name);
454
if (job) summaries.push(summarizeJob(job));
455
}
456
457
return summaries.sort((a, b) => b.createdAt - a.createdAt);
458
}
459
460
export function processWaitingVoicepeakJobs(): number {
461
ensureCacheRoot();
462
const tooling = getToolingStatus();
463
if (!tooling.ok) return 0;
464
465
let queued = 0;
466
for (const summary of listVoicepeakJobs()) {
467
if (
468
summary.status !== 'waiting'
469
&& summary.status !== 'pending'
470
&& summary.status !== 'processing'
471
&& summary.status !== 'error'
472
) continue;
473
const job = getVoicepeakJob(summary.id);
474
if (!job) continue;
475
if (enqueueVoicepeakJob(job)) queued += 1;
476
}
477
478
return queued;
479
}
480
481
export function deleteVoicepeakJob(id: string): boolean {
482
if (!isSafeJobId(id)) return false;
483
484
const jobDir = getJobDir(id);
485
cancelledJobs.add(id);
486
queuedJobs.delete(id);
487
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
488
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
489
fs.rmSync(jobDir, { recursive: true, force: true });
490
}
491
492
return existed;
493
}
494
495
export function deleteAllVoicepeakJobs(): number {
496
ensureCacheRoot();
497
const ids = new Set<string>();
498
499
for (const id of jobs.keys()) ids.add(id);
500
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
501
if (entry.isDirectory() && isSafeJobId(entry.name)) ids.add(entry.name);
502
}
503
504
let deleted = 0;
505
for (const id of ids) {
506
if (deleteVoicepeakJob(id)) deleted += 1;
507
}
508
509
return deleted;
510
}
511
512
export type VoicepeakArchiveOptions = {
513
from?: number;
514
to?: number;
515
};
516
517
export type VoicepeakArchiveResult = {
518
filePath: string;
519
fileName: string;
520
tempDir: string;
521
count: number;
522
};
523
524
function ensureArchiveRoot(): void {
525
fs.mkdirSync(ARCHIVE_ROOT, { recursive: true, mode: 0o700 });
526
}
527
528
function isJobInRange(job: VoicepeakJobSummary, options: VoicepeakArchiveOptions): boolean {
529
if (options.from && job.createdAt < options.from) return false;
530
if (options.to && job.createdAt > options.to) return false;
531
return true;
532
}
533
534
function assertSafeArchiveEntries(archivePath: string): void {
535
const result = execFileSyncText('tar', ['-tzf', archivePath]);
536
const entries = result.split('\n').map((entry) => entry.trim()).filter(Boolean);
537
538
for (const entry of entries) {
539
if (entry.startsWith('/') || entry.includes('..') || entry.includes('\\')) {
540
throw new Error('안전하지 않은 아카이브 경로가 포함되어 있습니다.');
541
}
542
}
543
}
544
545
function execFileSyncText(file: string, args: string[]): string {
546
const { execFileSync } = require('child_process') as typeof import('child_process');
547
return execFileSync(file, args, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });
548
}
549
550
function execFileSyncQuiet(file: string, args: string[]): void {
551
const { execFileSync } = require('child_process') as typeof import('child_process');
552
execFileSync(file, args, { stdio: 'ignore', maxBuffer: 10 * 1024 * 1024 });
553
}
554
555
export async function createVoicepeakArchive(options: VoicepeakArchiveOptions = {}): Promise<VoicepeakArchiveResult> {
556
ensureCacheRoot();
557
ensureArchiveRoot();
558
559
const selected = listVoicepeakJobs().filter((job) => isJobInRange(job, options));
560
if (!selected.length) {
561
throw new Error('내보낼 음성이 없습니다.');
562
}
563
564
const tempDir = path.join(ARCHIVE_ROOT, `export-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`);
565
const payloadDir = path.join(tempDir, 'voicepeak-cache');
566
fs.mkdirSync(payloadDir, { recursive: true, mode: 0o700 });
567
568
const manifest = {
569
exportedAt: new Date().toISOString(),
570
format: 'hinana-voicepeak-cache-v1',
571
jobs: selected.map((job) => ({
572
id: job.id,
573
title: job.title,
574
status: job.status,
575
createdAt: job.createdAt,
576
sentenceCount: job.sentenceCount
577
}))
578
};
579
580
for (const job of selected) {
581
const fullJob = getVoicepeakJob(job.id);
582
if (fullJob) normalizeJobAudioToMp3(fullJob);
583
584
const sourceDir = getJobDir(job.id);
585
const targetDir = path.join(payloadDir, job.id);
586
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
587
fs.cpSync(sourceDir, targetDir, { recursive: true, force: true });
588
}
589
}
590
591
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
592
593
const fileName = `voicepeak-cache-${Date.now()}.tar.gz`;
594
const filePath = path.join(tempDir, fileName);
595
await execFileAsync('tar', ['-czf', filePath, '-C', tempDir, 'manifest.json', 'voicepeak-cache'], {
596
timeout: 120000,
597
maxBuffer: 2 * 1024 * 1024
598
});
599
600
return { filePath, fileName, tempDir, count: selected.length };
601
}
602
603
function collectMetadataDirs(root: string): string[] {
604
const dirs: string[] = [];
605
606
function walk(dir: string): void {
607
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
608
const fullPath = path.join(dir, entry.name);
609
if (!entry.isDirectory()) continue;
610
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
611
dirs.push(fullPath);
612
continue;
613
}
614
walk(fullPath);
615
}
616
}
617
618
walk(root);
619
return dirs;
620
}
621
622
export async function importVoicepeakArchive(archivePath: string): Promise<{ importedCount: number }> {
623
ensureCacheRoot();
624
ensureArchiveRoot();
625
assertSafeArchiveEntries(archivePath);
626
627
const tempDir = path.join(ARCHIVE_ROOT, `import-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`);
628
fs.mkdirSync(tempDir, { recursive: true, mode: 0o700 });
629
630
try {
631
await execFileAsync('tar', ['-xzf', archivePath, '-C', tempDir], {
632
timeout: 120000,
633
maxBuffer: 2 * 1024 * 1024
634
});
635
636
let importedCount = 0;
637
for (const metadataDir of collectMetadataDirs(tempDir)) {
638
const jobId = path.basename(metadataDir);
639
if (!isSafeJobId(jobId)) continue;
640
641
const metadataPath = path.join(metadataDir, 'metadata.json');
642
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
643
if (metadata.id !== jobId) continue;
644
645
metadata.title = normalizeTitle(metadata.title);
646
metadata.sentences = Array.isArray(metadata.sentences) ? metadata.sentences : [];
647
metadata.sentences.forEach((sentence) => {
648
if (sentence.audioUrl) {
649
sentence.audioUrl = `/hinana/voicepeak/files/${jobId}/sentence-${sentence.index + 1}.mp3`;
650
}
651
});
652
if (metadata.fullAudioUrl) metadata.fullAudioUrl = `/hinana/voicepeak/files/${jobId}/combined.mp3`;
653
if (metadata.downloadUrl) metadata.downloadUrl = `${metadata.fullAudioUrl}?download=1`;
654
655
const targetDir = getJobDir(jobId);
656
fs.rmSync(targetDir, { recursive: true, force: true });
657
fs.cpSync(metadataDir, targetDir, { recursive: true, force: true });
658
writeMetadata(targetDir, metadata);
659
normalizeJobAudioToMp3(metadata);
660
jobs.set(jobId, metadata);
661
cancelledJobs.delete(jobId);
662
importedCount += 1;
663
}
664
665
return { importedCount };
666
} finally {
667
fs.rmSync(tempDir, { recursive: true, force: true });
668
}
669
}
670
671
export function getVoicepeakFilePath(jobId: string, fileName: string): string | null {
672
const job = getVoicepeakJob(jobId);
673
if (!job) return null;
674
675
if (fileName === 'combined.mp3') {
676
const filePath = path.join(getJobDir(jobId), fileName);
677
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
678
}
679
680
const sentenceMatch = fileName.match(/^sentence-(\d+)\.mp3$/);
681
if (!sentenceMatch) return null;
682
683
const index = Number(sentenceMatch[1]) - 1;
684
if (!Number.isInteger(index) || index < 0 || index >= job.sentences.length) return null;
685
686
const filePath = path.join(
687
getJobDir(jobId),
688
`sentence-${String(index + 1).padStart(2, '0')}`,
689
'audio.mp3'
690
);
691
692
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
693
}
694
695
export function cleanupVoicepeakCache(): void {
696
ensureCacheRoot();
697
const now = Date.now();
698
699
for (const [id, job] of jobs) {
700
if (job.expiresAt <= now) jobs.delete(id);
701
}
702
703
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
704
if (!entry.isDirectory()) continue;
705
706
const jobDir = path.join(CACHE_ROOT, entry.name);
707
const metadataPath = path.join(jobDir, 'metadata.json');
708
let expiresAt = 0;
709
710
try {
711
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
712
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
713
expiresAt = metadata.expiresAt || 0;
714
} else {
715
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
716
}
717
} catch {
718
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
719
}
720
721
if (expiresAt <= now) {
722
fs.rmSync(jobDir, { recursive: true, force: true });
723
}
724
}
725
}
726
727
export function startVoicepeakCacheCleanup(): void {
728
if (cleanupTimer) return;
729
cleanupVoicepeakCache();
730
cleanupTimer = setInterval(cleanupVoicepeakCache, 10 * 60 * 1000);
731
}
732