|
| 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 | +}); |
0 commit comments