- Electron tray app (main, preload, renderer) - Parallel chunked HTTP transfer engine - Dragon Wind server client (auth + multipart API) - Upload queue UI with progress, speed, ETA - Folder browser with search - Settings (workers, chunk size) - Server API docs for desktop multipart endpoints
4 KiB
4 KiB
Dragon Wind Desktop — Server API Additions
These three endpoints need to be added to server.js in the VPM-Uploader repo to support the desktop client.
POST /api/desktop/multipart/init
Auth: x-auth-token header required.
Request:
{
"filename": "A001C001.mxf",
"size": 10737418240,
"prefix": "Jobs/2026-04-06",
"totalParts": 640
}
Response:
{
"uploadId": "abc123...",
"key": "Jobs/2026-04-06/A001C001.mxf",
"bucket": "vpm-media",
"presignedParts": [
"https://s3.example.com/vpm-media/Jobs/...",
"..."
]
}
Implementation notes:
- Call
s3.createMultipartUpload({ Bucket, Key }) - For each part 1..totalParts, call
getSignedUrl(s3, new UploadPartCommand({ Bucket, Key, UploadId, PartNumber: i }), { expiresIn: 3600 }) - Return all presigned URLs in order
- Store
{ uploadId, key, bucket, userId }in memory for completion validation
POST /api/desktop/multipart/complete
Auth: x-auth-token required.
Request:
{
"uploadId": "abc123...",
"key": "Jobs/2026-04-06/A001C001.mxf",
"bucket": "vpm-media",
"parts": [
{ "PartNumber": 1, "ETag": "\"abc\"" },
{ "PartNumber": 2, "ETag": "\"def\"" }
]
}
Response:
{ "success": true }
Implementation notes:
- Call
s3.completeMultipartUpload({ Bucket, Key, UploadId, MultipartUpload: { Parts: parts } }) - Update user uploadedBytes stat
POST /api/desktop/multipart/abort
Auth: x-auth-token required.
Request:
{
"uploadId": "abc123...",
"key": "Jobs/2026-04-06/A001C001.mxf",
"bucket": "vpm-media"
}
Response:
{ "success": true }
Sample Express implementation
// Add to server.js after existing /api/udp routes
const { UploadPartCommand, CreateMultipartUploadCommand,
CompleteMultipartUploadCommand, AbortMultipartUploadCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const desktopSessions = new Map(); // uploadId → { key, bucket, userId }
app.post('/api/desktop/multipart/init', requireAuth, async (req, res) => {
const { filename, size, prefix, totalParts } = req.body;
if (!filename || !size || !totalParts) return res.status(400).json({ error: 'Missing fields' });
const cfg = getConfig();
if (!cfg.bucket) return res.status(503).json({ error: 'S3 not configured' });
const key = prefix ? `${prefix.replace(/\/+$/, '')}/${filename}` : filename;
try {
const create = await s3Client.send(new CreateMultipartUploadCommand({ Bucket: cfg.bucket, Key: key }));
const uploadId = create.UploadId;
const presignedParts = await Promise.all(
Array.from({ length: totalParts }, (_, i) =>
getSignedUrl(s3Client, new UploadPartCommand({
Bucket: cfg.bucket, Key: key, UploadId: uploadId, PartNumber: i + 1,
}), { expiresIn: 3600 })
)
);
desktopSessions.set(uploadId, { key, bucket: cfg.bucket, userId: req.user.username });
res.json({ uploadId, key, bucket: cfg.bucket, presignedParts });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/desktop/multipart/complete', requireAuth, async (req, res) => {
const { uploadId, key, bucket, parts } = req.body;
if (!uploadId || !parts) return res.status(400).json({ error: 'Missing fields' });
try {
await s3Client.send(new CompleteMultipartUploadCommand({
Bucket: bucket, Key: key, UploadId: uploadId,
MultipartUpload: { Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber) },
}));
desktopSessions.delete(uploadId);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/desktop/multipart/abort', requireAuth, async (req, res) => {
const { uploadId, key, bucket } = req.body;
try {
await s3Client.send(new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId }));
desktopSessions.delete(uploadId);
res.json({ success: true });
} catch (err) {
res.json({ success: true }); // best-effort
}
});