From b82da637c68fe15e220f5dc4f735db3d48f2e475 Mon Sep 17 00:00:00 2001 From: Ray Tiley Date: Fri, 6 Mar 2026 06:26:35 -0500 Subject: [PATCH] Add file upload example script with chunked upload workflow Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 12 ++++ js/file-upload.mjs | 159 +++++++++++++++++++++++++++++++++++++++++++++ js/utils.mjs | 6 +- 3 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 js/file-upload.mjs diff --git a/README.md b/README.md index abfc91e..b9874a1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,18 @@ Before using this script edit the `utils.mjs` to point the script at your server Usage: `node new-show.mjs` +### js/file-upload.mjs + +Uploads a file to Cablecast using the chunked FileUpload API. Demonstrates the full workflow: +1. Create an upload job (`POST /cablecastapi/v1/fileuploads`) +2. Upload the file in 5 MB segments (`POST /cablecastapi/v1/fileuploads/{id}/upload`) +3. Mark the upload complete (`PUT /cablecastapi/v1/fileuploads/{id}`) — this creates an Asset and links it to the destination file store +4. Poll until the server finishes processing the file + +Usage: `node file-upload.mjs [destination-store-id]` + +The destination store ID defaults to `11`. Use `GET /cablecastapi/v1/filestores` to list available stores on your system. + ### js/macros.mjs Lists all of the Control Rooms and Macros for a system. Also fires a named `Start Meeting` macro. diff --git a/js/file-upload.mjs b/js/file-upload.mjs new file mode 100644 index 0000000..a97b49c --- /dev/null +++ b/js/file-upload.mjs @@ -0,0 +1,159 @@ +import 'dotenv/config'; +import { cablecastAPIRequest, SERVER_BASE_URL, USERNAME, PASSWORD } from './utils.mjs'; +import { openSync, readSync, closeSync, statSync } from 'fs'; +import { basename } from 'path'; + +/** + * Uploads a file to Cablecast using the chunked FileUpload API. + * + * The upload workflow is: + * 1. POST /v1/fileuploads - Create an upload job + * 2. POST /v1/fileuploads/{id}/upload - Upload each chunk (segment) + * 3. PUT /v1/fileuploads/{id} - Mark upload complete (triggers asset creation) + * 4. Poll GET /v1/fileuploads/{id} - Wait for server to finish processing + * + * FileUpload States: + * 0 = Error + * 1 = Uploading (segments being received) + * 2 = UploadingComplete (client done, server reassembling) + * 3 = PostProcessing (reassembling segments) + * 4 = Transferring (moving file to destination store) + * 5 = Finished (complete) + * 6 = Timeout (abandoned after 4 hours of inactivity) + * + * Usage: + * node file-upload.mjs [destination-store-id] + * + * Example: + * node file-upload.mjs ./my-video.mp4 11 + */ + +const CHUNK_SIZE = 5 * 1024 * 1024; // 5 MB per segment + +async function main() { + const filePath = process.argv[2]; + const destinationStoreId = parseInt(process.argv[3] || '11', 10); + + if (!filePath) { + console.error('Usage: node file-upload.mjs [destination-store-id]'); + console.error(''); + console.error(' destination-store-id defaults to 11. Use GET /v1/filestores to list available stores.'); + process.exit(1); + } + + if (Number.isNaN(destinationStoreId)) { + console.error('destination-store-id must be a number'); + process.exit(1); + } + + const fileName = basename(filePath); + const fileSize = statSync(filePath).size; + const totalSegments = Math.ceil(fileSize / CHUNK_SIZE); + + console.log(`File: ${fileName} (${(fileSize / 1024 / 1024).toFixed(2)} MB)`); + console.log(`Destination store: ${destinationStoreId}`); + console.log(`Segments: ${totalSegments} (${(CHUNK_SIZE / 1024 / 1024).toFixed(0)} MB each)`); + console.log(''); + + // Step 1: Create the FileUpload record + console.log('Creating upload job...'); + const createResponse = await cablecastAPIRequest('/cablecastapi/v1/fileuploads', 'POST', { + fileUpload: { + fileName: fileName, + destinationStore: destinationStoreId, + totalSegments: totalSegments, + size: fileSize, + } + }); + const uploadId = createResponse.fileUpload.id; + console.log(`Upload job created: ID ${uploadId}`); + + // Step 2: Upload each segment + // The segment upload endpoint expects multipart form data with a "file" field + // and a "segment" field. We can't use cablecastAPIRequest here since it sends JSON. + // Read each chunk from disk on demand so memory usage stays bounded to ~CHUNK_SIZE. + const fd = openSync(filePath, 'r'); + try { + for (let segment = 0; segment < totalSegments; segment++) { + const start = segment * CHUNK_SIZE; + const chunkSize = Math.min(CHUNK_SIZE, fileSize - start); + const chunk = Buffer.alloc(chunkSize); + readSync(fd, chunk, 0, chunkSize, start); + + const formData = new FormData(); + formData.append('segment', segment.toString()); + formData.append('file', new Blob([chunk]), fileName); + + const response = await fetch( + `${SERVER_BASE_URL}/cablecastapi/v1/fileuploads/${uploadId}/upload`, + { + method: 'POST', + headers: { + 'Authorization': `Basic ${btoa(`${USERNAME}:${PASSWORD}`)}`, + }, + body: formData, + } + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to upload segment ${segment}: ${response.status} ${text}`); + } + + console.log(` Uploaded segment ${segment + 1} of ${totalSegments} (${(chunkSize / 1024).toFixed(0)} KB)`); + } + } finally { + closeSync(fd); + } + + // Step 3: Mark the upload as complete (state 2 = UploadingComplete) + // This triggers the server to create an Asset and link it to the destination file store. + console.log('Marking upload complete...'); + const completeResponse = await cablecastAPIRequest(`/cablecastapi/v1/fileuploads/${uploadId}`, 'PUT', { + fileUpload: { + state: 2, // UploadingComplete + } + }); + console.log(`Upload marked complete. Asset ID: ${completeResponse.fileUpload.asset ?? '(pending)'}`); + + // Step 4: Poll until the server finishes processing + console.log('Waiting for server to process file...'); + const maxWaitMs = 5 * 60 * 1000; // 5 minutes + const pollIntervalMs = 3000; + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitMs) { + const status = await cablecastAPIRequest(`/cablecastapi/v1/fileuploads/${uploadId}`); + const state = status.fileUpload.state; + + const stateNames = { + 0: 'Error', 1: 'Uploading', 2: 'UploadingComplete', + 3: 'PostProcessing', 4: 'Transferring', 5: 'Finished', 6: 'Timeout' + }; + console.log(` State: ${stateNames[state] || state} (${state})`); + + if (state === 5) { + console.log(''); + console.log('Upload complete!'); + console.log(` FileUpload ID: ${status.fileUpload.id}`); + console.log(` Asset ID: ${status.fileUpload.asset}`); + console.log(` File: ${status.fileUpload.fileName}`); + return; + } + + if (state === 0 || state === 6) { + throw new Error(`Upload failed with state: ${stateNames[state]} (${state})`); + } + + await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); + } + + console.error('Timed out waiting for processing. Check the upload status manually:'); + console.error(` GET /cablecastapi/v1/fileuploads/${uploadId}`); + process.exit(1); +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/js/utils.mjs b/js/utils.mjs index 7ac613d..1235be5 100644 --- a/js/utils.mjs +++ b/js/utils.mjs @@ -1,7 +1,7 @@ -const SERVER_BASE_URL = 'https://eng-demo.cablecast.tv'; -const USERNAME = 'admin'; -const PASSWORD = process.env.CABLECAST_PASSWORD ?? 'yourpassword'; +export const SERVER_BASE_URL = 'https://eng-demo.cablecast.tv'; +export const USERNAME = 'admin'; +export const PASSWORD = process.env.CABLECAST_PASSWORD ?? 'yourpassword'; export async function cablecastAPIRequest(endpoint, method = 'GET', body, parseResponse = true) { let response = await fetch(`${SERVER_BASE_URL}${endpoint}`, {