@vue-skuilder/express 0.1.20 → 0.1.22

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.
@@ -50,7 +50,8 @@ function(newDoc, oldDoc, userCtx, secObj) {
50
50
  }
51
51
 
52
52
  // Special case for design documents - only admins can modify (handled above)
53
- if (newDoc._id.startsWith('_design/')) {
53
+ // Use indexOf instead of startsWith for CouchDB SpiderMonkey compatibility (ES5)
54
+ if (newDoc._id.indexOf('_design/') === 0) {
54
55
  throw({forbidden: "Only admins can modify design documents"});
55
56
  }
56
57
  }
@@ -50,7 +50,8 @@ function(newDoc, oldDoc, userCtx, secObj) {
50
50
  }
51
51
 
52
52
  // Special case for design documents - only admins can modify (handled above)
53
- if (newDoc._id.startsWith('_design/')) {
53
+ // Use indexOf instead of startsWith for CouchDB SpiderMonkey compatibility (ES5)
54
+ if (newDoc._id.indexOf('_design/') === 0) {
54
55
  throw({forbidden: "Only admins can modify design documents"});
55
56
  }
56
57
  }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ts-node
2
+ export {};
3
+ //# sourceMappingURL=normalize-local.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalize-local.d.ts","sourceRoot":"","sources":["../../src/attachment-preprocessing/normalize-local.ts"],"names":[],"mappings":""}
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env ts-node
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { promisify } from 'util';
5
+ import { exec as execCallback } from 'child_process';
6
+ const exec = promisify(execCallback);
7
+ import FFMPEGstatic from 'ffmpeg-static';
8
+ if (!FFMPEGstatic) {
9
+ error('FFMPEGstatic executable not found');
10
+ process.exit(1);
11
+ }
12
+ // string | null here - but we know it's a string from the above check
13
+ const FFMPEG = FFMPEGstatic;
14
+ function log(s) {
15
+ // eslint-disable-next-line no-console
16
+ console.log(s);
17
+ }
18
+ function error(s) {
19
+ // eslint-disable-next-line no-console
20
+ console.error(s);
21
+ }
22
+ function showUsage() {
23
+ log(`
24
+ Usage: tsx normalize-local.ts <input-directory> [-o <output-directory>]
25
+
26
+ Options:
27
+ -o <output-directory> Directory to write normalized files (created if needed)
28
+ Default: same as input directory
29
+
30
+ Examples:
31
+ tsx normalize-local.ts .
32
+ tsx normalize-local.ts /path/to/wavs -o /path/to/output
33
+ tsx normalize-local.ts . -o ./normalized
34
+ `);
35
+ }
36
+ log(`FFMPEG path: ${FFMPEG}`);
37
+ /**
38
+ * Normalizes a single wav file to mp3 with loudnorm
39
+ * Same spec as the attachment preprocessing: I=-16:TP=-1.5:LRA=11
40
+ */
41
+ async function normalizeFile(inputPath, outputDir) {
42
+ const tmpDir = fs.mkdtempSync(`audioNormalize-local-`);
43
+ const baseName = path.basename(inputPath, '.wav');
44
+ const targetDir = outputDir || path.dirname(inputPath);
45
+ const outputPath = path.join(targetDir, `${baseName}.normalized.mp3`);
46
+ const PADDED = path.join(tmpDir, 'padded.wav');
47
+ const PADDED_NORMALIZED = path.join(tmpDir, 'paddedNormalized.wav');
48
+ const NORMALIZED = path.join(tmpDir, 'normalized.mp3');
49
+ try {
50
+ log(`[${baseName}] Processing...`);
51
+ // Pad with 10s of silence
52
+ log(`[${baseName}] Padding with silence...`);
53
+ await exec(`"${FFMPEG}" -i "${inputPath}" -af "adelay=10000|10000" "${PADDED}"`);
54
+ // Analyze loudness
55
+ log(`[${baseName}] Analyzing loudness...`);
56
+ const info = await exec(`"${FFMPEG}" -i "${PADDED}" -af loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json -f null -`);
57
+ const data = JSON.parse(info.stderr.substring(info.stderr.indexOf('{')));
58
+ log(`[${baseName}] Input loudness: I=${data.input_i} LUFS, TP=${data.input_tp} dBTP, LRA=${data.input_lra} LU`);
59
+ // Normalize the padded file
60
+ log(`[${baseName}] Normalizing...`);
61
+ await exec(`"${FFMPEG}" -i "${PADDED}" -af ` +
62
+ `loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=${data.input_i}:` +
63
+ `measured_LRA=${data.input_lra}:measured_TP=${data.input_tp}:` +
64
+ `measured_thresh=${data.input_thresh}:offset=${data.target_offset}:linear=true:` +
65
+ `print_format=summary -ar 48k "${PADDED_NORMALIZED}"`);
66
+ // Cut off the padded part and convert to mp3
67
+ log(`[${baseName}] Cutting padding and encoding to mp3...`);
68
+ await exec(`"${FFMPEG}" -i "${PADDED_NORMALIZED}" -ss 00:00:10.000 -acodec libmp3lame -b:a 192k "${NORMALIZED}"`);
69
+ // Copy to output location
70
+ fs.copyFileSync(NORMALIZED, outputPath);
71
+ log(`[${baseName}] ✓ Saved to: ${outputPath}`);
72
+ }
73
+ catch (e) {
74
+ error(`[${baseName}] Error: ${e}`);
75
+ throw e;
76
+ }
77
+ finally {
78
+ // Cleanup temp directory
79
+ const files = fs.readdirSync(tmpDir);
80
+ files.forEach((file) => {
81
+ fs.unlinkSync(path.join(tmpDir, file));
82
+ });
83
+ fs.rmdirSync(tmpDir);
84
+ }
85
+ }
86
+ async function main() {
87
+ // Check FFMPEG availability
88
+ try {
89
+ if (!fs.existsSync(FFMPEG)) {
90
+ error(`FFMPEG executable not found at path: ${FFMPEG}`);
91
+ process.exit(1);
92
+ }
93
+ const result = await exec(`"${FFMPEG}" -version`);
94
+ const version = result.stdout.split('\n')[0];
95
+ log(`FFMPEG version: ${version}`);
96
+ // Verify loudnorm filter availability
97
+ const filters = await exec(`"${FFMPEG}" -filters | grep loudnorm`);
98
+ if (!filters.stdout.includes('loudnorm')) {
99
+ throw new Error('loudnorm filter not available');
100
+ }
101
+ log('loudnorm filter: available\n');
102
+ }
103
+ catch (e) {
104
+ error(`FFMPEG check failed: ${e}`);
105
+ process.exit(1);
106
+ }
107
+ // Parse command line arguments
108
+ const args = process.argv.slice(2);
109
+ let targetDir = '.';
110
+ let outputDir;
111
+ // Check for help flag
112
+ if (args.includes('-h') || args.includes('--help')) {
113
+ showUsage();
114
+ process.exit(0);
115
+ }
116
+ for (let i = 0; i < args.length; i++) {
117
+ if (args[i] === '-o' && args[i + 1]) {
118
+ outputDir = args[i + 1];
119
+ i++; // Skip next arg
120
+ }
121
+ else if (!args[i].startsWith('-')) {
122
+ targetDir = args[i];
123
+ }
124
+ }
125
+ const absoluteDir = path.resolve(targetDir);
126
+ if (!fs.existsSync(absoluteDir)) {
127
+ error(`Directory not found: ${absoluteDir}`);
128
+ process.exit(1);
129
+ }
130
+ if (!fs.statSync(absoluteDir).isDirectory()) {
131
+ error(`Not a directory: ${absoluteDir}`);
132
+ process.exit(1);
133
+ }
134
+ // Create output directory if specified
135
+ let absoluteOutputDir;
136
+ if (outputDir) {
137
+ absoluteOutputDir = path.resolve(outputDir);
138
+ if (!fs.existsSync(absoluteOutputDir)) {
139
+ fs.mkdirSync(absoluteOutputDir, { recursive: true });
140
+ log(`Created output directory: ${absoluteOutputDir}`);
141
+ }
142
+ else if (!fs.statSync(absoluteOutputDir).isDirectory()) {
143
+ error(`Output path exists but is not a directory: ${absoluteOutputDir}`);
144
+ process.exit(1);
145
+ }
146
+ log(`Output directory: ${absoluteOutputDir}\n`);
147
+ }
148
+ log(`Scanning directory: ${absoluteDir}\n`);
149
+ // Find all .wav files
150
+ const files = fs.readdirSync(absoluteDir);
151
+ const wavFiles = files.filter((f) => f.toLowerCase().endsWith('.wav'));
152
+ if (wavFiles.length === 0) {
153
+ log('No .wav files found in directory');
154
+ process.exit(0);
155
+ }
156
+ log(`Found ${wavFiles.length} .wav file(s)\n`);
157
+ // Process each file
158
+ for (const wavFile of wavFiles) {
159
+ const fullPath = path.join(absoluteDir, wavFile);
160
+ await normalizeFile(fullPath, absoluteOutputDir);
161
+ log(''); // blank line between files
162
+ }
163
+ log(`\nComplete! Processed ${wavFiles.length} file(s)`);
164
+ }
165
+ main().catch((error) => {
166
+ error('Fatal error:', error);
167
+ process.exit(1);
168
+ });
169
+ //# sourceMappingURL=normalize-local.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalize-local.js","sourceRoot":"","sources":["../../src/attachment-preprocessing/normalize-local.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.20",
6
+ "version": "0.1.22",
7
7
  "description": "an API",
