Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
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