Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
src/services/japanese-lounge.service.ts
공개 가능
1
import fs from 'fs';
2
import path from 'path';
3
import { randomUUID } from 'crypto';
4
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
5
6
export type JapaneseLoungeLine = {
7
index: number;
8
japanese: string;
9
korean: string;
10
start?: number;
11
end?: number;
12
};
13
14
export type JapaneseLoungeEntry = {
15
id: string;
16
title: string;
17
createdAt: string;
18
updatedAt: string;
19
japaneseText: string;
20
koreanText: string;
21
originalAudioName: string;
22
audioFileName: string;
23
audioUrl: string;
24
lines: JapaneseLoungeLine[];
25
};
26
27
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
28
const INDEX_FILE = path.join(LOUNGE_ROOT, 'entries.json');
29
const MAX_TITLE_LENGTH = 120;
30
const SUPPORTED_AUDIO_EXTENSIONS = new Set(['.mp3', '.wav']);
31
32
function ensureStore(): void {
33
fs.mkdirSync(LOUNGE_ROOT, { recursive: true });
34
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
35
}
36
37
function readEntries(): JapaneseLoungeEntry[] {
38
ensureStore();
39
try {
40
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
41
return Array.isArray(parsed) ? parsed : [];
42
} catch {
43
return [];
44
}
45
}
46
47
function writeEntries(entries: JapaneseLoungeEntry[]): void {
48
ensureStore();
49
const tempFile = `${INDEX_FILE}.tmp`;
50
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
51
fs.renameSync(tempFile, INDEX_FILE);
52
}
53
54
function normalizeTitle(value: string): string {
55
const title = value.trim().replace(/\s+/g, ' ').slice(0, MAX_TITLE_LENGTH);
56
return title || '일본어 라운지 기록';
57
}
58
59
function splitSentences(text: string): string[] {
60
const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
61
if (!normalized) return [];
62
63
return normalized
64
.split(/\n+/)
65
.flatMap((line) => {
66
const trimmed = line.trim();
67
if (!trimmed) return [];
68
const matches = trimmed.match(/[^。!?!?..]+[。!?!?..]*/g);
69
return matches && matches.length ? matches.map((part) => part.trim()).filter(Boolean) : [trimmed];
70
})
71
.filter(Boolean);
72
}
73
74
function pairLines(japaneseText: string, koreanText: string): JapaneseLoungeLine[] {
75
const japaneseLines = splitSentences(japaneseText);
76
const koreanLines = splitSentences(koreanText);
77
const count = Math.max(japaneseLines.length, koreanLines.length);
78
79
return Array.from({ length: count }, (_, index) => ({
80
index,
81
japanese: japaneseLines[index] || '',
82
korean: koreanLines[index] || ''
83
}));
84
}
85
86
function entryDir(entryId: string): string {
87
return path.join(LOUNGE_ROOT, entryId);
88
}
89
90
function audioUrl(entryId: string, fileName: string): string {
91
return `/hinana/japanese-lounge/files/${encodeURIComponent(entryId)}/${encodeURIComponent(fileName)}`;
92
}
93
94
function normalizeStoredEntry(entry: JapaneseLoungeEntry): JapaneseLoungeEntry {
95
return {
96
...entry,
97
originalAudioName: decodeUploadedFileName(entry.originalAudioName),
98
audioUrl: audioUrl(entry.id, entry.audioFileName)
99
};
100
}
101
102
function decodeUploadedFileName(fileName: string): string {
103
const baseName = path.basename(fileName || '');
104
if (!baseName) return 'tts';
105
106
const decoded = Buffer.from(baseName, 'latin1').toString('utf8').normalize('NFC');
107
const replacementCount = (decoded.match(/\uFFFD/g) || []).length;
108
if (replacementCount === 0 && decoded !== baseName && /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(decoded)) {
109
return decoded;
110
}
111
112
return baseName;
113
}
114
115
function getAudioExtension(fileName: string): string {
116
const extension = path.extname(fileName || '').toLowerCase();
117
if (!SUPPORTED_AUDIO_EXTENSIONS.has(extension)) {
118
throw new Error('MP3 또는 WAV 파일을 업로드해 주세요.');
119
}
120
return extension;
121
}
122
123
export function listJapaneseLoungeEntries(): JapaneseLoungeEntry[] {
124
return readEntries()
125
.map(normalizeStoredEntry)
126
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
127
}
128
129
export function getJapaneseLoungeEntry(entryId: string): JapaneseLoungeEntry | null {
130
const entry = readEntries().find((item) => item.id === entryId);
131
return entry ? normalizeStoredEntry(entry) : null;
132
}
133
134
export function createJapaneseLoungeEntry(input: {
135
title: string;
136
japaneseText: string;
137
koreanText: string;
138
uploadedFilePath: string;
139
originalAudioName: string;
140
}): JapaneseLoungeEntry {
141
const japaneseText = input.japaneseText.trim();
142
const koreanText = input.koreanText.trim();
143
if (!japaneseText) throw new Error('일본어 원문을 입력해 주세요.');
144
if (!koreanText) throw new Error('한국어 문장을 입력해 주세요.');
145
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
146
throw new Error('MP3 또는 WAV 파일을 업로드해 주세요.');
147
}
148
149
const id = randomUUID();
150
const now = new Date().toISOString();
151
const dir = entryDir(id);
152
const audioExtension = getAudioExtension(input.originalAudioName);
153
const audioFileName = `tts${audioExtension}`;
154
const audioPath = path.join(dir, audioFileName);
155
156
fs.mkdirSync(dir, { recursive: true });
157
try {
158
fs.renameSync(input.uploadedFilePath, audioPath);
159
} catch {
160
fs.copyFileSync(input.uploadedFilePath, audioPath);
161
fs.rmSync(input.uploadedFilePath, { force: true });
162
}
163
164
const entry: JapaneseLoungeEntry = {
165
id,
166
title: normalizeTitle(input.title),
167
createdAt: now,
168
updatedAt: now,
169
japaneseText,
170
koreanText,
171
originalAudioName: decodeUploadedFileName(input.originalAudioName || audioFileName),
172
audioFileName,
173
audioUrl: audioUrl(id, audioFileName),
174
lines: pairLines(japaneseText, koreanText)
175
};
176
177
const entries = readEntries();
178
entries.push(entry);
179
writeEntries(entries);
180
return entry;
181
}
182
183
export function updateJapaneseLoungeTimings(entryId: string, timings: Array<{ index: number; start?: number; end?: number }>): JapaneseLoungeEntry | null {
184
const entries = readEntries();
185
const entry = entries.find((item) => item.id === entryId);
186
if (!entry) return null;
187
188
const timingByIndex = new Map<number, { start?: number; end?: number }>();
189
timings.forEach((timing) => {
190
const index = Number(timing.index);
191
if (!Number.isInteger(index) || index < 0) return;
192
193
const start = Number(timing.start);
194
const end = Number(timing.end);
195
timingByIndex.set(index, {
196
start: Number.isFinite(start) && start >= 0 ? Math.round(start * 1000) / 1000 : undefined,
197
end: Number.isFinite(end) && end >= 0 ? Math.round(end * 1000) / 1000 : undefined
198
});
199
});
200
201
entry.lines = entry.lines.map((line) => {
202
const timing = timingByIndex.get(line.index);
203
if (!timing) return line;
204
205
const next = { ...line };
206
delete next.start;
207
delete next.end;
208
if (typeof timing.start === 'number') next.start = timing.start;
209
if (typeof timing.end === 'number' && timing.end > (next.start || 0)) next.end = timing.end;
210
return next;
211
});
212
entry.updatedAt = new Date().toISOString();
213
writeEntries(entries);
214
return normalizeStoredEntry(entry);
215
}
216
217
export function updateJapaneseLoungeLines(entryId: string, lines: Array<{ japanese?: string; korean?: string; start?: number; end?: number }>): JapaneseLoungeEntry | null {
218
const entries = readEntries();
219
const entry = entries.find((item) => item.id === entryId);
220
if (!entry) return null;
221
222
const nextLines = lines
223
.map((line, index) => {
224
const japanese = String(line.japanese || '').trim();
225
const korean = String(line.korean || '').trim();
226
const start = Number(line.start);
227
const end = Number(line.end);
228
const next: JapaneseLoungeLine = { index, japanese, korean };
229
230
if (Number.isFinite(start) && start >= 0) next.start = Math.round(start * 1000) / 1000;
231
if (Number.isFinite(end) && end >= 0 && end > (next.start || 0)) next.end = Math.round(end * 1000) / 1000;
232
return next;
233
})
234
.filter((line) => line.japanese || line.korean);
235
236
if (!nextLines.length) throw new Error('저장할 문장이 없습니다.');
237
238
entry.lines = nextLines;
239
entry.japaneseText = nextLines.map((line) => line.japanese).filter(Boolean).join('\n');
240
entry.koreanText = nextLines.map((line) => line.korean).filter(Boolean).join('\n');
241
entry.updatedAt = new Date().toISOString();
242
writeEntries(entries);
243
return normalizeStoredEntry(entry);
244
}
245
246
export function updateJapaneseLoungeMeta(entryId: string, input: { originalAudioName?: string }): JapaneseLoungeEntry | null {
247
const entries = readEntries();
248
const entry = entries.find((item) => item.id === entryId);
249
if (!entry) return null;
250
251
const originalAudioName = String(input.originalAudioName || '').trim();
252
if (originalAudioName) {
253
entry.originalAudioName = path.basename(originalAudioName).slice(0, 180);
254
}
255
entry.updatedAt = new Date().toISOString();
256
writeEntries(entries);
257
return normalizeStoredEntry(entry);
258
}
259
260
export function deleteJapaneseLoungeEntry(entryId: string): boolean {
261
const entries = readEntries();
262
const nextEntries = entries.filter((entry) => entry.id !== entryId);
263
if (nextEntries.length === entries.length) return false;
264
265
writeEntries(nextEntries);
266
fs.rmSync(entryDir(entryId), { recursive: true, force: true });
267
return true;
268
}
269
270
export function getJapaneseLoungeAudioPath(entryId: string, fileName: string): string | null {
271
const entry = getJapaneseLoungeEntry(entryId);
272
if (!entry || entry.audioFileName !== fileName) return null;
273
274
const filePath = path.join(entryDir(entry.id), entry.audioFileName);
275
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
276
return filePath;
277
}
278