klaudio 0.1.0 → 0.3.0
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/README.md +67 -65
- package/package.json +44 -40
- package/src/cache.js +262 -262
- package/src/cli.js +1205 -999
- package/src/extractor.js +203 -203
- package/src/installer.js +161 -128
- package/src/player.js +359 -359
- package/src/presets.js +5 -5
- package/src/scanner.js +41 -6
- package/src/unity.js +241 -0
package/src/extractor.js
CHANGED
|
@@ -1,203 +1,203 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import { readdir, mkdir, stat, chmod } from "node:fs/promises";
|
|
3
|
-
import { createWriteStream } from "node:fs";
|
|
4
|
-
import { join, extname, basename, resolve as resolvePath } from "node:path";
|
|
5
|
-
import { platform, homedir, tmpdir } from "node:os";
|
|
6
|
-
import { pipeline } from "node:stream/promises";
|
|
7
|
-
|
|
8
|
-
const TOOLS_DIR = join(homedir(), ".klonk", "tools");
|
|
9
|
-
|
|
10
|
-
// Packed audio formats that vgmstream-cli can convert to WAV
|
|
11
|
-
const PACKED_EXTENSIONS = new Set([".wem", ".bnk", ".bank", ".fsb", ".pck"]);
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Check if a file is a packed audio format we can extract.
|
|
15
|
-
*/
|
|
16
|
-
export function isPackedAudio(filePath) {
|
|
17
|
-
return PACKED_EXTENSIONS.has(extname(filePath).toLowerCase());
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Check if a game directory has extractable packed audio.
|
|
22
|
-
*/
|
|
23
|
-
export async function hasPackedAudio(gamePath) {
|
|
24
|
-
const formats = { wem: 0, bnk: 0, bank: 0, fsb: 0, pck: 0 };
|
|
25
|
-
await scanForPackedAudio(gamePath, formats, 0);
|
|
26
|
-
return {
|
|
27
|
-
total: Object.values(formats).reduce((a, b) => a + b, 0),
|
|
28
|
-
formats,
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function scanForPackedAudio(dir, formats, depth) {
|
|
33
|
-
if (depth > 5) return;
|
|
34
|
-
try {
|
|
35
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
36
|
-
for (const entry of entries) {
|
|
37
|
-
if (entry.isDirectory()) {
|
|
38
|
-
await scanForPackedAudio(join(dir, entry.name), formats, depth + 1);
|
|
39
|
-
} else if (entry.isFile()) {
|
|
40
|
-
const ext = extname(entry.name).toLowerCase().slice(1);
|
|
41
|
-
if (ext in formats) formats[ext]++;
|
|
42
|
-
}
|
|
43
|
-
// Stop early if we found enough
|
|
44
|
-
if (Object.values(formats).reduce((a, b) => a + b, 0) > 100) return;
|
|
45
|
-
}
|
|
46
|
-
} catch { /* skip */ }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Get the path to vgmstream-cli, downloading it if needed.
|
|
51
|
-
*/
|
|
52
|
-
export async function getVgmstreamPath(onProgress) {
|
|
53
|
-
const os = platform();
|
|
54
|
-
const exeName = os === "win32" ? "vgmstream-cli.exe" : "vgmstream-cli";
|
|
55
|
-
const toolPath = join(TOOLS_DIR, exeName);
|
|
56
|
-
|
|
57
|
-
// Check if already downloaded
|
|
58
|
-
try {
|
|
59
|
-
await stat(toolPath);
|
|
60
|
-
return toolPath;
|
|
61
|
-
} catch { /* not found, need to download */ }
|
|
62
|
-
|
|
63
|
-
if (onProgress) onProgress("Downloading vgmstream-cli...");
|
|
64
|
-
|
|
65
|
-
await mkdir(TOOLS_DIR, { recursive: true });
|
|
66
|
-
|
|
67
|
-
// Download the appropriate release
|
|
68
|
-
const releaseUrl = os === "win32"
|
|
69
|
-
? "https://github.com/vgmstream/vgmstream-releases/releases/download/nightly/vgmstream-win64.zip"
|
|
70
|
-
: os === "darwin"
|
|
71
|
-
? "https://github.com/vgmstream/vgmstream-releases/releases/download/nightly/vgmstream-macos.zip"
|
|
72
|
-
: "https://github.com/vgmstream/vgmstream-releases/releases/download/nightly/vgmstream-linux.zip";
|
|
73
|
-
|
|
74
|
-
const zipPath = join(tmpdir(), "vgmstream.zip");
|
|
75
|
-
|
|
76
|
-
// Download using Node.js fetch
|
|
77
|
-
const response = await fetch(releaseUrl, { redirect: "follow" });
|
|
78
|
-
if (!response.ok) throw new Error(`Failed to download vgmstream: ${response.status}`);
|
|
79
|
-
|
|
80
|
-
const fileStream = createWriteStream(zipPath);
|
|
81
|
-
await pipeline(response.body, fileStream);
|
|
82
|
-
|
|
83
|
-
if (onProgress) onProgress("Extracting vgmstream-cli...");
|
|
84
|
-
|
|
85
|
-
// Extract using tar (handles zip on modern systems) or PowerShell on Windows
|
|
86
|
-
if (os === "win32") {
|
|
87
|
-
await new Promise((resolve, reject) => {
|
|
88
|
-
execFile("powershell.exe", [
|
|
89
|
-
"-NoProfile", "-Command",
|
|
90
|
-
`Expand-Archive -Path '${zipPath}' -DestinationPath '${TOOLS_DIR}' -Force`,
|
|
91
|
-
], { windowsHide: true }, (err) => {
|
|
92
|
-
if (err) reject(err);
|
|
93
|
-
else resolve();
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
} else {
|
|
97
|
-
await new Promise((resolve, reject) => {
|
|
98
|
-
execFile("unzip", ["-o", zipPath, "-d", TOOLS_DIR], (err) => {
|
|
99
|
-
if (err) reject(err);
|
|
100
|
-
else resolve();
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
// Make executable
|
|
104
|
-
try { await chmod(toolPath, 0o755); } catch { /* ignore */ }
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Verify it exists
|
|
108
|
-
await stat(toolPath);
|
|
109
|
-
return toolPath;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Find all extractable audio files in a game directory.
|
|
114
|
-
*/
|
|
115
|
-
export async function findPackedAudioFiles(gamePath, maxFiles = 50) {
|
|
116
|
-
const results = [];
|
|
117
|
-
|
|
118
|
-
async function scan(dir, depth = 0) {
|
|
119
|
-
if (depth > 5 || results.length >= maxFiles) return;
|
|
120
|
-
try {
|
|
121
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
122
|
-
for (const entry of entries) {
|
|
123
|
-
if (results.length >= maxFiles) break;
|
|
124
|
-
const fullPath = join(dir, entry.name);
|
|
125
|
-
if (entry.isDirectory()) {
|
|
126
|
-
const lower = entry.name.toLowerCase();
|
|
127
|
-
if (["__pycache__", "node_modules", ".git"].some(s => lower.includes(s))) continue;
|
|
128
|
-
await scan(fullPath, depth + 1);
|
|
129
|
-
} else if (entry.isFile()) {
|
|
130
|
-
const ext = extname(entry.name).toLowerCase();
|
|
131
|
-
// Formats vgmstream-cli can convert directly
|
|
132
|
-
// (.bnk needs bnkextr preprocessing — skip for now)
|
|
133
|
-
if (ext === ".wem" || ext === ".fsb" || ext === ".bank") {
|
|
134
|
-
results.push({ path: fullPath, name: entry.name, dir });
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
} catch { /* skip */ }
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
await scan(gamePath);
|
|
142
|
-
return results;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Extract/convert a packed audio file to WAV using vgmstream-cli.
|
|
147
|
-
*
|
|
148
|
-
* @param {string} inputPath - Path to .wem/.bnk/.bank/.fsb file
|
|
149
|
-
* @param {string} outputDir - Directory to write WAV files to
|
|
150
|
-
* @param {string} vgmstreamPath - Path to vgmstream-cli binary
|
|
151
|
-
* @returns {string[]} Array of output WAV file paths
|
|
152
|
-
*/
|
|
153
|
-
export async function extractToWav(inputPath, outputDir, vgmstreamPath) {
|
|
154
|
-
await mkdir(outputDir, { recursive: true });
|
|
155
|
-
|
|
156
|
-
// Resolve to absolute OS-native paths (critical on Windows where
|
|
157
|
-
// MSYS uses forward slashes but native exes need backslashes)
|
|
158
|
-
const absInput = resolvePath(inputPath);
|
|
159
|
-
const ext = extname(absInput).toLowerCase();
|
|
160
|
-
const baseName = basename(absInput, ext);
|
|
161
|
-
const outputPath = resolvePath(join(outputDir, `${baseName}.wav`));
|
|
162
|
-
|
|
163
|
-
return new Promise((resolve, reject) => {
|
|
164
|
-
// For .bnk files, vgmstream can extract subsongs
|
|
165
|
-
// First try with -S flag to get subsong count
|
|
166
|
-
execFile(vgmstreamPath, ["-m", absInput], { windowsHide: true, timeout: 10000 }, (err, stdout) => {
|
|
167
|
-
if (err) {
|
|
168
|
-
// Single file conversion
|
|
169
|
-
execFile(vgmstreamPath, ["-o", outputPath, absInput], { windowsHide: true, timeout: 30000 }, (err2) => {
|
|
170
|
-
if (err2) reject(new Error(`Failed to convert ${basename(absInput)}: ${err2.message}`));
|
|
171
|
-
else resolve([outputPath]);
|
|
172
|
-
});
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Check if it has multiple subsongs
|
|
177
|
-
const subsongMatch = stdout.match(/stream count:\s*(\d+)/i) || stdout.match(/subsong count:\s*(\d+)/i);
|
|
178
|
-
const subsongCount = subsongMatch ? parseInt(subsongMatch[1]) : 1;
|
|
179
|
-
|
|
180
|
-
if (subsongCount <= 1) {
|
|
181
|
-
// Single conversion
|
|
182
|
-
execFile(vgmstreamPath, ["-o", outputPath, absInput], { windowsHide: true, timeout: 30000 }, (err2) => {
|
|
183
|
-
if (err2) reject(new Error(`Failed to convert ${basename(absInput)}: ${err2.message}`));
|
|
184
|
-
else resolve([outputPath]);
|
|
185
|
-
});
|
|
186
|
-
} else {
|
|
187
|
-
// Extract each subsong (up to 20)
|
|
188
|
-
const count = Math.min(subsongCount, 20);
|
|
189
|
-
const outputs = [];
|
|
190
|
-
let done = 0;
|
|
191
|
-
|
|
192
|
-
for (let i = 1; i <= count; i++) {
|
|
193
|
-
const subOutput = join(outputDir, `${baseName}_${String(i).padStart(3, "0")}.wav`);
|
|
194
|
-
execFile(vgmstreamPath, ["-o", resolvePath(subOutput), "-s", String(i), absInput], { windowsHide: true, timeout: 30000 }, (err2) => {
|
|
195
|
-
if (!err2) outputs.push(subOutput);
|
|
196
|
-
done++;
|
|
197
|
-
if (done === count) resolve(outputs);
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
}
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readdir, mkdir, stat, chmod } from "node:fs/promises";
|
|
3
|
+
import { createWriteStream } from "node:fs";
|
|
4
|
+
import { join, extname, basename, resolve as resolvePath } from "node:path";
|
|
5
|
+
import { platform, homedir, tmpdir } from "node:os";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
|
|
8
|
+
const TOOLS_DIR = join(homedir(), ".klonk", "tools");
|
|
9
|
+
|
|
10
|
+
// Packed audio formats that vgmstream-cli can convert to WAV
|
|
11
|
+
const PACKED_EXTENSIONS = new Set([".wem", ".bnk", ".bank", ".fsb", ".pck"]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if a file is a packed audio format we can extract.
|
|
15
|
+
*/
|
|
16
|
+
export function isPackedAudio(filePath) {
|
|
17
|
+
return PACKED_EXTENSIONS.has(extname(filePath).toLowerCase());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check if a game directory has extractable packed audio.
|
|
22
|
+
*/
|
|
23
|
+
export async function hasPackedAudio(gamePath) {
|
|
24
|
+
const formats = { wem: 0, bnk: 0, bank: 0, fsb: 0, pck: 0 };
|
|
25
|
+
await scanForPackedAudio(gamePath, formats, 0);
|
|
26
|
+
return {
|
|
27
|
+
total: Object.values(formats).reduce((a, b) => a + b, 0),
|
|
28
|
+
formats,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function scanForPackedAudio(dir, formats, depth) {
|
|
33
|
+
if (depth > 5) return;
|
|
34
|
+
try {
|
|
35
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
await scanForPackedAudio(join(dir, entry.name), formats, depth + 1);
|
|
39
|
+
} else if (entry.isFile()) {
|
|
40
|
+
const ext = extname(entry.name).toLowerCase().slice(1);
|
|
41
|
+
if (ext in formats) formats[ext]++;
|
|
42
|
+
}
|
|
43
|
+
// Stop early if we found enough
|
|
44
|
+
if (Object.values(formats).reduce((a, b) => a + b, 0) > 100) return;
|
|
45
|
+
}
|
|
46
|
+
} catch { /* skip */ }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get the path to vgmstream-cli, downloading it if needed.
|
|
51
|
+
*/
|
|
52
|
+
export async function getVgmstreamPath(onProgress) {
|
|
53
|
+
const os = platform();
|
|
54
|
+
const exeName = os === "win32" ? "vgmstream-cli.exe" : "vgmstream-cli";
|
|
55
|
+
const toolPath = join(TOOLS_DIR, exeName);
|
|
56
|
+
|
|
57
|
+
// Check if already downloaded
|
|
58
|
+
try {
|
|
59
|
+
await stat(toolPath);
|
|
60
|
+
return toolPath;
|
|
61
|
+
} catch { /* not found, need to download */ }
|
|
62
|
+
|
|
63
|
+
if (onProgress) onProgress("Downloading vgmstream-cli...");
|
|
64
|
+
|
|
65
|
+
await mkdir(TOOLS_DIR, { recursive: true });
|
|
66
|
+
|
|
67
|
+
// Download the appropriate release
|
|
68
|
+
const releaseUrl = os === "win32"
|
|
69
|
+
? "https://github.com/vgmstream/vgmstream-releases/releases/download/nightly/vgmstream-win64.zip"
|
|
70
|
+
: os === "darwin"
|
|
71
|
+
? "https://github.com/vgmstream/vgmstream-releases/releases/download/nightly/vgmstream-macos.zip"
|
|
72
|
+
: "https://github.com/vgmstream/vgmstream-releases/releases/download/nightly/vgmstream-linux.zip";
|
|
73
|
+
|
|
74
|
+
const zipPath = join(tmpdir(), "vgmstream.zip");
|
|
75
|
+
|
|
76
|
+
// Download using Node.js fetch
|
|
77
|
+
const response = await fetch(releaseUrl, { redirect: "follow" });
|
|
78
|
+
if (!response.ok) throw new Error(`Failed to download vgmstream: ${response.status}`);
|
|
79
|
+
|
|
80
|
+
const fileStream = createWriteStream(zipPath);
|
|
81
|
+
await pipeline(response.body, fileStream);
|
|
82
|
+
|
|
83
|
+
if (onProgress) onProgress("Extracting vgmstream-cli...");
|
|
84
|
+
|
|
85
|
+
// Extract using tar (handles zip on modern systems) or PowerShell on Windows
|
|
86
|
+
if (os === "win32") {
|
|
87
|
+
await new Promise((resolve, reject) => {
|
|
88
|
+
execFile("powershell.exe", [
|
|
89
|
+
"-NoProfile", "-Command",
|
|
90
|
+
`Expand-Archive -Path '${zipPath}' -DestinationPath '${TOOLS_DIR}' -Force`,
|
|
91
|
+
], { windowsHide: true }, (err) => {
|
|
92
|
+
if (err) reject(err);
|
|
93
|
+
else resolve();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
} else {
|
|
97
|
+
await new Promise((resolve, reject) => {
|
|
98
|
+
execFile("unzip", ["-o", zipPath, "-d", TOOLS_DIR], (err) => {
|
|
99
|
+
if (err) reject(err);
|
|
100
|
+
else resolve();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
// Make executable
|
|
104
|
+
try { await chmod(toolPath, 0o755); } catch { /* ignore */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Verify it exists
|
|
108
|
+
await stat(toolPath);
|
|
109
|
+
return toolPath;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Find all extractable audio files in a game directory.
|
|
114
|
+
*/
|
|
115
|
+
export async function findPackedAudioFiles(gamePath, maxFiles = 50) {
|
|
116
|
+
const results = [];
|
|
117
|
+
|
|
118
|
+
async function scan(dir, depth = 0) {
|
|
119
|
+
if (depth > 5 || results.length >= maxFiles) return;
|
|
120
|
+
try {
|
|
121
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (results.length >= maxFiles) break;
|
|
124
|
+
const fullPath = join(dir, entry.name);
|
|
125
|
+
if (entry.isDirectory()) {
|
|
126
|
+
const lower = entry.name.toLowerCase();
|
|
127
|
+
if (["__pycache__", "node_modules", ".git"].some(s => lower.includes(s))) continue;
|
|
128
|
+
await scan(fullPath, depth + 1);
|
|
129
|
+
} else if (entry.isFile()) {
|
|
130
|
+
const ext = extname(entry.name).toLowerCase();
|
|
131
|
+
// Formats vgmstream-cli can convert directly
|
|
132
|
+
// (.bnk needs bnkextr preprocessing — skip for now)
|
|
133
|
+
if (ext === ".wem" || ext === ".fsb" || ext === ".bank") {
|
|
134
|
+
results.push({ path: fullPath, name: entry.name, dir });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch { /* skip */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await scan(gamePath);
|
|
142
|
+
return results;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Extract/convert a packed audio file to WAV using vgmstream-cli.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} inputPath - Path to .wem/.bnk/.bank/.fsb file
|
|
149
|
+
* @param {string} outputDir - Directory to write WAV files to
|
|
150
|
+
* @param {string} vgmstreamPath - Path to vgmstream-cli binary
|
|
151
|
+
* @returns {string[]} Array of output WAV file paths
|
|
152
|
+
*/
|
|
153
|
+
export async function extractToWav(inputPath, outputDir, vgmstreamPath) {
|
|
154
|
+
await mkdir(outputDir, { recursive: true });
|
|
155
|
+
|
|
156
|
+
// Resolve to absolute OS-native paths (critical on Windows where
|
|
157
|
+
// MSYS uses forward slashes but native exes need backslashes)
|
|
158
|
+
const absInput = resolvePath(inputPath);
|
|
159
|
+
const ext = extname(absInput).toLowerCase();
|
|
160
|
+
const baseName = basename(absInput, ext);
|
|
161
|
+
const outputPath = resolvePath(join(outputDir, `${baseName}.wav`));
|
|
162
|
+
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
// For .bnk files, vgmstream can extract subsongs
|
|
165
|
+
// First try with -S flag to get subsong count
|
|
166
|
+
execFile(vgmstreamPath, ["-m", absInput], { windowsHide: true, timeout: 10000 }, (err, stdout) => {
|
|
167
|
+
if (err) {
|
|
168
|
+
// Single file conversion
|
|
169
|
+
execFile(vgmstreamPath, ["-o", outputPath, absInput], { windowsHide: true, timeout: 30000 }, (err2) => {
|
|
170
|
+
if (err2) reject(new Error(`Failed to convert ${basename(absInput)}: ${err2.message}`));
|
|
171
|
+
else resolve([outputPath]);
|
|
172
|
+
});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Check if it has multiple subsongs
|
|
177
|
+
const subsongMatch = stdout.match(/stream count:\s*(\d+)/i) || stdout.match(/subsong count:\s*(\d+)/i);
|
|
178
|
+
const subsongCount = subsongMatch ? parseInt(subsongMatch[1]) : 1;
|
|
179
|
+
|
|
180
|
+
if (subsongCount <= 1) {
|
|
181
|
+
// Single conversion
|
|
182
|
+
execFile(vgmstreamPath, ["-o", outputPath, absInput], { windowsHide: true, timeout: 30000 }, (err2) => {
|
|
183
|
+
if (err2) reject(new Error(`Failed to convert ${basename(absInput)}: ${err2.message}`));
|
|
184
|
+
else resolve([outputPath]);
|
|
185
|
+
});
|
|
186
|
+
} else {
|
|
187
|
+
// Extract each subsong (up to 20)
|
|
188
|
+
const count = Math.min(subsongCount, 20);
|
|
189
|
+
const outputs = [];
|
|
190
|
+
let done = 0;
|
|
191
|
+
|
|
192
|
+
for (let i = 1; i <= count; i++) {
|
|
193
|
+
const subOutput = join(outputDir, `${baseName}_${String(i).padStart(3, "0")}.wav`);
|
|
194
|
+
execFile(vgmstreamPath, ["-o", resolvePath(subOutput), "-s", String(i), absInput], { windowsHide: true, timeout: 30000 }, (err2) => {
|
|
195
|
+
if (!err2) outputs.push(subOutput);
|
|
196
|
+
done++;
|
|
197
|
+
if (done === count) resolve(outputs);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
}
|