8
8
  "main": "dist/index.js",
9
9
  "type": "module",
@@ -35,8 +35,8 @@
35
35
  "author": "Colin Kennedy",
36
36
  "license": "GPL-3.0-or-later",
37
37
  "dependencies": {
38
- "@vue-skuilder/common": "^0.1.20",
39
- "@vue-skuilder/db": "^0.1.20",
38
+ "@vue-skuilder/common": "^0.1.22",
39
+ "@vue-skuilder/db": "^0.1.22",
40
40
  "axios": "^1.12.0",
41
41
  "cookie-parser": "^1.4.7",
42
42
  "cors": "^2.8.5",
@@ -77,5 +77,5 @@
77
77
  "typescript-eslint": "^8.48.1",
78
78
  "vitest": "^4.0.15"
79
79
  },
80
- "stableVersion": "0.1.20"
80
+ "stableVersion": "0.1.22"
81
81
  }
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env ts-node
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { promisify } from 'util';
6
+ import { exec as execCallback } from 'child_process';
7
+ const exec = promisify(execCallback);
8
+
9
+ import FFMPEGstatic from 'ffmpeg-static';
10
+
11
+ if (!FFMPEGstatic) {
12
+ error('FFMPEGstatic executable not found');
13
+ process.exit(1);
14
+ }
15
+
16
+ // string | null here - but we know it's a string from the above check
17
+ const FFMPEG = FFMPEGstatic as unknown as string;
18
+
19
+ function log(s: string) {
20
+ // eslint-disable-next-line no-console
21
+ console.log(s);
22
+ }
23
+ function error(s: string) {
24
+ // eslint-disable-next-line no-console
25
+ console.error(s);
26
+ }
27
+
28
+ function showUsage() {
29
+ log(`
30
+ Usage: tsx normalize-local.ts <input-directory> [-o <output-directory>]
31
+
32
+ Options:
33
+ -o <output-directory> Directory to write normalized files (created if needed)
34
+ Default: same as input directory
35
+
36
+ Examples:
37
+ tsx normalize-local.ts .
38
+ tsx normalize-local.ts /path/to/wavs -o /path/to/output
39
+ tsx normalize-local.ts . -o ./normalized
40
+ `);
41
+ }
42
+
43
+ log(`FFMPEG path: ${FFMPEG}`);
44
+
45
+ /**
46
+ * From FFMPEG's loudnorm output - loudness data on a media file
47
+ */
48
+ interface LoudnessData {
49
+ input_i: string;
50
+ input_tp: string;
51
+ input_lra: string;
52
+ input_thresh: string;
53
+ output_i: string;
54
+ output_tp: string;
55
+ output_lra: string;
56
+ output_thresh: string;
57
+ normalization_type: string;
58
+ target_offset: string;
59
+ }
60
+
61
+ /**
62
+ * Normalizes a single wav file to mp3 with loudnorm
63
+ * Same spec as the attachment preprocessing: I=-16:TP=-1.5:LRA=11
64
+ */
65
+ async function normalizeFile(
66
+ inputPath: string,
67
+ outputDir?: string
68
+ ): Promise<void> {
69
+ const tmpDir = fs.mkdtempSync(`audioNormalize-local-`);
70
+ const baseName = path.basename(inputPath, '.wav');
71
+ const targetDir = outputDir || path.dirname(inputPath);
72
+ const outputPath = path.join(targetDir, `${baseName}.normalized.mp3`);
73
+
74
+ const PADDED = path.join(tmpDir, 'padded.wav');
75
+ const PADDED_NORMALIZED = path.join(tmpDir, 'paddedNormalized.wav');
76
+ const NORMALIZED = path.join(tmpDir, 'normalized.mp3');
77
+
78
+ try {
79
+ log(`[${baseName}] Processing...`);
80
+
81
+ // Pad with 10s of silence
82
+ log(`[${baseName}] Padding with silence...`);
83
+ await exec(
84
+ `"${FFMPEG}" -i "${inputPath}" -af "adelay=10000|10000" "${PADDED}"`
85
+ );
86
+
87
+ // Analyze loudness
88
+ log(`[${baseName}] Analyzing loudness...`);
89
+ const info = await exec(
90
+ `"${FFMPEG}" -i "${PADDED}" -af loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json -f null -`
91
+ );
92
+
93
+ const data: LoudnessData = JSON.parse(
94
+ info.stderr.substring(info.stderr.indexOf('{'))
95
+ );
96
+
97
+ log(
98
+ `[${baseName}] Input loudness: I=${data.input_i} LUFS, TP=${data.input_tp} dBTP, LRA=${data.input_lra} LU`
99
+ );
100
+
101
+ // Normalize the padded file
102
+ log(`[${baseName}] Normalizing...`);
103
+ await exec(
104
+ `"${FFMPEG}" -i "${PADDED}" -af ` +
105
+ `loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=${data.input_i}:` +
106
+ `measured_LRA=${data.input_lra}:measured_TP=${data.input_tp}:` +
107
+ `measured_thresh=${data.input_thresh}:offset=${data.target_offset}:linear=true:` +
108
+ `print_format=summary -ar 48k "${PADDED_NORMALIZED}"`
109
+ );
110
+
111
+ // Cut off the padded part and convert to mp3
112
+ log(`[${baseName}] Cutting padding and encoding to mp3...`);
113
+ await exec(
114
+ `"${FFMPEG}" -i "${PADDED_NORMALIZED}" -ss 00:00:10.000 -acodec libmp3lame -b:a 192k "${NORMALIZED}"`
115
+ );
116
+
117
+ // Copy to output location
118
+ fs.copyFileSync(NORMALIZED, outputPath);
119
+ log(`[${baseName}] ✓ Saved to: ${outputPath}`);
120
+ } catch (e) {
121
+ error(`[${baseName}] Error: ${e}`);
122
+ throw e;
123
+ } finally {
124
+ // Cleanup temp directory
125
+ const files = fs.readdirSync(tmpDir);
126
+ files.forEach((file) => {
127
+ fs.unlinkSync(path.join(tmpDir, file));
128
+ });
129
+ fs.rmdirSync(tmpDir);
130
+ }
131
+ }
132
+
133
+ async function main() {
134
+ // Check FFMPEG availability
135
+ try {
136
+ if (!fs.existsSync(FFMPEG)) {
137
+ error(`FFMPEG executable not found at path: ${FFMPEG}`);
138
+ process.exit(1);
139
+ }
140
+
141
+ const result = await exec(`"${FFMPEG}" -version`);
142
+ const version = result.stdout.split('\n')[0];
143
+ log(`FFMPEG version: ${version}`);
144
+
145
+ // Verify loudnorm filter availability
146
+ const filters = await exec(`"${FFMPEG}" -filters | grep loudnorm`);
147
+ if (!filters.stdout.includes('loudnorm')) {
148
+ throw new Error('loudnorm filter not available');
149
+ }
150
+ log('loudnorm filter: available\n');
151
+ } catch (e) {
152
+ error(`FFMPEG check failed: ${e}`);
153
+ process.exit(1);
154
+ }
155
+
156
+ // Parse command line arguments
157
+ const args = process.argv.slice(2);
158
+ let targetDir = '.';
159
+ let outputDir: string | undefined;
160
+
161
+ // Check for help flag
162
+ if (args.includes('-h') || args.includes('--help')) {
163
+ showUsage();
164
+ process.exit(0);
165
+ }
166
+
167
+ for (let i = 0; i < args.length; i++) {
168
+ if (args[i] === '-o' && args[i + 1]) {
169
+ outputDir = args[i + 1];
170
+ i++; // Skip next arg
171
+ } else if (!args[i].startsWith('-')) {
172
+ targetDir = args[i];
173
+ }
174
+ }
175
+
176
+ const absoluteDir = path.resolve(targetDir);
177
+
178
+ if (!fs.existsSync(absoluteDir)) {
179
+ error(`Directory not found: ${absoluteDir}`);
180
+ process.exit(1);
181
+ }
182
+
183
+ if (!fs.statSync(absoluteDir).isDirectory()) {
184
+ error(`Not a directory: ${absoluteDir}`);
185
+ process.exit(1);
186
+ }
187
+
188
+ // Create output directory if specified
189
+ let absoluteOutputDir: string | undefined;
190
+ if (outputDir) {
191
+ absoluteOutputDir = path.resolve(outputDir);
192
+ if (!fs.existsSync(absoluteOutputDir)) {
193
+ fs.mkdirSync(absoluteOutputDir, { recursive: true });
194
+ log(`Created output directory: ${absoluteOutputDir}`);
195
+ } else if (!fs.statSync(absoluteOutputDir).isDirectory()) {
196
+ error(`Output path exists but is not a directory: ${absoluteOutputDir}`);
197
+ process.exit(1);
198
+ }
199
+ log(`Output directory: ${absoluteOutputDir}\n`);
200
+ }
201
+
202
+ log(`Scanning directory: ${absoluteDir}\n`);
203
+
204
+ // Find all .wav files
205
+ const files = fs.readdirSync(absoluteDir);
206
+ const wavFiles = files.filter((f) => f.toLowerCase().endsWith('.wav'));
207
+
208
+ if (wavFiles.length === 0) {
209
+ log('No .wav files found in directory');
210
+ process.exit(0);
211
+ }
212
+
213
+ log(`Found ${wavFiles.length} .wav file(s)\n`);
214
+
215
+ // Process each file
216
+ for (const wavFile of wavFiles) {
217
+ const fullPath = path.join(absoluteDir, wavFile);
218
+ await normalizeFile(fullPath, absoluteOutputDir);
219
+ log(''); // blank line between files
220
+ }
221
+
222
+ log(`\nComplete! Processed ${wavFiles.length} file(s)`);
223
+ }
224
+
225
+ main().catch((error) => {
226
+ error('Fatal error:', error);
227
+ process.exit(1);
228
+ });