Skip to content

Commit 6585edb

Browse files
committed
add uploader url feature
1 parent 35a6d2f commit 6585edb

4 files changed

Lines changed: 235 additions & 25 deletions

File tree

src/components/ConfigModal.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ type ConfigModalProps = {
88
draftInactivityTimeLimit: number;
99
onDraftInactivityTimeLimitChange: (newLimit: number) => void;
1010
submitInactivityTimeLimitChange: () => void;
11+
draftUploaderUrl: string;
12+
onDraftUploaderUrlChange: (newUrl: string) => void;
13+
submitUploaderUrlChange: () => void;
1114
};
1215

1316
export function asModal<P extends { isOpen: boolean; onClose: () => void }>(
@@ -44,7 +47,10 @@ function ConfigModalBase({
4447
recordingConfig,
4548
draftInactivityTimeLimit,
4649
onDraftInactivityTimeLimitChange,
47-
submitInactivityTimeLimitChange
50+
submitInactivityTimeLimitChange,
51+
draftUploaderUrl,
52+
onDraftUploaderUrlChange,
53+
submitUploaderUrlChange
4854
}: ConfigModalProps) {
4955
return (
5056
<div>
@@ -69,6 +75,23 @@ function ConfigModalBase({
6975
Recording will automatically stop after this many seconds of inactivity
7076
</p>
7177
</div>
78+
79+
<div className="form-group">
80+
<label className="form-label" htmlFor="uploader-url">
81+
Uploader URL
82+
</label>
83+
<input
84+
id="uploader-url"
85+
type='text'
86+
className='form-input'
87+
value={draftUploaderUrl}
88+
onChange={(e) => onDraftUploaderUrlChange(e.target.value)}
89+
placeholder="https://example.com/upload"
90+
/>
91+
<p className="text-muted" style={{fontSize: '0.875rem', marginTop: '0.5rem'}}>
92+
URL endpoint for uploading recorded files (leave empty to disable uploads)
93+
</p>
94+
</div>
7295
</div>
7396
<div className="modal-footer">
7497
<button
@@ -83,6 +106,7 @@ function ConfigModalBase({
83106
className='btn-primary'
84107
onClick={() => {
85108
submitInactivityTimeLimitChange();
109+
submitUploaderUrlChange();
86110
onClose();
87111
}}
88112
>

src/jamscribe_module.tsx

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,15 @@ import 'springboard/modules/files/files_module';
88

99
import type {FileSaver, RecordingConfig} from './services/recorder';
1010

11+
// @platform "node"
12+
import {uploadFile} from './services/upload_service';
13+
// @platform end
14+
1115
let fileSaver: FileSaver | undefined;
1216

1317
// @platform "node"
1418
import fs from 'node:fs';
15-
fileSaver = {
16-
writeFile: async (fileName, buffer) => {
17-
if (!fs.existsSync('./midi_files')) {
18-
fs.mkdirSync('midi_files')
19-
}
20-
21-
await fs.promises.writeFile(fileName, buffer);
22-
},
23-
};
19+
// fileSaver will be set inside the module after recordingConfig is available
2420
// @platform end
2521

2622
import {MidiRecorderImpl} from './services/recorder';
@@ -35,25 +31,51 @@ type DraftedFile = {
3531

3632
const initialRecordingConfig: RecordingConfig = {
3733
inactivityTimeLimitSeconds: 60,
34+
uploaderUrl: '',
3835
};
3936

4037
springboard.registerModule('JamScribe', {}, async (moduleAPI) => {
41-
// @platform "node"
42-
await moduleAPI.getModule('io').ensureListening();
43-
// @platform end
38+
if (moduleAPI.deps.core.isMaestro()) {
39+
await moduleAPI.getModule('io').ensureListening();
40+
}
4441

4542
const recordingConfig = await moduleAPI.statesAPI.createPersistentState('recordingConfig', initialRecordingConfig);
4643
const draftRecordingConfig = await moduleAPI.statesAPI.createSharedState('draftRecordingConfig', recordingConfig.getState());
4744

45+
// @platform "node"
46+
fileSaver = {
47+
writeFile: async (fileName, buffer) => {
48+
if (!fs.existsSync('./midi_files')) {
49+
fs.mkdirSync('midi_files')
50+
}
51+
52+
await fs.promises.writeFile(fileName, buffer);
53+
54+
try {
55+
await uploadFile(fileName, 'audio/midi', buffer, recordingConfig.getState().uploaderUrl);
56+
} catch (error) {
57+
console.error('Upload failed, but file saved locally:', error);
58+
}
59+
},
60+
};
61+
// @platform end
62+
4863
const logMessages = await moduleAPI.statesAPI.createSharedState<LogMessage[]>('logMessages', []);
4964
const draftedFiles = await moduleAPI.statesAPI.createSharedState<DraftedFile[]>('draftedFiles', []);
5065

51-
const changeDraftInactivityTimeLimit = moduleAPI.createAction('changeDraftInactivityTimeLimit', {}, async ({limit}: {limit: number}) => {
52-
draftRecordingConfig.setState(c => ({...c, inactivityTimeLimitSeconds: limit}));
53-
});
54-
55-
const submitInactivityTimeLimit = moduleAPI.createAction('submitInactivityTimeLimit', {}, async () => {
56-
recordingConfig.setState(c => ({...c, inactivityTimeLimitSeconds: draftRecordingConfig.getState().inactivityTimeLimitSeconds}));
66+
const actions = moduleAPI.createActions({
67+
changeDraftInactivityTimeLimit: async ({limit}: {limit: number}) => {
68+
draftRecordingConfig.setState(c => ({...c, inactivityTimeLimitSeconds: limit}));
69+
},
70+
submitInactivityTimeLimit: async () => {
71+
recordingConfig.setState(c => ({...c, inactivityTimeLimitSeconds: draftRecordingConfig.getState().inactivityTimeLimitSeconds}));
72+
},
73+
changeDraftUploaderUrl: async ({url}: {url: string}) => {
74+
draftRecordingConfig.setState(c => ({...c, uploaderUrl: url}));
75+
},
76+
submitUploaderUrl: async () => {
77+
recordingConfig.setState(c => ({...c, uploaderUrl: draftRecordingConfig.getState().uploaderUrl}));
78+
},
5779
});
5880

5981
moduleAPI.registerRoute('/', {}, () => (
@@ -63,8 +85,12 @@ springboard.registerModule('JamScribe', {}, async (moduleAPI) => {
6385
recordingConfig={recordingConfig.useState()}
6486

6587
draftInactivityTimeLimit={draftRecordingConfig.useState().inactivityTimeLimitSeconds}
66-
onDraftInactivityTimeLimitChange={(limit: number) => changeDraftInactivityTimeLimit({limit})}
67-
submitInactivityTimeLimitChange={() => submitInactivityTimeLimit({})}
88+
onDraftInactivityTimeLimitChange={(limit: number) => actions.changeDraftInactivityTimeLimit({limit})}
89+
submitInactivityTimeLimitChange={() => actions.submitInactivityTimeLimit()}
90+
91+
draftUploaderUrl={draftRecordingConfig.useState().uploaderUrl}
92+
onDraftUploaderUrlChange={(url: string) => actions.changeDraftUploaderUrl({url})}
93+
submitUploaderUrlChange={() => actions.submitUploaderUrl()}
6894
/>
6995
));
7096

@@ -76,7 +102,7 @@ springboard.registerModule('JamScribe', {}, async (moduleAPI) => {
76102
// default implementation of file saver
77103
if (!fileSaver) {
78104
fileSaver = {
79-
writeFile: (fileName, buffer) => {
105+
writeFile: async (fileName, buffer) => {
80106
const filesModule = moduleAPI.deps.module.moduleRegistry.getModule('Files');
81107
const file = new File([
82108
new Blob([buffer.toString()])
@@ -120,6 +146,10 @@ type MainProps = {
120146
draftInactivityTimeLimit: number;
121147
onDraftInactivityTimeLimitChange: (newLimit: number) => void;
122148
submitInactivityTimeLimitChange: () => void;
149+
150+
draftUploaderUrl: string;
151+
onDraftUploaderUrlChange: (newUrl: string) => void;
152+
submitUploaderUrlChange: () => void;
123153
}
124154

125155
const Main = ({
@@ -128,7 +158,10 @@ const Main = ({
128158
recordingConfig,
129159
draftInactivityTimeLimit,
130160
onDraftInactivityTimeLimitChange,
131-
submitInactivityTimeLimitChange
161+
submitInactivityTimeLimitChange,
162+
draftUploaderUrl,
163+
onDraftUploaderUrlChange,
164+
submitUploaderUrlChange
132165
}: MainProps) => {
133166
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
134167

@@ -152,6 +185,9 @@ const Main = ({
152185
draftInactivityTimeLimit={draftInactivityTimeLimit}
153186
onDraftInactivityTimeLimitChange={onDraftInactivityTimeLimitChange}
154187
submitInactivityTimeLimitChange={submitInactivityTimeLimitChange}
188+
draftUploaderUrl={draftUploaderUrl}
189+
onDraftUploaderUrlChange={onDraftUploaderUrlChange}
190+
submitUploaderUrlChange={submitUploaderUrlChange}
155191
/>
156192

157193
<div className="main-grid">
@@ -192,7 +228,7 @@ const Main = ({
192228
</div>
193229
<ul className="log-list">
194230
{logs.length > 0 ? (
195-
[...logs].reverse().map((logEntry, i) => {
231+
[...logs].reverse().map((logEntry) => {
196232
const formatTime = (date: Date | string | number) => {
197233
const dateObj = new Date(date);
198234
const now = new Date();
@@ -218,7 +254,7 @@ const Main = ({
218254
<li key={logEntry.id} className='log-item fade-in'>
219255
<span className="log-timestamp">{formatTime(logEntry.timestamp)}</span>
220256
<span className="log-message">{logEntry.message}</span>
221-
</li>
257+
</li>
222258
);
223259
})
224260
) : (

src/services/recorder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type FileSaver = {
2828

2929
export type RecordingConfig = {
3030
inactivityTimeLimitSeconds: number;
31+
uploaderUrl: string;
3132
}
3233

3334
export class MidiRecorderImpl {

src/services/upload_service.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// @platform "node"
2+
import * as crypto from 'node:crypto';
3+
// @platform end
4+
5+
const PART_SIZE = 5 * 1024 * 1024;
6+
7+
function computeSha1(buffer: Buffer): string {
8+
const hash = crypto.createHash('sha1');
9+
hash.update(buffer);
10+
return hash.digest('hex');
11+
}
12+
13+
type UploadInfo = {
14+
project_name: string;
15+
workspace_name: string;
16+
endpoints: {
17+
initiate_upload: string;
18+
part_url: string;
19+
complete_upload: string;
20+
abort_upload: string;
21+
};
22+
};
23+
24+
type InitiateData = {
25+
upload_id: string;
26+
file_path: string;
27+
upload_record_id: string;
28+
part_url_endpoint: string;
29+
complete_url: string;
30+
abort_url: string;
31+
};
32+
33+
export const uploadFile = async (fileName: string, contentType: string, buffer: Buffer, uploaderUrl: string): Promise<void> => {
34+
try {
35+
if (!uploaderUrl) {
36+
console.log('No uploader URL configured, skipping upload');
37+
return;
38+
}
39+
40+
const infoResponse = await fetch(uploaderUrl);
41+
42+
if (!infoResponse.ok) {
43+
const errorText = await infoResponse.text();
44+
throw new Error(`Failed to get upload info: ${infoResponse.status} ${infoResponse.statusText}\n${errorText}`);
45+
}
46+
47+
const uploadInfo = await infoResponse.json() as UploadInfo;
48+
49+
const fileSize = buffer.length;
50+
const partCount = Math.ceil(fileSize / PART_SIZE);
51+
const fileHash = computeSha1(buffer);
52+
53+
const initiateResponse = await fetch(uploadInfo.endpoints.initiate_upload, {
54+
method: 'POST',
55+
headers: {
56+
'Content-Type': 'application/json',
57+
},
58+
body: JSON.stringify({
59+
file_name: fileName,
60+
content_type: contentType,
61+
part_count: partCount,
62+
}),
63+
});
64+
65+
if (!initiateResponse.ok) {
66+
const errorText = await initiateResponse.text();
67+
throw new Error(`Failed to initiate upload: ${initiateResponse.status} ${initiateResponse.statusText}\n${errorText}`);
68+
}
69+
70+
const initiateData = await initiateResponse.json() as InitiateData;
71+
72+
const uploadedParts: Array<{ETag: string; PartNumber: number}> = [];
73+
74+
for (let partNumber = 1; partNumber <= partCount; partNumber++) {
75+
const start = (partNumber - 1) * PART_SIZE;
76+
const end = Math.min(start + PART_SIZE, fileSize);
77+
const partBuffer = buffer.slice(start, end);
78+
79+
const partUrlResponse = await fetch(uploadInfo.endpoints.part_url, {
80+
method: 'POST',
81+
headers: {
82+
'Content-Type': 'application/json',
83+
},
84+
body: JSON.stringify({
85+
upload_id: initiateData.upload_id,
86+
file_path: initiateData.file_path,
87+
part_number: partNumber,
88+
content_type: contentType,
89+
}),
90+
});
91+
92+
if (!partUrlResponse.ok) {
93+
const errorText = await partUrlResponse.text();
94+
throw new Error(`Failed to get part URL: ${partUrlResponse.status} ${partUrlResponse.statusText}\n${errorText}`);
95+
}
96+
97+
const partUrlData = await partUrlResponse.json() as {
98+
part_number: number;
99+
upload_url: string;
100+
};
101+
102+
const uploadPartResponse = await fetch(partUrlData.upload_url, {
103+
method: 'PUT',
104+
body: partBuffer,
105+
headers: {
106+
'Content-Type': contentType,
107+
},
108+
});
109+
110+
if (!uploadPartResponse.ok) {
111+
const errorText = await uploadPartResponse.text();
112+
throw new Error(`Failed to upload part ${partNumber}: ${uploadPartResponse.status} ${uploadPartResponse.statusText}\n${errorText}`);
113+
}
114+
115+
const etag = uploadPartResponse.headers.get('ETag');
116+
if (!etag) {
117+
throw new Error(`No ETag returned for part ${partNumber}`);
118+
}
119+
120+
uploadedParts.push({
121+
ETag: etag.replace(/"/g, ''),
122+
PartNumber: partNumber,
123+
});
124+
}
125+
126+
const completeResponse = await fetch(uploadInfo.endpoints.complete_upload, {
127+
method: 'POST',
128+
headers: {
129+
'Content-Type': 'application/json',
130+
},
131+
body: JSON.stringify({
132+
upload_id: initiateData.upload_id,
133+
file_path: initiateData.file_path,
134+
upload_record_id: initiateData.upload_record_id,
135+
parts: uploadedParts,
136+
file_size: fileSize,
137+
file_hash: fileHash,
138+
}),
139+
});
140+
141+
if (!completeResponse.ok) {
142+
const errorText = await completeResponse.text();
143+
throw new Error(`Failed to complete upload: ${completeResponse.status} ${completeResponse.statusText}\n${errorText}`);
144+
}
145+
} catch (error) {
146+
console.error('Error during multipart upload:', error);
147+
throw error;
148+
}
149+
};

0 commit comments

Comments
 (0)