Public Source Viewer

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

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

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