Skip to content

Commit d033864

Browse files
raytileyRay Tileyclaude
authored
Add file upload example script with chunked upload workflow (#5)
Co-authored-by: Ray Tiley <raytiley@Rays-Mac-mini.local> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9aca3af commit d033864

3 files changed

Lines changed: 174 additions & 3 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ Before using this script edit the `utils.mjs` to point the script at your server
1616

1717
Usage: `node new-show.mjs`
1818

19+
### js/file-upload.mjs
20+
21+
Uploads a file to Cablecast using the chunked FileUpload API. Demonstrates the full workflow:
22+
1. Create an upload job (`POST /cablecastapi/v1/fileuploads`)
23+
2. Upload the file in 5 MB segments (`POST /cablecastapi/v1/fileuploads/{id}/upload`)
24+
3. Mark the upload complete (`PUT /cablecastapi/v1/fileuploads/{id}`) — this creates an Asset and links it to the destination file store
25+
4. Poll until the server finishes processing the file
26+
27+
Usage: `node file-upload.mjs <path-to-file> [destination-store-id]`
28+
29+
The destination store ID defaults to `11`. Use `GET /cablecastapi/v1/filestores` to list available stores on your system.
30+
1931
### js/macros.mjs
2032

2133
Lists all of the Control Rooms and Macros for a system. Also fires a named `Start Meeting` macro.

js/file-upload.mjs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import 'dotenv/config';
2+
import { cablecastAPIRequest, SERVER_BASE_URL, USERNAME, PASSWORD } from './utils.mjs';
3+
import { openSync, readSync, closeSync, statSync } from 'fs';
4+
import { basename } from 'path';
5+
6+
/**
7+
* Uploads a file to Cablecast using the chunked FileUpload API.
8+
*
9+
* The upload workflow is:
10+
* 1. POST /v1/fileuploads - Create an upload job
11+
* 2. POST /v1/fileuploads/{id}/upload - Upload each chunk (segment)
12+
* 3. PUT /v1/fileuploads/{id} - Mark upload complete (triggers asset creation)
13+
* 4. Poll GET /v1/fileuploads/{id} - Wait for server to finish processing
14+
*
15+
* FileUpload States:
16+
* 0 = Error
17+
* 1 = Uploading (segments being received)
18+
* 2 = UploadingComplete (client done, server reassembling)
19+
* 3 = PostProcessing (reassembling segments)
20+
* 4 = Transferring (moving file to destination store)
21+
* 5 = Finished (complete)
22+
* 6 = Timeout (abandoned after 4 hours of inactivity)
23+
*
24+
* Usage:
25+
* node file-upload.mjs <path-to-file> [destination-store-id]
26+
*
27+
* Example:
28+
* node file-upload.mjs ./my-video.mp4 11
29+
*/
30+
31+
const CHUNK_SIZE = 5 * 1024 * 1024; // 5 MB per segment
32+
33+
async function main() {
34+
const filePath = process.argv[2];
35+
const destinationStoreId = parseInt(process.argv[3] || '11', 10);
36+
37+
if (!filePath) {
38+
console.error('Usage: node file-upload.mjs <path-to-file> [destination-store-id]');
39+
console.error('');
40+
console.error(' destination-store-id defaults to 11. Use GET /v1/filestores to list available stores.');
41+
process.exit(1);
42+
}
43+
44+
if (Number.isNaN(destinationStoreId)) {
45+
console.error('destination-store-id must be a number');
46+
process.exit(1);
47+
}
48+
49+
const fileName = basename(filePath);
50+
const fileSize = statSync(filePath).size;
51+
const totalSegments = Math.ceil(fileSize / CHUNK_SIZE);
52+
53+
console.log(`File: ${fileName} (${(fileSize / 1024 / 1024).toFixed(2)} MB)`);
54+
console.log(`Destination store: ${destinationStoreId}`);
55+
console.log(`Segments: ${totalSegments} (${(CHUNK_SIZE / 1024 / 1024).toFixed(0)} MB each)`);
56+
console.log('');
57+
58+
// Step 1: Create the FileUpload record
59+
console.log('Creating upload job...');
60+
const createResponse = await cablecastAPIRequest('/cablecastapi/v1/fileuploads', 'POST', {
61+
fileUpload: {
62+
fileName: fileName,
63+
destinationStore: destinationStoreId,
64+
totalSegments: totalSegments,
65+
size: fileSize,
66+
}
67+
});
68+
const uploadId = createResponse.fileUpload.id;
69+
console.log(`Upload job created: ID ${uploadId}`);
70+
71+
// Step 2: Upload each segment
72+
// The segment upload endpoint expects multipart form data with a "file" field
73+
// and a "segment" field. We can't use cablecastAPIRequest here since it sends JSON.
74+
// Read each chunk from disk on demand so memory usage stays bounded to ~CHUNK_SIZE.
75+
const fd = openSync(filePath, 'r');
76+
try {
77+
for (let segment = 0; segment < totalSegments; segment++) {
78+
const start = segment * CHUNK_SIZE;
79+
const chunkSize = Math.min(CHUNK_SIZE, fileSize - start);
80+
const chunk = Buffer.alloc(chunkSize);
81+
readSync(fd, chunk, 0, chunkSize, start);
82+
83+
const formData = new FormData();
84+
formData.append('segment', segment.toString());
85+
formData.append('file', new Blob([chunk]), fileName);
86+
87+
const response = await fetch(
88+
`${SERVER_BASE_URL}/cablecastapi/v1/fileuploads/${uploadId}/upload`,
89+
{
90+
method: 'POST',
91+
headers: {
92+
'Authorization': `Basic ${btoa(`${USERNAME}:${PASSWORD}`)}`,
93+
},
94+
body: formData,
95+
}
96+
);
97+
98+
if (!response.ok) {
99+
const text = await response.text();
100+
throw new Error(`Failed to upload segment ${segment}: ${response.status} ${text}`);
101+
}
102+
103+
console.log(` Uploaded segment ${segment + 1} of ${totalSegments} (${(chunkSize / 1024).toFixed(0)} KB)`);
104+
}
105+
} finally {
106+
closeSync(fd);
107+
}
108+
109+
// Step 3: Mark the upload as complete (state 2 = UploadingComplete)
110+
// This triggers the server to create an Asset and link it to the destination file store.
111+
console.log('Marking upload complete...');
112+
const completeResponse = await cablecastAPIRequest(`/cablecastapi/v1/fileuploads/${uploadId}`, 'PUT', {
113+
fileUpload: {
114+
state: 2, // UploadingComplete
115+
}
116+
});
117+
console.log(`Upload marked complete. Asset ID: ${completeResponse.fileUpload.asset ?? '(pending)'}`);
118+
119+
// Step 4: Poll until the server finishes processing
120+
console.log('Waiting for server to process file...');
121+
const maxWaitMs = 5 * 60 * 1000; // 5 minutes
122+
const pollIntervalMs = 3000;
123+
const startTime = Date.now();
124+
125+
while (Date.now() - startTime < maxWaitMs) {
126+
const status = await cablecastAPIRequest(`/cablecastapi/v1/fileuploads/${uploadId}`);
127+
const state = status.fileUpload.state;
128+
129+
const stateNames = {
130+
0: 'Error', 1: 'Uploading', 2: 'UploadingComplete',
131+
3: 'PostProcessing', 4: 'Transferring', 5: 'Finished', 6: 'Timeout'
132+
};
133+
console.log(` State: ${stateNames[state] || state} (${state})`);
134+
135+
if (state === 5) {
136+
console.log('');
137+
console.log('Upload complete!');
138+
console.log(` FileUpload ID: ${status.fileUpload.id}`);
139+
console.log(` Asset ID: ${status.fileUpload.asset}`);
140+
console.log(` File: ${status.fileUpload.fileName}`);
141+
return;
142+
}
143+
144+
if (state === 0 || state === 6) {
145+
throw new Error(`Upload failed with state: ${stateNames[state]} (${state})`);
146+
}
147+
148+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
149+
}
150+
151+
console.error('Timed out waiting for processing. Check the upload status manually:');
152+
console.error(` GET /cablecastapi/v1/fileuploads/${uploadId}`);
153+
process.exit(1);
154+
}
155+
156+
main().catch(error => {
157+
console.error(error);
158+
process.exit(1);
159+
});

js/utils.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

2-
const SERVER_BASE_URL = 'https://eng-demo.cablecast.tv';
3-
const USERNAME = 'admin';
4-
const PASSWORD = process.env.CABLECAST_PASSWORD ?? 'yourpassword';
2+
export const SERVER_BASE_URL = 'https://eng-demo.cablecast.tv';
3+
export const USERNAME = 'admin';
4+
export const PASSWORD = process.env.CABLECAST_PASSWORD ?? 'yourpassword';
55

66
export async function cablecastAPIRequest(endpoint, method = 'GET', body, parseResponse = true) {
77
let response = await fetch(`${SERVER_BASE_URL}${endpoint}`, {

0 commit comments

Comments
 (0)