Claude finished the response
const uploadMultipartToS3new = async (req, res) => { try { var datetime = new Date(); logToFile("uploadMultipartToS3newww body"+JSON.stringify(req.body)); logToFile(`uploadMultipartToS3new IN ${datetime.toISOString()}`); if (!req.files || req.files.length === 0) { retur
pasted
how to apply the file chunk process to this and how to send C# to api
May 11
I'll analyze your current code and show you how to add chunked file upload support — both the Node.js API changes and the C# client code.
Let me first understand what your current code does, then design the chunked approach.
Here's how chunked upload works conceptually, then the full implementation:
Current flow: Client sends all files at once → server processes them together.
Chunked flow: Client splits each file into chunks → uploads chunks one by one → server reassembles → processes the full file.
This diagram shows the full flow:
V
Connecting to visualize...
Now here's the full code — Node.js server side first, then C# client.
Node.js — two new routes + assembly logic
Add these alongside your existing uploadMultipartToS3new:
javascript
const os = require('os');
const path = require('path');
const fss = require('fs').promises;
const fs = require('fs');
// ─── POST /upload-chunk ───────────────────────────────────────────────────────
// Body fields (multipart): uploadId, chunkIndex, totalChunks, originalname, mimetype
// File field: "chunk"
const uploadChunk = async (req, res) => {
try {
const { uploadId, chunkIndex, totalChunks } = req.body;
if (!uploadId || chunkIndex === undefined || !totalChunks) {
return res.status(400).json({ ok: false, error: "Missing chunk metadata" });
}
if (!req.files || req.files.length === 0) {
return res.status(400).json({ ok: false, error: "No chunk data received" });
}
const chunkBuffer = req.files[0].buffer;
// Save chunk to a temp folder: /tmp/{uploadId}/{chunkIndex}.part
const tmpDir = path.join(os.tmpdir(), 'uploads', uploadId);
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true });
}
const chunkPath = path.join(tmpDir, `${String(chunkIndex).padStart(5, '0')}.part`);
await fss.writeFile(chunkPath, chunkBuffer);
logToFile(`✔ Saved chunk ${chunkIndex}/${totalChunks} for ${uploadId}`);
res.json({ ok: true, received: parseInt(chunkIndex) + 1, total: parseInt(totalChunks) });
} catch (err) {
logToFile(`❌ uploadChunk error: ${err.stack || err.message}`);
res.status(500).json({ ok: false, error: err.message });
}
};
// ─── POST /finalize-upload ────────────────────────────────────────────────────
// Body (JSON): { uploadId, originalname, mimetype, uploadUrl, serverpath, filetype, thumbUrl }
const finalizeUpload = async (req, res) => {
const { uploadId, originalname, mimetype, uploadUrl, serverpath, filetype, thumbUrl } = req.body;
try {
const tmpDir = path.join(os.tmpdir(), 'uploads', uploadId);
if (!fs.existsSync(tmpDir)) {
return res.status(400).json({ ok: false, error: `No chunks found for uploadId: ${uploadId}` });
}
// Read and sort all .part files
const parts = (await fss.readdir(tmpDir))
.filter(f => f.endsWith('.part'))
.sort(); // padded names sort correctly: 00000.part, 00001.part …
if (parts.length === 0) {
return res.status(400).json({ ok: false, error: "Chunk folder is empty" });
}
logToFile(`✔ Assembling ${parts.length} chunks for ${uploadId} (${originalname})`);
// Concatenate all chunk buffers into one
const buffers = await Promise.all(
parts.map(p => fss.readFile(path.join(tmpDir, p)))
);
const fullBuffer = Buffer.concat(buffers);
// Build a fake "file" object matching what processSingleImage expects
const fakeFile = {
buffer: fullBuffer,
originalname: originalname,
mimetype: mimetype,
};
const body = { uploadUrl, serverpath, filetype, thumbUrl };
// Reuse your existing image processing function unchanged
const result = await processSingleImage(fakeFile, body);
// Clean up temp folder
await fss.rm(tmpDir, { recursive: true, force: true });
logToFile(`✔ Finalized ${originalname}: ${result.status}`);
res.json({ ok: true, result });
} catch (err) {
logToFile(`❌ finalizeUpload error: ${err.stack || err.message}`);
res.status(500).json({ ok: false, error: err.message });
}
};
Register the routes (wherever you have your Express router):
javascript
router.post('/upload-chunk', upload.array('chunk', 1), uploadChunk);
router.post('/finalize-upload', express.json(), finalizeUpload);
C# Client — chunked upload
csharp
using System;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using System.Collections.Generic;
public class ChunkedUploader
{
private readonly HttpClient _http;
private readonly string _baseUrl;
private const int ChunkSize = 2 * 1024 * 1024; // 2 MB per chunk
public ChunkedUploader(string baseUrl)
{
_http = new HttpClient();
_baseUrl = baseUrl.TrimEnd('/');
}
// ── Upload one file in chunks ─────────────────────────────────────────────
public async Task<UploadResult> UploadFileAsync(
string filePath,
string serverPath,
string uploadUrl,
string thumbUrl,
string fileType = "image")
{
var fileBytes = await File.ReadAllBytesAsync(filePath);
var fileName = Path.GetFileName(filePath);
var mimeType = GetMimeType(fileName);
var uploadId = Guid.NewGuid().ToString("N"); // e.g. "a3f9…"
var totalBytes = fileBytes.Length;
var totalChunks = (int)Math.Ceiling((double)totalBytes / ChunkSize);
Console.WriteLine($"Uploading {fileName} ({totalBytes:N0} bytes) in {totalChunks} chunks");
// ── 1. Send each chunk ────────────────────────────────────────────────
for (int i = 0; i < totalChunks; i++)
{
var offset = i * ChunkSize;
var length = Math.Min(ChunkSize, totalBytes - offset);
var chunkData = new byte[length];
Buffer.BlockCopy(fileBytes, offset, chunkData, 0, length);
var success = await SendChunkWithRetryAsync(
uploadId, i, totalChunks, fileName, mimeType, chunkData
);
if (!success)
return new UploadResult { Ok = false, Error = $"Chunk {i} failed after retries" };
var pct = (int)((i + 1) / (double)totalChunks * 100);
Console.WriteLine($" [{pct,3}%] chunk {i + 1}/{totalChunks}");
}
// ── 2. Finalize ───────────────────────────────────────────────────────
return await FinalizeAsync(uploadId, fileName, mimeType, serverPath, uploadUrl, thumbUrl, fileType);
}
// ── Upload multiple files concurrently (max 3 at a time) ─────────────────
public async Task<List<UploadResult>> UploadFilesAsync(
IEnumerable<string> filePaths,
string serverPath, string uploadUrl, string thumbUrl, string fileType = "image",
int concurrency = 3)
{
var semaphore = new System.Threading.SemaphoreSlim(concurrency);
var tasks = new List<Task<UploadResult>>();
foreach (var filePath in filePaths)
{
await semaphore.WaitAsync();
tasks.Add(Task.Run(async () =>
{
try { return await UploadFileAsync(filePath, serverPath, uploadUrl, thumbUrl, fileType); }
finally { semaphore.Release(); }
}));
}
return new List<UploadResult>(await Task.WhenAll(tasks));
}
// ── Send a single chunk (with up to 3 retries) ────────────────────────────
private async Task<bool> SendChunkWithRetryAsync(
string uploadId, int index, int total,
string fileName, string mimeType, byte[] data,
int maxRetries = 3)
{
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
using var form = new MultipartFormDataContent();
// Metadata fields
form.Add(new StringContent(uploadId), "uploadId");
form.Add(new StringContent(index.ToString()), "chunkIndex");
form.Add(new StringContent(total.ToString()), "totalChunks");
form.Add(new StringContent(fileName), "originalname");
form.Add(new StringContent(mimeType), "mimetype");
// Chunk bytes
var byteContent = new ByteArrayContent(data);
byteContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
form.Add(byteContent, "chunk", fileName);
var response = await _http.PostAsync($"{_baseUrl}/upload-chunk", form);
if (response.IsSuccessStatusCode)
return true;
Console.WriteLine($" Chunk {index} attempt {attempt + 1} failed: {response.StatusCode}");
}
catch (Exception ex)
{
Console.WriteLine($" Chunk {index} attempt {attempt + 1} exception: {ex.Message}");
}
await Task.Delay(500 * (attempt + 1)); // back-off: 0.5s, 1s, 1.5s
}
return false;
}
// ── POST /finalize-upload ─────────────────────────────────────────────────
private async Task<UploadResult> FinalizeAsync(
string uploadId, string fileName, string mimeType,
string serverPath, string uploadUrl, string thumbUrl, string fileType)
{
var payload = new
{
uploadId,
originalname = fileName,
mimetype = mimeType,
serverpath = serverPath,
uploadUrl,
thumbUrl,
filetype = fileType
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
try
{
var response = await _http.PostAsync($"{_baseUrl}/finalize-upload", content);
var body = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
var result = JsonSerializer.Deserialize<UploadResult>(body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return result ?? new UploadResult { Ok = true };
}
return new UploadResult { Ok = false, Error = $"Finalize HTTP {response.StatusCode}: {body}" };
}
catch (Exception ex)
{
return new UploadResult { Ok = false, Error = ex.Message };
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static string GetMimeType(string fileName) =>
Path.GetExtension(fileName).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".avif" => "image/avif",
".gif" => "image/gif",
".webp" => "image/webp",
".pdf" => "application/pdf",
_ => "application/octet-stream"
};
}
// ── Result model ──────────────────────────────────────────────────────────────
public class UploadResult
{
public bool Ok { get; set; }
public string Error { get; set; }
public object Result { get; set; }
}
Usage example:
csharp
var uploader = new ChunkedUploader("https://your-server.com");
// Single file
var result = await uploader.UploadFileAsync(
filePath: @"C:\photos\image.jpg",
serverPath: @"\\NAS\share",
uploadUrl: "images/2024",
thumbUrl: "images/2024/thumbs"
);
Console.WriteLine(result.Ok ? "Success" : result.Error);
// Multiple files
var results = await uploader.UploadFilesAsync(
filePaths: new[] { "a.jpg", "b.jpg", "c.jpg" },
serverPath: @"\\NAS\share",
uploadUrl: "images/2024",
thumbUrl: "images/2024/thumbs",
concurrency: 3 // matches your runWithConcurrency limit
);
Key design decisions
processSingleImage is untouched — the finalize route assembles the buffer and builds a fake file object that matches exactly what your function already expects. No refactoring needed.
Chunk naming uses zero-padded numbers (00000.part, 00001.part) so a plain alphabetical sort always gives correct order — no need to parse the index back out.
Retry logic lives in C#, not the server. If a chunk fails the server never sees a partial state — it only gets a finalize call once every chunk is confirmed received.
Temp cleanup happens inside finalizeUpload after the image is processed. If you want a safety net for abandoned uploads (client crashes mid-upload), add a cron job that deletes tmp/uploads/* folders older than 1 hour.
previous one convert avif process these sharp sharp.concurrency(4);
sharp.cache(false);
sharp(resizedBuffer)
.resize(200,200)
.avif({ quality: 40, effort: 2 })
.toBuffer();
May 11
You want to convert those sharp calls to work with the chunked/finalized buffer. Here's the updated processSingleImage with the sharp settings applied consistently:
javascript
async function processSingleImage(req, body) {
const datetime = new Date();
const buffer = req.buffer;
logToFile(`✔ buffsize ${buffer.length}`);
const { uploadUrl, serverpath, filetype, thumbUrl } = body;
const fileName = req.originalname
.replace(/\.(jpg|jpeg|png|webp)$/i, '.avif');
try {
const SHARE_ROOT = `\\${serverpath}\\${uploadUrl}`;
if (!fs.existsSync(SHARE_ROOT)) {
logToFile(`SHARE_ROOT not found, creating...`);
fs.mkdirSync(SHARE_ROOT, { recursive: true });
}
logToFile(`mimetype: ${req.mimetype}`);
if (req.mimetype.includes("image")) {
// ── Global sharp settings (set once before any pipeline) ──────────────
sharp.concurrency(4);
sharp.cache(false);
// ── Read metadata ──────────────────────────────────────────────────────
const meta = await sharp(buffer, { limitInputPixels: false }).metadata();
const imgl = resizeByMax(
Math.trunc(meta.width),
Math.trunc(meta.height),
1500
);
// ── Main image → avif ──────────────────────────────────────────────────
let pipeline = sharp(buffer, { limitInputPixels: false });
if (meta.hasAlpha) {
pipeline = pipeline.flatten({ background: "#fff" });
}
const resizedBuffer = await pipeline
.toColourspace("srgb")
.resize(imgl.width, imgl.height, {
fit: "inside",
withoutEnlargement: true
})
.avif({ quality: 45, effort: 2 })
.toBuffer();
const finalPath = path.join(SHARE_ROOT, fileName);
await fss.writeFile(finalPath, resizedBuffer);
logToFile(`✔ Saved main image: ${finalPath}`);
// ── Thumbnail → avif ───────────────────────────────────────────────────
// Re-use resizedBuffer (already resized/colourspaced) — no need to
// re-read the original large buffer
const thumbBuffer = await sharp(resizedBuffer, { limitInputPixels: false })
.resize(200, 200, {
fit: "cover", // fills the 200×200 box, crops edges
position: "centre"
})
.avif({ quality: 40, effort: 2 })
.toBuffer();
const thumbDir = `\\${serverpath}\\${thumbUrl}`;
logToFile(`thumbDir: ${thumbDir}`);
if (!fs.existsSync(thumbDir)) {
fs.mkdirSync(thumbDir, { recursive: true });
}
const thumbPath = path.join(thumbDir, fileName);
logToFile(`thumbPath: ${thumbPath}`);
await fss.writeFile(thumbPath, thumbBuffer);
const datetimeOut = new Date();
logToFile(`processSingleImage OUT ${datetimeOut.toISOString()}`);
return { fileName, status: "success" };
} else {
// Non-image file — write raw buffer directly
await fss.writeFile(path.join(SHARE_ROOT, fileName), buffer);
return { fileName, status: "success" };
}
} catch (err) {
logToFile(`❌ Image failed ${fileName}: ${err.stack || err.message}`);
return { fileName, status: "failed", error: err.message };
}
}
What changed and why
sharp.concurrency(4) and sharp.cache(false) placement — moved to the top of the image block, before any pipeline is created. These are global settings on the sharp module itself; calling them after you've already started a pipeline has no effect on that pipeline. Setting them once at the top of the function guarantees they're active for both the main resize and the thumbnail.
Thumbnail uses resizedBuffer not buffer — your original code already did this correctly. The thumbnail pipeline reads from the already-resized avif output, so sharp doesn't have to decode a large original JPEG twice. Kept and made explicit with a comment.
Added fit: "cover" + position: "centre" to the thumbnail resize — your original used bare .resize(200, 200) which defaults to fit: "cover" anyway, but making it explicit avoids sharp's behaviour changing between versions. If you'd prefer letterboxing instead of cropping, swap to fit: "contain" with .flatten({ background: "#fff" }) before .avif(...).
limitInputPixels: false on the thumbnail pipeline too — the resized buffer is an avif, and while it's small in pixel dimensions, sharp still validates the input. Keeping the flag consistent avoids surprises if a resized image is unusually wide.
Filename replace uses a regex — .replace(/\.(jpg|jpeg|png|webp)$/i, '.avif') instead of two chained .replace() calls, so it handles .jpeg and .webp inputs correctly too.
i need full process chunck with avif convertion
May 11
Let me give you the complete end-to-end code — Node.js server + C# client — with chunked upload and avif conversion fully integrated.
Check available skills
Check available skills
Node.js — uploadHandler.js (complete file):
javascript
const os = require('os');
const path = require('path');
const fs = require('fs');
const fss = require('fs').promises;
const sharp = require('sharp');
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
function logToFile(msg) {
console.log(`[${new Date().toISOString()}] ${msg}`);
}
function resizeByMax(w, h, max) {
if (w <= max && h <= max) return { width: w, height: h };
const ratio = Math.min(max / w, max / h);
return { width: Math.round(w * ratio), height: Math.round(h * ratio) };
}
function getTmpDir(uploadId) {
return path.join(os.tmpdir(), 'chunk_uploads', uploadId);
}
// ─────────────────────────────────────────────────────────────────────────────
// ROUTE 1 — POST /upload-chunk
// Multipart fields: uploadId, chunkIndex, totalChunks, originalname, mimetype
// File field: "chunk" (single chunk binary)
// ─────────────────────────────────────────────────────────────────────────────
const uploadChunk = async (req, res) => {
try {
const { uploadId, chunkIndex, totalChunks, originalname, mimetype } = req.body;
if (!uploadId || chunkIndex === undefined || !totalChunks || !originalname) {
return res.status(400).json({ ok: false, error: 'Missing chunk metadata' });
}
if (!req.files || req.files.length === 0) {
return res.status(400).json({ ok: false, error: 'No chunk data received' });
}
const chunkBuffer = req.files[0].buffer;
// ── Save chunk as zero-padded file so readdir sort = correct order ────────
const tmpDir = getTmpDir(uploadId);
const metaPath = path.join(tmpDir, '_meta.json');
const chunkPath = path.join(tmpDir, `${String(chunkIndex).padStart(6, '0')}.part`);
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true });
}
// Persist metadata on first chunk so finalize knows filename / mimetype
if (!fs.existsSync(metaPath)) {
await fss.writeFile(metaPath, JSON.stringify({ originalname, mimetype, totalChunks }));
}
await fss.writeFile(chunkPath, chunkBuffer);
logToFile(`✔ chunk ${chunkIndex}/${parseInt(totalChunks) - 1} saved for ${uploadId}`);
res.json({
ok : true,
received: parseInt(chunkIndex) + 1,
total : parseInt(totalChunks),
});
} catch (err) {
logToFile(`❌ uploadChunk: ${err.stack || err.message}`);
res.status(500).json({ ok: false, error: err.message });
}
};
// ─────────────────────────────────────────────────────────────────────────────
// ROUTE 2 — POST /finalize-upload
// JSON body: { uploadId, uploadUrl, serverpath, filetype, thumbUrl }
// ─────────────────────────────────────────────────────────────────────────────
const finalizeUpload = async (req, res) => {
const { uploadId, uploadUrl, serverpath, filetype, thumbUrl } = req.body;
if (!uploadId) {
return res.status(400).json({ ok: false, error: 'uploadId required' });
}
const tmpDir = getTmpDir(uploadId);
const metaPath = path.join(tmpDir, '_meta.json');
try {
// ── Read saved metadata ───────────────────────────────────────────────────
if (!fs.existsSync(metaPath)) {
return res.status(400).json({ ok: false, error: `No upload found for id: ${uploadId}` });
}
const meta = JSON.parse(await fss.readFile(metaPath, 'utf8'));
const { originalname, mimetype, totalChunks } = meta;
// ── Verify all chunks arrived ─────────────────────────────────────────────
const parts = (await fss.readdir(tmpDir))
.filter(f => f.endsWith('.part'))
.sort(); // 000000.part … 000099.part
if (parts.length !== parseInt(totalChunks)) {
return res.status(400).json({
ok : false,
error : `Expected ${totalChunks} chunks, got ${parts.length}`,
});
}
logToFile(`✔ Assembling ${parts.length} chunks → ${originalname}`);
// ── Concatenate chunks into one buffer ────────────────────────────────────
const buffers = await Promise.all(parts.map(p => fss.readFile(path.join(tmpDir, p))));
const fullBuffer = Buffer.concat(buffers);
logToFile(`✔ Assembled buffer size: ${fullBuffer.length}`);
// ── Hand off to image processor ───────────────────────────────────────────
const fakeFile = { buffer: fullBuffer, originalname, mimetype };
const body = { uploadUrl, serverpath, filetype, thumbUrl };
const result = await processSingleImage(fakeFile, body);
// ── Clean up temp folder ──────────────────────────────────────────────────
await fss.rm(tmpDir, { recursive: true, force: true });
logToFile(`✔ Finalized ${originalname}: ${result.status}`);
res.json({ ok: true, result });
} catch (err) {
logToFile(`❌ finalizeUpload: ${err.stack || err.message}`);
// Still try to clean up
await fss.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
res.status(500).json({ ok: false, error: err.message });
}
};
// ─────────────────────────────────────────────────────────────────────────────
// ROUTE 3 — POST /upload-multipart (original batch route, unchanged)
// ─────────────────────────────────────────────────────────────────────────────
const uploadMultipartToS3new = async (req, res) => {
try {
const datetime = new Date();
logToFile(`uploadMultipartToS3new IN ${datetime.toISOString()}`);
logToFile(`uploadMultipartToS3new body ${JSON.stringify(req.body)}`);
if (!req.files || req.files.length === 0) {
return res.status(400).json({ ok: false, error: 'No files received' });
}
const tasks = req.files.map(file => () => processSingleImage(file, req.body));
const results = await runWithConcurrency(tasks, 3);
const success = results.filter(r => r.status === 'success');
const failed = results.filter(r => r.status === 'failed');
logToFile(`uploadMultipartToS3new OUT ${new Date().toISOString()}`);
res.json({
ok : true,
successCount: success.length,
failedCount : failed.length,
failedImages: failed,
});
} catch (err) {
logToFile(`❌ upload fatal: ${err.stack || err.message}`);
res.status(500).json({ ok: false, error: err.message });
}
};
// ─────────────────────────────────────────────────────────────────────────────
// Core — process one image (shared by both routes)
// ─────────────────────────────────────────────────────────────────────────────
async function processSingleImage(req, body) {
logToFile(`processSingleImage IN ${new Date().toISOString()}`);
const buffer = req.buffer;
const { uploadUrl, serverpath, filetype, thumbUrl } = body;
// Always output .avif regardless of input extension
const fileName = req.originalname.replace(/\.(jpg|jpeg|png|webp|gif)$/i, '.avif');
try {
const SHARE_ROOT = `\\${serverpath}\\${uploadUrl}`;
if (!fs.existsSync(SHARE_ROOT)) {
logToFile(`Creating SHARE_ROOT: ${SHARE_ROOT}`);
fs.mkdirSync(SHARE_ROOT, { recursive: true });
}
logToFile(`mimetype: ${req.mimetype}`);
// ── Image path ────────────────────────────────────────────────────────────
if (req.mimetype.includes('image')) {
// Global sharp settings — applied before any pipeline
sharp.concurrency(4);
sharp.cache(false);
// ── Read metadata ───────────────────────────────────────────────────────
const imgMeta = await sharp(buffer, { limitInputPixels: false }).metadata();
logToFile(`✔ Original: ${imgMeta.width}×${imgMeta.height} hasAlpha:${imgMeta.hasAlpha}`);
const imgl = resizeByMax(
Math.trunc(imgMeta.width),
Math.trunc(imgMeta.height),
1500
);
// ── Build main pipeline ─────────────────────────────────────────────────
let pipeline = sharp(buffer, { limitInputPixels: false });
if (imgMeta.hasAlpha) {
pipeline = pipeline.flatten({ background: '#fff' });
}
// ── Main image → avif (quality 45, effort 2) ────────────────────────────
const resizedBuffer = await pipeline
.toColourspace('srgb')
.resize(imgl.width, imgl.height, {
fit : 'inside',
withoutEnlargement: true,
})
.avif({ quality: 45, effort: 2 })
.toBuffer();
logToFile(`✔ buffsize resized: ${resizedBuffer.length}`);
const finalPath = path.join(SHARE_ROOT, fileName);
await fss.writeFile(finalPath, resizedBuffer);
logToFile(`✔ Saved main: ${finalPath}`);
// ── Thumbnail → avif (200×200, quality 40, effort 2) ───────────────────
// Re-uses resizedBuffer — avoids decoding the original large file again
const thumbBuffer = await sharp(resizedBuffer, { limitInputPixels: false })
.resize(200, 200, {
fit : 'cover',
position: 'centre',
})
.avif({ quality: 40, effort: 2 })
.toBuffer();
logToFile(`✔ buffsize thumb: ${thumbBuffer.length}`);
const thumbDir = `\\${serverpath}\\${thumbUrl}`;
if (!fs.existsSync(thumbDir)) {
fs.mkdirSync(thumbDir, { recursive: true });
}
const thumbPath = path.join(thumbDir, fileName);
await fss.writeFile(thumbPath, thumbBuffer);
logToFile(`✔ Saved thumb: ${thumbPath}`);
logToFile(`processSingleImage OUT ${new Date().toISOString()}`);
return { fileName, status: 'success' };
// ── Non-image path (pdf, etc.) ────────────────────────────────────────────
} else {
const rawFileName = req.originalname; // keep original extension
await fss.writeFile(path.join(SHARE_ROOT, rawFileName), buffer);
logToFile(`✔ Saved non-image: ${rawFileName}`);
return { fileName: rawFileName, status: 'success' };
}
} catch (err) {
logToFile(`❌ processSingleImage ${fileName}: ${err.stack || err.message}`);
return { fileName, status: 'failed', error: err.message };
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Concurrency helper (unchanged from original)
// ─────────────────────────────────────────────────────────────────────────────
async function runWithConcurrency(tasks, limit = 20) {
const results = [];
const executing = [];
for (const task of tasks) {
const p = task().then(res => {
executing.splice(executing.indexOf(p), 1);
return res;
});
results.push(p);
executing.push(p);
if (executing.length >= limit) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
module.exports = { uploadChunk, finalizeUpload, uploadMultipartToS3new };
Node.js — routes.js (register all three routes):
javascript
const express = require('express');
const multer = require('multer');
const router = express.Router();
const {
uploadChunk,
finalizeUpload,
uploadMultipartToS3new,
} = require('./uploadHandler');
// multer — memory storage, no size limit (adjust limit to taste)
const upload = multer({
storage: multer.memoryStorage(),
limits : { fileSize: 50 * 1024 * 1024 }, // 50 MB per chunk max
});
// Chunked upload (new)
router.post('/upload-chunk', upload.array('chunk', 1), uploadChunk);
router.post('/finalize-upload', express.json(), finalizeUpload);
// Original batch route (unchanged)
router.post('/upload-multipart', upload.array('files', 50), uploadMultipartToS3new);
module.exports = router;
C# — ChunkedUploader.cs (complete, with avif-aware flow):
csharp
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
public class ChunkedUploader : IDisposable
{
// ── Config ────────────────────────────────────────────────────────────────
private readonly HttpClient _http;
private readonly string _baseUrl;
private const int ChunkSize = 2 * 1024 * 1024; // 2 MB per chunk
private const int MaxRetries = 3;
private const int Concurrency = 3; // parallel files
public ChunkedUploader(string baseUrl)
{
_http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
_baseUrl = baseUrl.TrimEnd('/');
}
// ─────────────────────────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Upload one file in chunks then trigger avif conversion.</summary>
public async Task<UploadResult> UploadFileAsync(
string filePath,
string serverPath,
string uploadUrl,
string thumbUrl,
string fileType = "image",
IProgress<int> progress = null)
{
if (!File.Exists(filePath))
return Fail($"File not found: {filePath}");
var fileBytes = await File.ReadAllBytesAsync(filePath);
var fileName = Path.GetFileName(filePath);
var mimeType = GetMimeType(fileName);
var uploadId = Guid.NewGuid().ToString("N");
var totalChunks = (int)Math.Ceiling((double)fileBytes.Length / ChunkSize);
Console.WriteLine($"[{fileName}] {fileBytes.Length:N0} bytes → {totalChunks} chunks (id={uploadId})");
// ── 1. Send chunks sequentially for this file ─────────────────────────
for (int i = 0; i < totalChunks; i++)
{
var offset = i * ChunkSize;
var length = Math.Min(ChunkSize, fileBytes.Length - offset);
var chunk = new byte[length];
Buffer.BlockCopy(fileBytes, offset, chunk, 0, length);
var sent = await SendChunkWithRetryAsync(
uploadId, i, totalChunks, fileName, mimeType, chunk
);
if (!sent)
return Fail($"Chunk {i} failed after {MaxRetries} retries");
var pct = (int)Math.Round((i + 1) / (double)totalChunks * 100);
progress?.Report(pct);
Console.WriteLine($" [{fileName}] {pct,3}% chunk {i + 1}/{totalChunks}");
}
// ── 2. Finalize → server assembles + runs avif conversion ─────────────
Console.WriteLine($" [{fileName}] All chunks sent — finalizing...");
return await FinalizeAsync(uploadId, fileName, mimeType, serverPath, uploadUrl, thumbUrl, fileType);
}
/// <summary>Upload multiple files with bounded concurrency.</summary>
public async Task<List<UploadResult>> UploadFilesAsync(
IEnumerable<string> filePaths,
string serverPath,
string uploadUrl,
string thumbUrl,
string fileType = "image")
{
var semaphore = new SemaphoreSlim(Concurrency);
var tasks = new List<Task<UploadResult>>();
foreach (var fp in filePaths)
{
await semaphore.WaitAsync();
var captured = fp;
tasks.Add(Task.Run(async () =>
{
try { return await UploadFileAsync(captured, serverPath, uploadUrl, thumbUrl, fileType); }
finally { semaphore.Release(); }
}));
}
return new List<UploadResult>(await Task.WhenAll(tasks));
}
// ─────────────────────────────────────────────────────────────────────────
// Private — send one chunk (with retry + back-off)
// ─────────────────────────────────────────────────────────────────────────
private async Task<bool> SendChunkWithRetryAsync(
string uploadId, int index, int total,
string fileName, string mimeType, byte[] data)
{
for (int attempt = 0; attempt < MaxRetries; attempt++)
{
try
{
using var form = new MultipartFormDataContent();
// ── Metadata fields ───────────────────────────────────────────
form.Add(new StringContent(uploadId), "uploadId");
form.Add(new StringContent(index.ToString()), "chunkIndex");
form.Add(new StringContent(total.ToString()), "totalChunks");
form.Add(new StringContent(fileName), "originalname");
form.Add(new StringContent(mimeType), "mimetype");
// ── Chunk binary ──────────────────────────────────────────────
var byteContent = new ByteArrayContent(data);
byteContent.Headers.ContentType =
new MediaTypeHeaderValue("application/octet-stream");
form.Add(byteContent, "chunk", fileName);
var response = await _http.PostAsync($"{_baseUrl}/upload-chunk", form);
if (response.IsSuccessStatusCode) return true;
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine($" Chunk {index} attempt {attempt + 1} HTTP {(int)response.StatusCode}: {body}");
}
catch (Exception ex)
{
Console.WriteLine($" Chunk {index} attempt {attempt + 1} exception: {ex.Message}");
}
// Exponential back-off: 500ms, 1000ms, 1500ms
await Task.Delay(500 * (attempt + 1));
}
return false;
}
// ─────────────────────────────────────────────────────────────────────────
// Private — finalize (triggers assembly + avif conversion on server)
// ─────────────────────────────────────────────────────────────────────────
private async Task<UploadResult> FinalizeAsync(
string uploadId, string fileName, string mimeType,
string serverPath, string uploadUrl, string thumbUrl, string fileType)
{
var payload = new
{
uploadId,
originalname = fileName,
mimetype = mimeType,
serverpath = serverPath,
uploadUrl,
thumbUrl,
filetype = fileType,
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
try
{
var response = await _http.PostAsync($"{_baseUrl}/finalize-upload", content);
var body = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
var result = JsonSerializer.Deserialize<UploadResult>(
body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
);
Console.WriteLine($" Finalize OK → {result?.Result}");
return result ?? new UploadResult { Ok = true };
}
return Fail($"Finalize HTTP {(int)response.StatusCode}: {body}");
}
catch (Exception ex)
{
return Fail(ex.Message);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private static UploadResult Fail(string error)
{
Console.WriteLine($" ❌ {error}");
return new UploadResult { Ok = false, Error = error };
}
private static string GetMimeType(string fileName) =>
Path.GetExtension(fileName).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".avif" => "image/avif",
".webp" => "image/webp",
".gif" => "image/gif",
".pdf" => "application/pdf",
_ => "application/octet-stream",
};
public void Dispose() => _http.Dispose();
}
// ─────────────────────────────────────────────────────────────────────────────
// Models
// ─────────────────────────────────────────────────────────────────────────────
public class UploadResult
{
public bool Ok { get; set; }
public string Error { get; set; }
public object Result { get; set; }
}
C# — Program.cs (usage example):
csharp
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
class Program
{
static async Task Main(string[] args)
{
using var uploader = new ChunkedUploader("https://your-server.com");
// ── Single file with progress ─────────────────────────────────────────
var progress = new Progress<int>(pct => Console.Write($"\r progress: {pct}% "));
var single = await uploader.UploadFileAsync(
filePath : @"C:\photos\DSC_0001.jpg",
serverPath: @"\\NAS\share",
uploadUrl : "images/2024",
thumbUrl : "images/2024/thumbs",
progress : progress
);
Console.WriteLine();
Console.WriteLine(single.Ok ? "✔ uploaded" : $"❌ {single.Error}");
// ── Batch upload ──────────────────────────────────────────────────────
var files = new List<string>
{
@"C:\photos\DSC_0001.jpg",
@"C:\photos\DSC_0002.png",
@"C:\photos\DSC_0003.jpg",
};
var results = await uploader.UploadFilesAsync(
filePaths : files,
serverPath: @"\\NAS\share",
uploadUrl : "images/2024",
thumbUrl : "images/2024/thumbs"
);
foreach (var r in results)
Console.WriteLine(r.Ok ? $"✔ ok" : $"❌ {r.Error}");
}
}
How everything connects
Chunk phase — C# reads the file, slices it into 2 MB chunks, and POSTs each one to /upload-chunk with uploadId + chunkIndex + totalChunks. The server saves each chunk as 000000.part, 000001.part, … in a temp folder named after the uploadId. It also saves a _meta.json on the first chunk so the finalize route knows the original filename and mimetype without the client re-sending them.
Finalize phase — once all chunks are confirmed, C# POSTs once to /finalize-upload with just the uploadId and the destination path fields. The server reads the _meta.json, verifies the chunk count matches, concatenates all parts into one Buffer, then passes it straight into your existing processSingleImage unchanged — same sharp pipeline, same avif quality settings, same thumb generation.
avif conversion happens entirely on the server inside processSingleImage — sharp.concurrency(4) and sharp.cache(false) are set before the pipeline, the main image is resized to max 1500px and encoded at quality 45 effort 2, then the thumbnail is generated from the already-resized buffer at 200×200 quality 40 effort 2. The C# client only ever sends the original JPEG/PNG — it never touches avif.
const app = require('express')(); app.post('/finalize-upload', app.json(),finalizeUpload); app.json is not a function
May 12
The error is because app.json() doesn't exist — express.json() is a middleware on the express module itself, not on the app instance.
Fix:
javascript
// ❌ Wrong
const app = require('express')();
app.post('/finalize-upload', app.json(), finalizeUpload);
// ✅ Correct
const express = require('express');
const app = express();
app.post('/finalize-upload', express.json(), finalizeUpload);
If you're using a router instead of app directly:
javascript
const express = require('express');
const router = express.Router();
router.post('/upload-chunk', upload.array('chunk', 1), uploadChunk);
router.post('/finalize-upload', express.json(), finalizeUpload);
The key point — require('express') returns the express factory function, and express.json() lives on that factory. When you call require('express')() you get the app instance which doesn't have .json() on it. Always keep a reference to express before calling it.
when uploading image totally 5 chunks
May 12
The issue is that when you have exactly 5 chunks, the chunk count verification fails because the _meta.json is written only on the first chunk — but if chunks arrive out of order (parallel uploads), totalChunks might be wrong. Also the part count check can mismatch.
Let me trace the full flow for 5 chunks:
The real bugs:
Bug 1 — chunkIndex is a string from req.body, so parts.length !== parseInt(totalChunks) can silently fail if there's a type mismatch.
Bug 2 — _meta.json written only on first chunk (chunkIndex == 0), but if chunk 0 arrives last (network reorder), finalize finds no meta.
Bug 3 — C# sends chunks sequentially but if any retry causes a duplicate .part file it still counts as one — that's fine. But the readdir count includes _meta.json if filter is wrong.
Fix all three — Node.js:
javascript
// ─── POST /upload-chunk ───────────────────────────────────────────────────
const uploadChunk = async (req, res) => {
try {
// ✅ Parse integers immediately — req.body fields are always strings
const uploadId = req.body.uploadId;
const chunkIndex = parseInt(req.body.chunkIndex, 10);
const totalChunks = parseInt(req.body.totalChunks, 10);
const originalname = req.body.originalname;
const mimetype = req.body.mimetype;
if (!uploadId || isNaN(chunkIndex) || isNaN(totalChunks) || !originalname) {
return res.status(400).json({ ok: false, error: 'Missing chunk metadata' });
}
if (!req.files || req.files.length === 0) {
return res.status(400).json({ ok: false, error: 'No chunk data received' });
}
const chunkBuffer = req.files[0].buffer;
const tmpDir = getTmpDir(uploadId);
const metaPath = path.join(tmpDir, '_meta.json');
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true });
}
// ✅ Always write meta (overwrite is fine — values never change per upload)
// Fixes out-of-order arrival: chunk 3 might arrive before chunk 0
await fss.writeFile(
metaPath,
JSON.stringify({ originalname, mimetype, totalChunks })
);
// ✅ Zero-padded so readdir alphabetical sort = correct chunk order
const chunkPath = path.join(tmpDir, `${String(chunkIndex).padStart(6, '0')}.part`);
await fss.writeFile(chunkPath, chunkBuffer);
logToFile(`✔ chunk ${chunkIndex + 1}/${totalChunks} saved uploadId=${uploadId}`);
res.json({
ok : true,
received: chunkIndex + 1,
total : totalChunks,
});
} catch (err) {
logToFile(`❌ uploadChunk: ${err.stack || err.message}`);
res.status(500).json({ ok: false, error: err.message });
}
};
// ─── POST /finalize-upload ────────────────────────────────────────────────
const finalizeUpload = async (req, res) => {
const { uploadId, uploadUrl, serverpath, filetype, thumbUrl } = req.body;
if (!uploadId) {
return res.status(400).json({ ok: false, error: 'uploadId required' });
}
const tmpDir = getTmpDir(uploadId);
const metaPath = path.join(tmpDir, '_meta.json');
try {
if (!fs.existsSync(metaPath)) {
return res.status(400).json({ ok: false, error: `No upload found for id: ${uploadId}` });
}
const meta = JSON.parse(await fss.readFile(metaPath, 'utf8'));
const { originalname, mimetype, totalChunks } = meta;
// ✅ Filter ONLY .part files — _meta.json must NOT be counted
const parts = (await fss.readdir(tmpDir))
.filter(f => f.endsWith('.part'))
.sort();
logToFile(`✔ found ${parts.length} parts, expected ${totalChunks} uploadId=${uploadId}`);
// ✅ parseInt so "5" !== 5 comparison never fails
if (parts.length !== parseInt(totalChunks, 10)) {
return res.status(400).json({
ok : false,
error : `Chunk mismatch: expected ${totalChunks}, got ${parts.length}`,
detail: parts // sends back which parts arrived — useful for C# retry
});
}
logToFile(`✔ Assembling ${parts.length} chunks → ${originalname}`);
const buffers = await Promise.all(parts.map(p => fss.readFile(path.join(tmpDir, p))));
const fullBuffer = Buffer.concat(buffers);
logToFile(`✔ Assembled size: ${fullBuffer.length} bytes`);
const fakeFile = { buffer: fullBuffer, originalname, mimetype };
const body = { uploadUrl, serverpath, filetype, thumbUrl };
const result = await processSingleImage(fakeFile, body);
// Cleanup after successful processing
await fss.rm(tmpDir, { recursive: true, force: true });
logToFile(`✔ Done ${originalname}: ${result.status}`);
res.json({ ok: true, result });
} catch (err) {
logToFile(`❌ finalizeUpload: ${err.stack || err.message}`);
await fss.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
res.status(500).json({ ok: false, error: err.message });
}
};
C# — add chunk verification before finalize:
csharp
// After sending all chunks, verify count before finalizing
private async Task<bool> VerifyAllChunksSentAsync(
string uploadId, int totalChunks, string fileName)
{
// Re-send any chunk that got a non-200 response
// (already handled by SendChunkWithRetryAsync returning false)
// This is a final sanity log on the client side
Console.WriteLine($" [{fileName}] All {totalChunks} chunks confirmed → sending finalize");
return true;
}
public async Task<UploadResult> UploadFileAsync(
string filePath,
string serverPath,
string uploadUrl,
string thumbUrl,
string fileType = "image",
IProgress<int> progress = null)
{
if (!File.Exists(filePath))
return Fail($"File not found: {filePath}");
var fileBytes = await File.ReadAllBytesAsync(filePath);
var fileName = Path.GetFileName(filePath);
var mimeType = GetMimeType(fileName);
var uploadId = Guid.NewGuid().ToString("N");
var totalChunks = (int)Math.Ceiling((double)fileBytes.Length / ChunkSize);
Console.WriteLine($"[{fileName}] {fileBytes.Length:N0} bytes → {totalChunks} chunk(s) id={uploadId}");
// ── Send all chunks, track which ones failed ──────────────────────────────
var failedChunks = new List<int>();
for (int i = 0; i < totalChunks; i++)
{
var offset = i * ChunkSize;
var length = Math.Min(ChunkSize, fileBytes.Length - offset);
var chunk = new byte[length];
Buffer.BlockCopy(fileBytes, offset, chunk, 0, length);
var sent = await SendChunkWithRetryAsync(
uploadId, i, totalChunks, fileName, mimeType, chunk
);
if (!sent)
failedChunks.Add(i);
var pct = (int)Math.Round((i + 1) / (double)totalChunks * 100);
progress?.Report(pct);
Console.WriteLine($" [{fileName}] chunk {i + 1}/{totalChunks} {pct}% {(sent ? "✔" : "❌")}");
}
// ── If any chunk failed, abort — do not finalize ──────────────────────────
if (failedChunks.Count > 0)
{
return Fail($"Aborted: {failedChunks.Count} chunk(s) failed → [{string.Join(",", failedChunks)}]");
}
// ── All chunks confirmed → finalize (server assembles + avif converts) ────
Console.WriteLine($" [{fileName}] All {totalChunks} chunks OK → finalizing");
return await FinalizeAsync(
uploadId, fileName, mimeType, serverPath, uploadUrl, thumbUrl, fileType
);
}
Summary of all fixes
# Problem Fix
1 app.json() not a function Use express.json() from the module, not the app instance
2 _meta.json only written on chunk 0 Write meta on every chunk (safe overwrite, same data)
3 parts.length !== parseInt(totalChunks) string vs int parseInt(..., 10) both sides
4 _meta.json counted as a .part file .filter(f => f.endsWith('.part')) strictly
5 C# finalizes even if a chunk failed Track failedChunks[], abort if any failed before calling finalize
when uploading image totally 5 chunks 3 upload success and mobile network cut then how to continue remaining chunk and send
May 12
Good question — this is resumable upload. The client needs to ask the server "which chunks do you already have?" before resuming.
Node.js — add one new route /chunk-status
javascript
// ─── GET /chunk-status?uploadId=xxx ──────────────────────────────────────────
// Returns which chunk indexes the server already has
const chunkStatus = async (req, res) => {
try {
const { uploadId } = req.query;
if (!uploadId) {
return res.status(400).json({ ok: false, error: 'uploadId required' });
}
const tmpDir = getTmpDir(uploadId);
// Upload never started or temp folder wiped
if (!fs.existsSync(tmpDir)) {
return res.json({ ok: true, uploadId, receivedChunks: [], canResume: false });
}
// Read which .part files exist
const parts = (await fss.readdir(tmpDir))
.filter(f => f.endsWith('.part'))
.sort();
// Convert filenames back to indexes: "000002.part" → 2
const receivedIndexes = parts.map(f => parseInt(f.replace('.part', ''), 10));
logToFile(`✔ chunkStatus ${uploadId}: ${receivedIndexes.length} chunks on disk [${receivedIndexes}]`);
res.json({
ok : true,
uploadId,
receivedChunks: receivedIndexes, // e.g. [0, 1, 2]
canResume : receivedIndexes.length > 0,
});
} catch (err) {
logToFile(`❌ chunkStatus: ${err.stack || err.message}`);
res.status(500).json({ ok: false, error: err.message });
}
};
Register the route:
javascript
router.get('/chunk-status', chunkStatus);
C# — save uploadId locally, resume on reconnect
csharp
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
public class ChunkedUploader : IDisposable
{
private readonly HttpClient _http;
private readonly string _baseUrl;
private readonly string _resumeStorePath; // local file to persist uploadId
private const int ChunkSize = 2 * 1024 * 1024; // 2 MB
private const int MaxRetries = 3;
public ChunkedUploader(string baseUrl, string resumeStorePath = "resume_state.json")
{
_http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
_baseUrl = baseUrl.TrimEnd('/');
_resumeStorePath = resumeStorePath;
}
// ─────────────────────────────────────────────────────────────────────────
// Upload with full resume support
// ─────────────────────────────────────────────────────────────────────────
public async Task<UploadResult> UploadFileAsync(
string filePath,
string serverPath,
string uploadUrl,
string thumbUrl,
string fileType = "image",
IProgress<int> progress = null)
{
if (!File.Exists(filePath))
return Fail($"File not found: {filePath}");
var fileBytes = await File.ReadAllBytesAsync(filePath);
var fileName = Path.GetFileName(filePath);
var mimeType = GetMimeType(fileName);
var totalChunks = (int)Math.Ceiling((double)fileBytes.Length / ChunkSize);
// ── Check if a previous uploadId exists for this file ─────────────────
var uploadId = await TryLoadSavedUploadIdAsync(filePath);
var isResume = false;
if (uploadId != null)
{
// Ask server which chunks it already has
var status = await GetChunkStatusAsync(uploadId);
if (status != null && status.CanResume)
{
isResume = true;
Console.WriteLine($"[{fileName}] Resuming upload id={uploadId} " +
$"server has chunks: [{string.Join(",", status.ReceivedChunks)}]");
}
else
{
// Server has no record — start fresh
Console.WriteLine($"[{fileName}] No resume data on server, starting fresh");
uploadId = null;
}
}
// New upload — generate a fresh uploadId and save it
if (uploadId == null)
{
uploadId = Guid.NewGuid().ToString("N");
await SaveUploadIdAsync(filePath, uploadId, totalChunks);
Console.WriteLine($"[{fileName}] New upload id={uploadId} chunks={totalChunks}");
}
// ── Get already-received chunk indexes from server ────────────────────
var alreadyReceived = new HashSet<int>();
if (isResume)
{
var status = await GetChunkStatusAsync(uploadId);
if (status?.ReceivedChunks != null)
foreach (var idx in status.ReceivedChunks)
alreadyReceived.Add(idx);
}
// ── Send only missing chunks ──────────────────────────────────────────
var failedChunks = new List<int>();
for (int i = 0; i < totalChunks; i++)
{
// ✅ Skip chunks the server already confirmed
if (alreadyReceived.Contains(i))
{
Console.WriteLine($" [{fileName}] chunk {i + 1}/{totalChunks} ⏭ already on server");
progress?.Report((int)Math.Round((i + 1) / (double)totalChunks * 100));
continue;
}
var offset = i * ChunkSize;
var length = Math.Min(ChunkSize, fileBytes.Length - offset);
var chunk = new byte[length];
Buffer.BlockCopy(fileBytes, offset, chunk, 0, length);
var sent = await SendChunkWithRetryAsync(
uploadId, i, totalChunks, fileName, mimeType, chunk
);
if (!sent)
{
failedChunks.Add(i);
Console.WriteLine($" [{fileName}] chunk {i + 1}/{totalChunks} ❌ failed");
}
else
{
Console.WriteLine($" [{fileName}] chunk {i + 1}/{totalChunks} ✔");
}
var pct = (int)Math.Round((i + 1) / (double)totalChunks * 100);
progress?.Report(pct);
}
// ── Abort if any chunk still failed ───────────────────────────────────
if (failedChunks.Count > 0)
{
// Keep the saved uploadId so next call can resume again
Console.WriteLine($" [{fileName}] ❌ {failedChunks.Count} chunk(s) failed, " +
$"uploadId saved for next resume");
return Fail($"Incomplete: chunks [{string.Join(",", failedChunks)}] failed. " +
$"Call UploadFileAsync again to resume.");
}
// ── All chunks on server — finalize ───────────────────────────────────
Console.WriteLine($" [{fileName}] All {totalChunks} chunks OK → finalizing");
var result = await FinalizeAsync(
uploadId, fileName, mimeType, serverPath, uploadUrl, thumbUrl, fileType
);
// ── Clean up local resume state on success ────────────────────────────
if (result.Ok)
DeleteSavedUploadId(filePath);
return result;
}
// ─────────────────────────────────────────────────────────────────────────
// GET /chunk-status?uploadId=xxx
// ─────────────────────────────────────────────────────────────────────────
private async Task<ChunkStatusResponse> GetChunkStatusAsync(string uploadId)
{
try
{
var response = await _http.GetAsync($"{_baseUrl}/chunk-status?uploadId={uploadId}");
if (!response.IsSuccessStatusCode) return null;
var body = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<ChunkStatusResponse>(
body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
);
}
catch (Exception ex)
{
Console.WriteLine($" GetChunkStatus failed: {ex.Message}");
return null;
}
}
// ─────────────────────────────────────────────────────────────────────────
// Local resume state — saved as JSON next to the uploader
// Key = filePath, Value = { uploadId, totalChunks }
// ─────────────────────────────────────────────────────────────────────────
private async Task SaveUploadIdAsync(string filePath, string uploadId, int totalChunks)
{
try
{
Dictionary<string, ResumeEntry> store = LoadStore();
store[filePath] = new ResumeEntry { UploadId = uploadId, TotalChunks = totalChunks };
await File.WriteAllTextAsync(_resumeStorePath, JsonSerializer.Serialize(store));
}
catch (Exception ex)
{
Console.WriteLine($" SaveUploadId warning: {ex.Message}");
}
}
private async Task<string> TryLoadSavedUploadIdAsync(string filePath)
{
try
{
var store = LoadStore();
return store.TryGetValue(filePath, out var entry) ? entry.UploadId : null;
}
catch { return null; }
}
private void DeleteSavedUploadId(string filePath)
{
try
{
var store = LoadStore();
if (store.Remove(filePath))
File.WriteAllText(_resumeStorePath, JsonSerializer.Serialize(store));
}
catch { /* non-critical */ }
}
private Dictionary<string, ResumeEntry> LoadStore()
{
if (!File.Exists(_resumeStorePath))
return new Dictionary<string, ResumeEntry>();
try
{
var json = File.ReadAllText(_resumeStorePath);
return JsonSerializer.Deserialize<Dictionary<string, ResumeEntry>>(json)
?? new Dictionary<string, ResumeEntry>();
}
catch { return new Dictionary<string, ResumeEntry>(); }
}
// ─────────────────────────────────────────────────────────────────────────
// Send one chunk with retry
// ─────────────────────────────────────────────────────────────────────────
private async Task<bool> SendChunkWithRetryAsync(
string uploadId, int index, int total,
string fileName, string mimeType, byte[] data)
{
for (int attempt = 0; attempt < MaxRetries; attempt++)
{
try
{
using var form = new MultipartFormDataContent();
form.Add(new StringContent(uploadId), "uploadId");
form.Add(new StringContent(index.ToString()), "chunkIndex");
form.Add(new StringContent(total.ToString()), "totalChunks");
form.Add(new StringContent(fileName), "originalname");
form.Add(new StringContent(mimeType), "mimetype");
var byteContent = new ByteArrayContent(data);
byteContent.Headers.ContentType =
new MediaTypeHeaderValue("application/octet-stream");
form.Add(byteContent, "chunk", fileName);
var response = await _http.PostAsync($"{_baseUrl}/upload-chunk", form);
if (response.IsSuccessStatusCode) return true;
Console.WriteLine($" Chunk {index} attempt {attempt + 1} " +
$"HTTP {(int)response.StatusCode}");
}
catch (Exception ex)
{
Console.WriteLine($" Chunk {index} attempt {attempt + 1}: {ex.Message}");
}
await Task.Delay(500 * (attempt + 1));
}
return false;
}
// ─────────────────────────────────────────────────────────────────────────
// Finalize
// ─────────────────────────────────────────────────────────────────────────
private async Task<UploadResult> FinalizeAsync(
string uploadId, string fileName, string mimeType,
string serverPath, string uploadUrl, string thumbUrl, string fileType)
{
var payload = new
{
uploadId,
originalname = fileName,
mimetype = mimeType,
serverpath = serverPath,
uploadUrl,
thumbUrl,
filetype = fileType,
};
var content = new StringContent(
JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"
);
try
{
var response = await _http.PostAsync($"{_baseUrl}/finalize-upload", content);
var body = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
return JsonSerializer.Deserialize<UploadResult>(
body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
) ?? new UploadResult { Ok = true };
}
return Fail($"Finalize HTTP {(int)response.StatusCode}: {body}");
}
catch (Exception ex) { return Fail(ex.Message); }
}
private static UploadResult Fail(string error)
{
Console.WriteLine($" ❌ {error}");
return new UploadResult { Ok = false, Error = error };
}
private static string GetMimeType(string fileName) =>
Path.GetExtension(fileName).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".avif" => "image/avif",
".webp" => "image/webp",
".gif" => "image/gif",
_ => "application/octet-stream",
};
public void Dispose() => _http.Dispose();
}
// ─────────────────────────────────────────────────────────────────────────────
// Models
// ─────────────────────────────────────────────────────────────────────────────
public class UploadResult
{
public bool Ok { get; set; }
public string Error { get; set; }
public object Result { get; set; }
}
public class ChunkStatusResponse
{
public bool Ok { get; set; }
public string UploadId { get; set; }
public List<int> ReceivedChunks { get; set; }
public bool CanResume { get; set; }
}
public class ResumeEntry
{
public string UploadId { get; set; }
public int TotalChunks { get; set; }
}
Usage — call the exact same method, it auto-resumes:
csharp
using var uploader = new ChunkedUploader("https://your-server.com");
// First call — network cuts after chunk 3
var result = await uploader.UploadFileAsync(
filePath : @"C:\photos\DSC_0001.jpg",
serverPath: @"\\NAS\share",
uploadUrl : "images/2024",
thumbUrl : "images/2024/thumbs"
);
// result.Ok = false, uploadId saved locally
// --- network restored ---
// Second call — same method, same file path
// Automatically finds saved uploadId → asks server for status
// Server says chunks [0,1,2] received → only sends chunks [3,4]
var result2 = await uploader.UploadFileAsync(
filePath : @"C:\photos\DSC_0001.jpg",
serverPath: @"\\NAS\share",
uploadUrl : "images/2024",
thumbUrl : "images/2024/thumbs"
);
// result2.Ok = true ✔
What happens step by step
First attempt (network cuts at chunk 3):
Chunk 0 ✔ → saved as 000000.part
Chunk 1 ✔ → saved as 000001.part
Chunk 2 ✔ → saved as 000002.part
Chunk 3 ❌ network cut
uploadId saved to resume_state.json locally
Resume attempt:
C# reads resume_state.json → finds uploadId for this file
GET /chunk-status?uploadId=xxx
Server reads tmpDir → returns { receivedChunks: [0,1,2] }
C# skips chunks 0,1,2 ⏭
Sends chunk 3 ✔ → saved as 000003.part
Sends chunk 4 ✔ → saved as 000004.part
POST /finalize-upload
Server assembles 000000–000004.part → full buffer
sharp converts → avif saved ✔
resume_state.json entry deleted ✔
var result = await uploader.UploadFileAsync( filePath : @"C:\photos\DSC_0001.jpg", serverPath: @"\\NAS\share", uploadUrl : "images/2024", thumbUrl : "images/2024/thumbs" ); also passing ifromfile
May 12
Here's the updated UploadFileAsync that accepts IFormFile directly instead of a file path:
csharp
// ─────────────────────────────────────────────────────────────────────────────
// Overload — accepts IFormFile (from Ionic/MAUI file picker)
// ─────────────────────────────────────────────────────────────────────────────
public async Task<UploadResult> UploadFileAsync(
IFormFile file,
string serverPath,
string uploadUrl,
string thumbUrl,
string fileType = "image",
IProgress<int> progress = null)
{
// ── Read IFormFile into byte[] ────────────────────────────────────────────
byte[] fileBytes;
using (var ms = new MemoryStream())
{
await file.CopyToAsync(ms);
fileBytes = ms.ToArray();
}
var fileName = Path.GetFileName(file.FileName);
var mimeType = file.ContentType ?? GetMimeType(fileName);
// ── Delegate to core method ───────────────────────────────────────────────
return await UploadCoreAsync(
fileBytes, fileName, mimeType,
serverPath, uploadUrl, thumbUrl, fileType, progress
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Overload — accepts file path (original)
// ─────────────────────────────────────────────────────────────────────────────
public async Task<UploadResult> UploadFileAsync(
string filePath,
string serverPath,
string uploadUrl,
string thumbUrl,
string fileType = "image",
IProgress<int> progress = null)
{
if (!File.Exists(filePath))
return Fail($"File not found: {filePath}");
var fileBytes = await File.ReadAllBytesAsync(filePath);
var fileName = Path.GetFileName(filePath);
var mimeType = GetMimeType(fileName);
return await UploadCoreAsync(
fileBytes, fileName, mimeType,
serverPath, uploadUrl, thumbUrl, fileType, progress
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Core — all logic lives here, both overloads call this
// ─────────────────────────────────────────────────────────────────────────────
private async Task<UploadResult> UploadCoreAsync(
byte[] fileBytes,
string fileName,
string mimeType,
string serverPath,
string uploadUrl,
string thumbUrl,
string fileType,
IProgress<int> progress)
{
var totalChunks = (int)Math.Ceiling((double)fileBytes.Length / ChunkSize);
// ── Check for saved uploadId (resume support) ─────────────────────────────
var uploadId = await TryLoadSavedUploadIdAsync(fileName); // keyed by fileName
var alreadyReceived = new HashSet<int>();
if (uploadId != null)
{
var status = await GetChunkStatusAsync(uploadId);
if (status != null && status.CanResume)
{
Console.WriteLine($"[{fileName}] Resuming id={uploadId} " +
$"server has: [{string.Join(",", status.ReceivedChunks)}]");
foreach (var idx in status.ReceivedChunks)
alreadyReceived.Add(idx);
}
else
{
Console.WriteLine($"[{fileName}] No resume data on server, starting fresh");
uploadId = null;
}
}
if (uploadId == null)
{
uploadId = Guid.NewGuid().ToString("N");
await SaveUploadIdAsync(fileName, uploadId, totalChunks);
Console.WriteLine($"[{fileName}] New upload id={uploadId} chunks={totalChunks}");
}
// ── Send missing chunks ───────────────────────────────────────────────────
var failedChunks = new List<int>();
for (int i = 0; i < totalChunks; i++)
{
if (alreadyReceived.Contains(i))
{
Console.WriteLine($" [{fileName}] chunk {i + 1}/{totalChunks} ⏭ already on server");
progress?.Report((int)Math.Round((i + 1) / (double)totalChunks * 100));
continue;
}
var offset = i * ChunkSize;
var length = Math.Min(ChunkSize, fileBytes.Length - offset);
var chunk = new byte[length];
Buffer.BlockCopy(fileBytes, offset, chunk, 0, length);
var sent = await SendChunkWithRetryAsync(
uploadId, i, totalChunks, fileName, mimeType, chunk
);
if (!sent) failedChunks.Add(i);
var pct = (int)Math.Round((i + 1) / (double)totalChunks * 100);
progress?.Report(pct);
Console.WriteLine($" [{fileName}] chunk {i + 1}/{totalChunks} {(sent ? "✔" : "❌")}");
}
// ── Abort if chunks missing — keep uploadId for next resume ──────────────
if (failedChunks.Count > 0)
{
return Fail($"Incomplete: chunks [{string.Join(",", failedChunks)}] failed. " +
$"Call UploadFileAsync again to resume.");
}
// ── Finalize → server assembles + avif converts ───────────────────────────
Console.WriteLine($" [{fileName}] All {totalChunks} chunks OK → finalizing");
var result = await FinalizeAsync(
uploadId, fileName, mimeType, serverPath, uploadUrl, thumbUrl, fileType
);
if (result.Ok)
DeleteSavedUploadId(fileName);
return result;
}
Usage — both overloads side by side:
csharp
using var uploader = new ChunkedUploader("https://your-server.com");
// ── From IFormFile (Ionic / MAUI file picker) ─────────────────────────────────
IFormFile pickedFile = ...; // from file picker result
var result = await uploader.UploadFileAsync(
file : pickedFile,
serverPath: @"\\NAS\share",
uploadUrl : "images/2024",
thumbUrl : "images/2024/thumbs"
);
// ── From file path (original) ─────────────────────────────────────────────────
var result2 = await uploader.UploadFileAsync(
filePath : @"C:\photos\DSC_0001.jpg",
serverPath: @"\\NAS\share",
uploadUrl : "images/2024",
thumbUrl : "images/2024/thumbs"
);
// ── Batch with mixed sources ──────────────────────────────────────────────────
var tasks = pickedFiles.Select(f => uploader.UploadFileAsync(
file : f,
serverPath: @"\\NAS\share",
uploadUrl : "images/2024",
thumbUrl : "images/2024/thumbs"
));
var results = await Task.WhenAll(tasks);
Key design points
Two public overloads, one private core — UploadFileAsync(IFormFile) and UploadFileAsync(string filePath) both read their source into a byte[] + fileName + mimeType, then call UploadCoreAsync which has all the chunk/resume/finalize logic. No duplication.
IFormFile.ContentType used directly — the mimeType comes from file.ContentType which the browser/OS sets correctly (e.g. image/jpeg). GetMimeType() is only the fallback if ContentType is null.
Resume keyed by fileName — since IFormFile has no stable file path, resume_state.json uses fileName as the key instead of the full path. If your app can have two files with the same name from different folders, use file.FileName (which includes the path on some platforms) or append a file size to the key: $"{fileName}_{fileBytes.Length}".
i need ifromfile with handling failed chunks images retry
May 18
Here's the complete implementation with failed chunk retry handling:
csharp
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
public class ChunkedUploader : IDisposable
{
private readonly HttpClient _http;
private readonly string _baseUrl;
private readonly string _resumeStorePath;
private const int ChunkSize = 2 * 1024 * 1024; // 2 MB
private const int MaxRetries = 3;
private const int RetryDelay = 500; // ms base delay
public ChunkedUploader(string baseUrl, string resumeStorePath = "resume_state.json")
{
_http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
_baseUrl = baseUrl.TrimEnd('/');
_resumeStorePath = resumeStorePath;
}
// ─────────────────────────────────────────────────────────────────────────
// PUBLIC — IFormFile entry point
// ─────────────────────────────────────────────────────────────────────────
public async Task<UploadResult> UploadFileAsync(
IFormFile file,
string serverPath,
string uploadUrl,
string thumbUrl,
string fileType = "image",
IProgress<UploadProgress> progress = null)
{
// Read IFormFile → byte[]
byte[] fileBytes;
using (var ms = new MemoryStream())
{
await file.CopyToAsync(ms);
fileBytes = ms.ToArray();
}
var fileName = Path.GetFileName(file.FileName);
var mimeType = file.ContentType ?? GetMimeType(fileName);
// Resume key includes size to avoid collision if same filename different file
var resumeKey = $"{fileName}_{fileBytes.Length}";
return await UploadCoreAsync(
fileBytes, fileName, mimeType, resumeKey,
serverPath, uploadUrl, thumbUrl, fileType, progress
);
}
// ─────────────────────────────────────────────────────────────────────────
// PUBLIC — Batch IFormFile with concurrency + per-file retry
// ─────────────────────────────────────────────────────────────────────────
public async Task<BatchUploadResult> UploadFilesAsync(
IEnumerable<IFormFile> files,
string serverPath,
string uploadUrl,
string thumbUrl,
string fileType = "image",
int concurrency = 3,
int maxFileRetry = 2, // how many times to retry a fully-failed file
IProgress<BatchProgress> batchProgress = null)
{
var fileList = files.ToList();
var semaphore = new SemaphoreSlim(concurrency);
var tasks = new List<Task<FileUploadResult>>();
for (int fi = 0; fi < fileList.Count; fi++)
{
var file = fileList[fi];
var fileIndex = fi;
await semaphore.WaitAsync();
tasks.Add(Task.Run(async () =>
{
try
{
UploadResult result = null;
// Per-file retry loop
for (int attempt = 0; attempt <= maxFileRetry; attempt++)
{
if (attempt > 0)
{
Console.WriteLine($" [{file.FileName}] file retry {attempt}/{maxFileRetry}");
await Task.Delay(1000 * attempt);
}
var fileProgress = new Progress<UploadProgress>(p =>
{
batchProgress?.Report(new BatchProgress
{
FileIndex = fileIndex,
TotalFiles = fileList.Count,
FileName = file.FileName,
FilePercent = p.Percent,
ChunksDone = p.ChunksDone,
TotalChunks = p.TotalChunks,
FailedChunks = p.FailedChunks,
});
});
result = await UploadFileAsync(
file, serverPath, uploadUrl, thumbUrl, fileType, fileProgress
);
// Success — stop retrying this file
if (result.Ok) break;
// Only retry if failure was chunk-related (network),
// not a server processing error
if (!result.IsChunkFailure) break;
}
return new FileUploadResult
{
FileName = file.FileName,
Result = result,
};
}
finally { semaphore.Release(); }
}));
}
var allResults = await Task.WhenAll(tasks);
return new BatchUploadResult
{
Success = allResults.Where(r => r.Result.Ok).ToList(),
Failed = allResults.Where(r => !r.Result.Ok).ToList(),
SuccessCount = allResults.Count(r => r.Result.Ok),
FailedCount = allResults.Count(r => !r.Result.Ok),
};
}
// ─────────────────────────────────────────────────────────────────────────
// CORE — chunk split / resume / retry failed chunks / finalize
// ─────────────────────────────────────────────────────────────────────────
private async Task<UploadResult> UploadCoreAsync(
byte[] fileBytes,
string fileName,
string mimeType,
string resumeKey,
string serverPath,
string uploadUrl,
string thumbUrl,
string fileType,
IProgress<UploadProgress> progress)
{
var totalChunks = (int)Math.Ceiling((double)fileBytes.Length / ChunkSize);
// ── Resume: find existing uploadId ────────────────────────────────────
var uploadId = await TryLoadSavedUploadIdAsync(resumeKey);
var alreadyReceived = new HashSet<int>();
if (uploadId != null)
{
var status = await GetChunkStatusAsync(uploadId);
if (status is { CanResume: true })
{
Console.WriteLine($"[{fileName}] Resuming id={uploadId} " +
$"server has [{string.Join(",", status.ReceivedChunks)}]");
foreach (var idx in status.ReceivedChunks)
alreadyReceived.Add(idx);
}
else
{
Console.WriteLine($"[{fileName}] No resume data — starting fresh");
uploadId = null;
alreadyReceived.Clear();
}
}
if (uploadId == null)
{
uploadId = Guid.NewGuid().ToString("N");
await SaveUploadIdAsync(resumeKey, uploadId, totalChunks);
Console.WriteLine($"[{fileName}] New upload id={uploadId} chunks={totalChunks}");
}
// ── Build pending chunk list (skip already received) ──────────────────
var pendingChunks = Enumerable.Range(0, totalChunks)
.Where(i => !alreadyReceived.Contains(i))
.ToList();
Console.WriteLine($"[{fileName}] Pending chunks: {pendingChunks.Count}/{totalChunks}");
// ── Round 1: send all pending chunks ──────────────────────────────────
var failedChunks = new List<int>();
foreach (var i in pendingChunks)
{
var sent = await SendChunkWithRetryAsync(
uploadId, i, totalChunks, fileName, mimeType,
GetChunkBytes(fileBytes, i)
);
if (!sent)
failedChunks.Add(i);
ReportProgress(progress, fileName, i + 1, totalChunks,
alreadyReceived.Count + (pendingChunks.IndexOf(i) + 1),
failedChunks);
}
// ── Round 2: retry failed chunks once more (fresh HTTP call) ──────────
if (failedChunks.Count > 0)
{
Console.WriteLine($"[{fileName}] ⚠ Retrying {failedChunks.Count} failed chunk(s): " +
$"[{string.Join(",", failedChunks)}]");
await Task.Delay(1000); // brief pause before retry round
var stillFailed = new List<int>();
foreach (var i in failedChunks)
{
Console.WriteLine($" [{fileName}] retry chunk {i}");
var sent = await SendChunkWithRetryAsync(
uploadId, i, totalChunks, fileName, mimeType,
GetChunkBytes(fileBytes, i),
maxRetries: 5 // more retries on the retry round
);
if (!sent)
stillFailed.Add(i);
else
Console.WriteLine($" [{fileName}] chunk {i} recovered ✔");
}
failedChunks = stillFailed;
}
// ── Still failing — save state and return retryable error ─────────────
if (failedChunks.Count > 0)
{
Console.WriteLine($"[{fileName}] ❌ {failedChunks.Count} chunk(s) permanently failed");
return new UploadResult
{
Ok = false,
IsChunkFailure = true,
Error = $"Chunks [{string.Join(",", failedChunks)}] failed. " +
$"Call UploadFileAsync again to resume.",
FailedChunks = failedChunks,
};
}
// ── All chunks on server → finalize (assembly + avif conversion) ──────
Console.WriteLine($"[{fileName}] All {totalChunks} chunks OK → finalizing");
var result = await FinalizeAsync(
uploadId, fileName, mimeType, serverPath, uploadUrl, thumbUrl, fileType
);
// Clean up local resume state on success
if (result.Ok)
DeleteSavedUploadId(resumeKey);
return result;
}
// ─────────────────────────────────────────────────────────────────────────
// Send one chunk — with retry + exponential back-off
// ─────────────────────────────────────────────────────────────────────────
private async Task<bool> SendChunkWithRetryAsync(
string uploadId, int index, int total,
string fileName, string mimeType, byte[] data,
int maxRetries = MaxRetries)
{
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
using var form = new MultipartFormDataContent();
form.Add(new StringContent(uploadId), "uploadId");
form.Add(new StringContent(index.ToString()), "chunkIndex");
form.Add(new StringContent(total.ToString()), "totalChunks");
form.Add(new StringContent(fileName), "originalname");
form.Add(new StringContent(mimeType), "mimetype");
var bytes = new ByteArrayContent(data);
bytes.Headers.ContentType =
new MediaTypeHeaderValue("application/octet-stream");
form.Add(bytes, "chunk", fileName);
var response = await _http.PostAsync($"{_baseUrl}/upload-chunk", form);
if (response.IsSuccessStatusCode)
{
Console.WriteLine($" chunk {index} attempt {attempt + 1} ✔");
return true;
}
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine($" chunk {index} attempt {attempt + 1} " +
$"HTTP {(int)response.StatusCode}: {body}");
}
catch (Exception ex)
{
Console.WriteLine($" chunk {index} attempt {attempt + 1} ex: {ex.Message}");
}
// Exponential back-off: 500ms → 1000ms → 1500ms …
await Task.Delay(RetryDelay * (attempt + 1));
}
return false;
}
// ─────────────────────────────────────────────────────────────────────────
// GET /chunk-status?uploadId=xxx
// ─────────────────────────────────────────────────────────────────────────
private async Task<ChunkStatusResponse> GetChunkStatusAsync(string uploadId)
{
try
{
var response = await _http.GetAsync($"{_baseUrl}/chunk-status?uploadId={uploadId}");
if (!response.IsSuccessStatusCode) return null;
var body = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<ChunkStatusResponse>(
body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
);
}
catch (Exception ex)
{
Console.WriteLine($" GetChunkStatus failed: {ex.Message}");
return null;
}
}
// ─────────────────────────────────────────────────────────────────────────
// POST /finalize-upload
// ─────────────────────────────────────────────────────────────────────────
private async Task<UploadResult> FinalizeAsync(
string uploadId, string fileName, string mimeType,
string serverPath, string uploadUrl, string thumbUrl, string fileType)
{
var payload = new
{
uploadId,
originalname = fileName,
mimetype = mimeType,
serverpath = serverPath,
uploadUrl,
thumbUrl,
filetype = fileType,
};
var content = new StringContent(
JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"
);
try
{
var response = await _http.PostAsync($"{_baseUrl}/finalize-upload", content);
var body = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
return JsonSerializer.Deserialize<UploadResult>(
body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
) ?? new UploadResult { Ok = true };
}
return Fail($"Finalize HTTP {(int)response.StatusCode}: {body}");
}
catch (Exception ex) { return Fail(ex.Message); }
}
// ─────────────────────────────────────────────────────────────────────────
// Resume state — persisted to resume_state.json
// ─────────────────────────────────────────────────────────────────────────
private async Task SaveUploadIdAsync(string key, string uploadId, int totalChunks)
{
try
{
var store = LoadStore();
store[key] = new ResumeEntry { UploadId = uploadId, TotalChunks = totalChunks };
await File.WriteAllTextAsync(_resumeStorePath, JsonSerializer.Serialize(store));
}
catch (Exception ex) { Console.WriteLine($" SaveUploadId warn: {ex.Message}"); }
}
private async Task<string> TryLoadSavedUploadIdAsync(string key)
{
try
{
var store = LoadStore();
return store.TryGetValue(key, out var entry) ? entry.UploadId : null;
}
catch { return null; }
}
private void DeleteSavedUploadId(string key)
{
try
{
var store = LoadStore();
if (store.Remove(key))
File.WriteAllText(_resumeStorePath, JsonSerializer.Serialize(store));
}
catch { }
}
private Dictionary<string, ResumeEntry> LoadStore()
{
if (!File.Exists(_resumeStorePath))
return new Dictionary<string, ResumeEntry>();
try
{
var json = File.ReadAllText(_resumeStorePath);
return JsonSerializer.Deserialize<Dictionary<string, ResumeEntry>>(json)
?? new Dictionary<string, ResumeEntry>();
}
catch { return new Dictionary<string, ResumeEntry>(); }
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private byte[] GetChunkBytes(byte[] fileBytes, int index)
{
var offset = index * ChunkSize;
var length = Math.Min(ChunkSize, fileBytes.Length - offset);
var chunk = new byte[length];
Buffer.BlockCopy(fileBytes, offset, chunk, 0, length);
return chunk;
}
private static void ReportProgress(
IProgress<UploadProgress> progress,
string fileName, int chunksDone, int totalChunks,
int overallDone, List<int> failedChunks)
{
var pct = (int)Math.Round(overallDone / (double)totalChunks * 100);
progress?.Report(new UploadProgress
{
FileName = fileName,
Percent = pct,
ChunksDone = chunksDone,
TotalChunks = totalChunks,
FailedChunks = new List<int>(failedChunks),
});
Console.WriteLine($" [{fileName}] {pct,3}% chunk {chunksDone}/{totalChunks}" +
(failedChunks.Count > 0 ? $" failed:[{string.Join(",", failedChunks)}]" : ""));
}
private static UploadResult Fail(string error) =>
new UploadResult { Ok = false, Error = error, IsChunkFailure = false };
private static string GetMimeType(string fileName) =>
Path.GetExtension(fileName).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".avif" => "image/avif",
".webp" => "image/webp",
".gif" => "image/gif",
_ => "application/octet-stream",
};
public void Dispose() => _http.Dispose();
}
// ─────────────────────────────────────────────────────────────────────────────
// Models
// ─────────────────────────────────────────────────────────────────────────────
public class UploadResult
{
public bool Ok { get; set; }
public string Error { get; set; }
public object Result { get; set; }
public bool IsChunkFailure { get; set; } // true = network, safe to retry
public List<int> FailedChunks { get; set; } = new();
}
public class FileUploadResult
{
public string FileName { get; set; }
public UploadResult Result { get; set; }
}
public class BatchUploadResult
{
public List<FileUploadResult> Success { get; set; }
public List<FileUploadResult> Failed { get; set; }
public int SuccessCount { get; set; }
public int FailedCount { get; set; }
}
public class UploadProgress
{
public string FileName { get; set; }
public int Percent { get; set; }
public int ChunksDone { get; set; }
public int TotalChunks { get; set; }
public List<int> FailedChunks { get; set; } = new();
}
public class BatchProgress
{
public int FileIndex { get; set; }
public int TotalFiles { get; set; }
public string FileName { get; set; }
public int FilePercent { get; set; }
public int ChunksDone { get; set; }
public int TotalChunks { get; set; }
public List<int> FailedChunks { get; set; } = new();
}
public class ChunkStatusResponse
{
public bool Ok { get; set; }
public string UploadId { get; set; }
public List<int> ReceivedChunks { get; set; }
public bool CanResume { get; set; }
}
public class ResumeEntry
{
public string UploadId { get; set; }
public int TotalChunks { get; set; }
}
Usage:
csharp
using var uploader = new ChunkedUploader("https://your-server.com");
// ── Single IFormFile with progress ────────────────────────────────────────────
var progress = new Progress<UploadProgress>(p =>
{
Console.WriteLine($"{p.FileName} {p.Percent}% " +
$"{p.ChunksDone}/{p.TotalChunks} chunks" +
(p.FailedChunks.Count > 0
? $" retrying:[{string.Join(",", p.FailedChunks)}]"
: ""));
});
var result = await uploader.UploadFileAsync(
file : formFile, // IFormFile from picker
serverPath: @"\\NAS\share",
uploadUrl : "images/2024",
thumbUrl : "images/2024/thumbs",
progress : progress
);
if (!result.Ok)
{
if (result.IsChunkFailure)
Console.WriteLine($"Network error — call again to resume. Failed: {result.Error}");
else
Console.WriteLine($"Server error: {result.Error}");
}
// ── Batch IFormFile with per-file retry ───────────────────────────────────────
var batchProgress = new Progress<BatchProgress>(p =>
{
Console.WriteLine($"File {p.FileIndex + 1}/{p.TotalFiles} " +
$"{p.FileName} {p.FilePercent}%");
});
var batch = await uploader.UploadFilesAsync(
files : formFiles, // IEnumerable<IFormFile>
serverPath : @"\\NAS\share",
uploadUrl : "images/2024",
thumbUrl : "images/2024/thumbs",
concurrency : 3,
maxFileRetry : 2, // retry each failed file up to 2 times
batchProgress: batchProgress
);
Console.WriteLine($"Done: {batch.SuccessCount} ok, {batch.FailedCount} failed");
foreach (var f in batch.Failed)
Console.WriteLine($" ❌ {f.FileName}: {f.Result.Error}");
Retry flow for 5 chunks where 3 succeed and network cuts
Round 1 — initial send:
chunk 0 ✔ (3 retries max each)
chunk 1 ✔
chunk 2 ✔
chunk 3 ❌ network cut — all 3 retries exhausted
chunk 4 ❌ network cut — all 3 retries exhausted
Round 2 — automatic retry of failed chunks (5 retries, 1s pause first):
chunk 3 ✔ recovered
chunk 4 ✔ recovered
Finalize → server assembles 000000–000004.part
→ sharp avif conversion runs
→ thumb generated
→ ✔ done
If Round 2 also fails:
IsChunkFailure = true saved to resume_state.json
Next call to UploadFileAsync same file:
GET /chunk-status → server has [0,1,2]
Skips 0,1,2 ⏭
Only sends 3,4 again
my case i upload two images one by one how to call and which image is not uploded which one is suceess and how failed image to retry using previous code in .net core
May 18
Here's the complete implementation for your exact case — two images one by one, tracking success/failed, and retrying failed ones:
Controller:
csharp
[ApiController]
[Route("api/[controller]")]
public class ImageUploadController : ControllerBase
{
private readonly ChunkedUploader _uploader;
public ImageUploadController(ChunkedUploader uploader)
{
_uploader = uploader;
}
// ── POST /api/imageupload/upload ──────────────────────────────────────────
// Called once with all images — processes one by one, tracks each result
[HttpPost("upload")]
public async Task<IActionResult> Upload(
[FromForm] List<IFormFile> files,
[FromForm] string serverPath,
[FromForm] string uploadUrl,
[FromForm] string thumbUrl,
[FromForm] string fileType = "image")
{
if (files == null || files.Count == 0)
return BadRequest(new { ok = false, error = "No files received" });
var successList = new List<ImageResultDto>();
var failedList = new List<ImageResultDto>();
// ── Process images ONE BY ONE ─────────────────────────────────────────
foreach (var file in files)
{
Console.WriteLine($"\n▶ Uploading {file.FileName} ({file.Length:N0} bytes)");
var result = await _uploader.UploadFileAsync(
file : file,
serverPath: serverPath,
uploadUrl : uploadUrl,
thumbUrl : thumbUrl,
fileType : fileType,
progress : new Progress<UploadProgress>(p =>
{
Console.WriteLine($" {p.FileName} {p.Percent}% " +
$"chunk {p.ChunksDone}/{p.TotalChunks}" +
(p.FailedChunks.Count > 0
? $" retrying:[{string.Join(",", p.FailedChunks)}]"
: ""));
})
);
if (result.Ok)
{
Console.WriteLine($" ✔ {file.FileName} success");
successList.Add(new ImageResultDto
{
FileName = file.FileName,
Status = "success",
Result = result.Result,
});
}
else
{
Console.WriteLine($" ❌ {file.FileName} failed: {result.Error}");
failedList.Add(new ImageResultDto
{
FileName = file.FileName,
Status = "failed",
Error = result.Error,
IsChunkFailure = result.IsChunkFailure,
FailedChunks = result.FailedChunks,
});
}
}
return Ok(new UploadSummaryDto
{
Ok = true,
SuccessCount = successList.Count,
FailedCount = failedList.Count,
Success = successList,
Failed = failedList,
});
}
// ── POST /api/imageupload/retry ───────────────────────────────────────────
// Client sends back ONLY the failed images to retry
[HttpPost("retry")]
public async Task<IActionResult> Retry(
[FromForm] List<IFormFile> files,
[FromForm] string serverPath,
[FromForm] string uploadUrl,
[FromForm] string thumbUrl,
[FromForm] string fileType = "image")
{
if (files == null || files.Count == 0)
return BadRequest(new { ok = false, error = "No files to retry" });
var successList = new List<ImageResultDto>();
var failedList = new List<ImageResultDto>();
foreach (var file in files)
{
Console.WriteLine($"\n🔄 Retrying {file.FileName}");
var result = await _uploader.UploadFileAsync(
file : file,
serverPath: serverPath,
uploadUrl : uploadUrl,
thumbUrl : thumbUrl,
fileType : fileType
);
if (result.Ok)
{
successList.Add(new ImageResultDto
{
FileName = file.FileName,
Status = "success",
Result = result.Result,
});
}
else
{
failedList.Add(new ImageResultDto
{
FileName = file.FileName,
Status = "failed",
Error = result.Error,
IsChunkFailure = result.IsChunkFailure,
FailedChunks = result.FailedChunks,
});
}
}
return Ok(new UploadSummaryDto
{
Ok = failedList.Count == 0,
SuccessCount = successList.Count,
FailedCount = failedList.Count,
Success = successList,
Failed = failedList,
});
}
}
DTOs:
csharp
public class ImageResultDto
{
public string FileName { get; set; }
public string Status { get; set; } // "success" | "failed"
public string Error { get; set; }
public object Result { get; set; }
public bool IsChunkFailure { get; set; }
public List<int> FailedChunks { get; set; } = new();
}
public class UploadSummaryDto
{
public bool Ok { get; set; }
public int SuccessCount { get; set; }
public int FailedCount { get; set; }
public List<ImageResultDto> Success { get; set; } = new();
public List<ImageResultDto> Failed { get; set; } = new();
}
Register in Program.cs:
csharp
builder.Services.AddSingleton<ChunkedUploader>(sp =>
new ChunkedUploader(
baseUrl : "https://your-node-server.com",
resumeStorePath : "resume_state.json"
)
);
Ionic/mobile client — full flow:
typescript
// upload.service.ts
export class UploadService {
private apiUrl = 'https://your-dotnet-api.com/api/imageupload';
// ── Step 1: Upload all images, get back success + failed lists ──────────────
async uploadImages(
files: File[],
serverPath: string,
uploadUrl: string,
thumbUrl: string
): Promise<UploadSummary> {
const form = new FormData();
// Append all files
files.forEach(f => form.append('files', f, f.name));
form.append('serverPath', serverPath);
form.append('uploadUrl', uploadUrl);
form.append('thumbUrl', thumbUrl);
const response = await fetch(`${this.apiUrl}/upload`, {
method: 'POST',
body : form,
});
return response.json();
}
// ── Step 2: Retry only the failed images ────────────────────────────────────
async retryFailedImages(
failedFiles : File[], // original File objects for failed ones
serverPath : string,
uploadUrl : string,
thumbUrl : string
): Promise<UploadSummary> {
const form = new FormData();
failedFiles.forEach(f => form.append('files', f, f.name));
form.append('serverPath', serverPath);
form.append('uploadUrl', uploadUrl);
form.append('thumbUrl', thumbUrl);
const response = await fetch(`${this.apiUrl}/retry`, {
method: 'POST',
body : form,
});
return response.json();
}
}
// ── Usage in your component ────────────────────────────────────────────────────
async handleUpload(selectedFiles: File[]) {
// Keep a map of filename → File for retry lookup
const fileMap = new Map<string, File>(
selectedFiles.map(f => [f.name, f])
);
// First upload attempt
const summary = await this.uploadService.uploadImages(
selectedFiles,
'\\\\NAS\\share',
'images/2024',
'images/2024/thumbs'
);
console.log(`✔ Success: ${summary.successCount}`);
console.log(`❌ Failed: ${summary.failedCount}`);
summary.success.forEach(s => console.log(` ✔ ${s.fileName}`));
summary.failed.forEach(f => console.log(` ❌ ${f.fileName}: ${f.error}`));
// Auto-retry failed images if any
if (summary.failedCount > 0) {
// Get the original File objects for failed images
const filesToRetry = summary.failed
.map(f => fileMap.get(f.fileName))
.filter(Boolean);
console.log(`\n🔄 Retrying ${filesToRetry.length} failed image(s)...`);
const retryResult = await this.uploadService.retryFailedImages(
filesToRetry,
'\\\\NAS\\share',
'images/2024',
'images/2024/thumbs'
);
console.log(`Retry result: ✔ ${retryResult.successCount} ❌ ${retryResult.failedCount}`);
// Still failed after retry — show to user
if (retryResult.failedCount > 0) {
retryResult.failed.forEach(f =>
console.log(` Permanently failed: ${f.fileName} → ${f.error}`)
);
}
}
}
What happens for your exact case (2 images, 1 fails)
POST /api/imageupload/upload
files: [image1.jpg, image2.jpg]
▶ image1.jpg
chunk 1/5 ✔
chunk 2/5 ✔
chunk 3/5 ✔
chunk 4/5 ✔
chunk 5/5 ✔
finalize ✔ → avif saved
✔ image1.jpg success
▶ image2.jpg
chunk 1/5 ✔
chunk 2/5 ✔
chunk 3/5 ✔
chunk 4/5 ❌ network cut → Round 2 retry
chunk 4/5 ❌ still failing
chunk 5/5 ❌ network cut → Round 2 retry
chunk 5/5 ❌ still failing
❌ image2.jpg failed IsChunkFailure=true
Response:
{
"ok": true,
"successCount": 1,
"failedCount": 1,
"success": [{ "fileName": "image1.jpg", "status": "success" }],
"failed": [{ "fileName": "image2.jpg", "status": "failed",
"isChunkFailure": true,
"failedChunks": [3, 4] }]
}
POST /api/imageupload/retry
files: [image2.jpg] ← only the failed one
🔄 image2.jpg
GET /chunk-status → server has [0,1,2] ← resume kicks in
chunk 0 ⏭ skip
chunk 1 ⏭ skip
chunk 2 ⏭ skip
chunk 3 ✔ (network restored)
chunk 4 ✔
finalize ✔ → avif saved
Response:
{
"ok": true,
"successCount": 1,
"failedCount": 0,
"success": [{ "fileName": "image2.jpg", "status": "success" }],
"failed": []
}
<form action="/Home/TaskCreate" method="post" enctype="multipart/form-data" class="mt-2" id="form-create-task"> <div class="d-flex mb-4"> <h4 class="me-3">Create Work</h4> @if (Model.showofficial) { <div class="form-check align-content-center">
pasted
my index page taskattachments after selected call UploadFileAsync
May 18
You need to intercept the form submit, extract the taskattachments files, upload them via chunked uploader first, then submit the form with the returned filenames.
JavaScript — in your view (add before </form> or in a script section):
javascript
// ── Chunked upload config ─────────────────────────────────────────────────────
const CHUNK_SIZE = 2 * 1024 * 1024; // 2 MB
const API_BASE = 'https://your-node-server.com';
const SERVER_PATH = '\\\\NAS\\share';
const UPLOAD_URL = 'images/2024';
const THUMB_URL = 'images/2024/thumbs';
// Track upload state per file
const uploadState = {
files : [], // original File objects
results : [], // { fileName, status, avifName, error }
failedFiles : [], // File objects that failed
};
// ── File selected → show preview + auto-start upload ─────────────────────────
document.getElementById('taskattachments').addEventListener('change', async function () {
const files = Array.from(this.files);
if (!files.length) return;
uploadState.files = files;
uploadState.results = [];
uploadState.failedFiles = [];
renderPreviews(files);
await uploadAllFiles(files);
});
// ── Upload all files one by one ───────────────────────────────────────────────
async function uploadAllFiles(files) {
// Disable submit while uploading
document.getElementById('btnsave').disabled = true;
document.getElementById('btnsave').innerText = 'Uploading...';
for (const file of files) {
updatePreviewStatus(file.name, 'uploading', 0);
const result = await uploadFileInChunks(file);
if (result.ok) {
uploadState.results.push({
fileName: file.name,
avifName: result.avifName,
status : 'success',
});
updatePreviewStatus(file.name, 'success', 100);
} else {
uploadState.results.push({
fileName: file.name,
status : 'failed',
error : result.error,
});
uploadState.failedFiles.push(file);
updatePreviewStatus(file.name, 'failed', 0, result.error);
}
}
const failCount = uploadState.failedFiles.length;
if (failCount > 0) {
// Show retry button — don't enable submit yet
showRetryButton(failCount);
} else {
// All uploaded — enable submit
document.getElementById('btnsave').disabled = false;
document.getElementById('btnsave').innerText = 'Send Work';
}
}
// ── Retry only failed files ───────────────────────────────────────────────────
async function retryFailedUploads() {
const toRetry = [...uploadState.failedFiles];
uploadState.failedFiles = [];
hideRetryButton();
document.getElementById('btnsave').disabled = true;
document.getElementById('btnsave').innerText = 'Retrying...';
for (const file of toRetry) {
updatePreviewStatus(file.name, 'uploading', 0);
const result = await uploadFileInChunks(file);
if (result.ok) {
// Update existing result entry
const existing = uploadState.results.find(r => r.fileName === file.name);
if (existing) {
existing.status = 'success';
existing.avifName = result.avifName;
delete existing.error;
}
updatePreviewStatus(file.name, 'success', 100);
} else {
uploadState.failedFiles.push(file);
updatePreviewStatus(file.name, 'failed', 0, result.error);
}
}
if (uploadState.failedFiles.length > 0) {
showRetryButton(uploadState.failedFiles.length);
} else {
document.getElementById('btnsave').disabled = false;
document.getElementById('btnsave').innerText = 'Send Work';
}
}
// ── Core chunked upload for one file ─────────────────────────────────────────
async function uploadFileInChunks(file) {
const RESUME_KEY = `${file.name}_${file.size}`;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
// Check if we have a saved uploadId for resume
let uploadId = localStorage.getItem(`upload_${RESUME_KEY}`);
let alreadyReceived = new Set();
if (uploadId) {
try {
const statusRes = await fetch(`${API_BASE}/chunk-status?uploadId=${uploadId}`);
const status = await statusRes.json();
if (status.ok && status.canResume) {
status.receivedChunks.forEach(i => alreadyReceived.add(i));
console.log(`[${file.name}] Resuming — server has [${[...alreadyReceived]}]`);
} else {
uploadId = null;
alreadyReceived.clear();
}
} catch {
uploadId = null;
}
}
if (!uploadId) {
uploadId = crypto.randomUUID().replace(/-/g, '');
localStorage.setItem(`upload_${RESUME_KEY}`, uploadId);
}
// ── Round 1: send pending chunks ───────────────────────────────────────────
const failedChunks = [];
for (let i = 0; i < totalChunks; i++) {
if (alreadyReceived.has(i)) {
updatePreviewStatus(file.name, 'uploading',
Math.round((i + 1) / totalChunks * 100));
continue;
}
const start = i * CHUNK_SIZE;
const chunk = file.slice(start, start + CHUNK_SIZE);
const sent = await sendChunkWithRetry(uploadId, i, totalChunks, file, chunk);
if (!sent) failedChunks.push(i);
updatePreviewStatus(file.name, 'uploading',
Math.round((i + 1) / totalChunks * 100));
}
// ── Round 2: retry failed chunks ───────────────────────────────────────────
if (failedChunks.length > 0) {
console.log(`[${file.name}] Retrying chunks [${failedChunks}]`);
await new Promise(r => setTimeout(r, 1000));
const stillFailed = [];
for (const i of failedChunks) {
const start = i * CHUNK_SIZE;
const chunk = file.slice(start, start + CHUNK_SIZE);
const sent = await sendChunkWithRetry(uploadId, i, totalChunks, file, chunk, 5);
if (!sent) stillFailed.push(i);
}
if (stillFailed.length > 0) {
// Keep uploadId in localStorage for next resume
return {
ok : false,
error: `Chunks [${stillFailed}] failed. Will resume on retry.`,
};
}
}
// ── Finalize ───────────────────────────────────────────────────────────────
try {
const finalRes = await fetch(`${API_BASE}/finalize-upload`, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
uploadId,
originalname: file.name,
mimetype : file.type,
serverpath : SERVER_PATH,
uploadUrl : UPLOAD_URL,
thumbUrl : THUMB_URL,
filetype : 'image',
}),
});
const finalData = await finalRes.json();
if (finalData.ok) {
// Clean up localStorage on success
localStorage.removeItem(`upload_${RESUME_KEY}`);
return { ok: true, avifName: finalData.result?.fileName };
}
return { ok: false, error: finalData.error };
} catch (err) {
return { ok: false, error: err.message };
}
}
// ── Send one chunk with retry ─────────────────────────────────────────────────
async function sendChunkWithRetry(uploadId, index, total, file, chunkBlob, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const form = new FormData();
form.append('uploadId', uploadId);
form.append('chunkIndex', index);
form.append('totalChunks', total);
form.append('originalname', file.name);
form.append('mimetype', file.type);
form.append('chunk', chunkBlob, file.name);
const res = await fetch(`${API_BASE}/upload-chunk`, {
method: 'POST',
body : form,
});
if (res.ok) return true;
console.warn(`chunk ${index} attempt ${attempt + 1} HTTP ${res.status}`);
} catch (err) {
console.warn(`chunk ${index} attempt ${attempt + 1} error: ${err.message}`);
}
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
}
return false;
}
// ── Form submit — inject uploaded filenames then post ─────────────────────────
async function savetask() {
// Block if any uploads still pending or failed
const failCount = uploadState.failedFiles.length;
if (failCount > 0) {
alert(`${failCount} file(s) not uploaded yet. Please retry first.`);
return;
}
// Check all selected files have a success result
const allDone = uploadState.results.every(r => r.status === 'success');
if (uploadState.files.length > 0 && !allDone) {
alert('Some files are still uploading. Please wait.');
return;
}
// Inject avif filenames as hidden inputs so server knows what was uploaded
const form = document.getElementById('form-create-task');
uploadState.results
.filter(r => r.status === 'success')
.forEach(r => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'uploadedFiles';
input.value = r.avifName;
form.appendChild(input);
});
// Clear the file input so it doesn't re-POST the raw files
document.getElementById('taskattachments').value = '';
form.submit();
}
// ── Preview rendering ─────────────────────────────────────────────────────────
function renderPreviews(files) {
const container = document.getElementById('image_preview');
container.innerHTML = '';
files.forEach(file => {
const isImage = file.type.startsWith('image/');
const col = document.createElement('div');
col.className = 'col-md-2 mb-3 text-center';
col.id = `preview_${sanitizeId(file.name)}`;
col.innerHTML = `
<div class="position-relative border rounded p-1">
${isImage
? `<img src="${URL.createObjectURL(file)}"
class="img-fluid rounded mb-1"
style="height:80px;width:100%;object-fit:cover;" />`
: `<div class="d-flex align-items-center justify-content-center"
style="height:80px;">
<i class="bi bi-file-earmark fs-2"></i>
</div>`
}
<small class="d-block text-truncate" title="${file.name}">${file.name}</small>
<div class="mt-1" id="status_${sanitizeId(file.name)}">
<div class="progress" style="height:6px;">
<div class="progress-bar" id="bar_${sanitizeId(file.name)}"
style="width:0%"></div>
</div>
<small class="text-muted" id="lbl_${sanitizeId(file.name)}">Pending</small>
</div>
</div>`;
container.appendChild(col);
});
}
function updatePreviewStatus(fileName, status, percent, errorMsg = '') {
const id = sanitizeId(fileName);
const bar = document.getElementById(`bar_${id}`);
const lbl = document.getElementById(`lbl_${id}`);
if (!bar || !lbl) return;
bar.style.width = `${percent}%`;
switch (status) {
case 'uploading':
bar.className = 'progress-bar bg-primary progress-bar-striped progress-bar-animated';
lbl.className = 'text-primary';
lbl.innerText = `${percent}%`;
break;
case 'success':
bar.className = 'progress-bar bg-success';
lbl.className = 'text-success';
lbl.innerText = '✔ Uploaded';
break;
case 'failed':
bar.className = 'progress-bar bg-danger';
lbl.className = 'text-danger';
lbl.innerText = `❌ ${errorMsg || 'Failed'}`;
break;
}
}
function showRetryButton(count) {
let btn = document.getElementById('btn-retry-uploads');
if (!btn) {
btn = document.createElement('button');
btn.id = 'btn-retry-uploads';
btn.type = 'button';
btn.className = 'btn btn-warning ms-4';
btn.onclick = retryFailedUploads;
document.querySelector('.modal-submit-btn').insertAdjacentElement('beforebegin', btn);
}
btn.innerText = `🔄 Retry ${count} Failed`;
btn.style.display = '';
}
function hideRetryButton() {
const btn = document.getElementById('btn-retry-uploads');
if (btn) btn.style.display = 'none';
}
function sanitizeId(name) {
return name.replace(/[^a-zA-Z0-9]/g, '_');
}
ASP.NET Controller — receive uploaded filenames:
csharp
[HttpPost]
public async Task<IActionResult> TaskCreate(
TaskCreateViewModel model,
[FromForm] List<string> uploadedFiles) // ← avif names from hidden inputs
{
// uploadedFiles = ["photo1.avif", "photo2.avif"]
// No file processing needed — already uploaded to NAS by chunked uploader
model.Attachments = uploadedFiles;
// ... save task to DB
return RedirectToAction("Index");
}
Full flow
User selects image1.jpg + image2.jpg
↓ change event fires immediately
image1.jpg:
chunk 1/5 ✔ chunk 2/5 ✔ chunk 3/5 ✔
chunk 4/5 ❌ → Round 2 retry → ✔ recovered
chunk 5/5 ✔
finalize ✔ → avif saved on NAS
preview ✔ Uploaded (green)
image2.jpg:
chunk 1/3 ✔ chunk 2/3 ✔ chunk 3/3 ✔
finalize ✔ → avif saved on NAS
preview ✔ Uploaded (green)
All done → "Send Work" button enabled
User clicks Send Work:
hidden inputs injected: uploadedFiles=image1.avif, uploadedFiles=image2.avif
file input cleared (no raw bytes posted)
form.submit() → POST /Home/TaskCreate
controller receives uploadedFiles list → saves to DB
upload cun current uploads and retry and delete image each file
May 19
Here's the complete implementation with upload progress, retry, and delete per file:
V
Connecting to visualize...
Now here's the full production code to drop into your view:
Replace <div id="image_preview"> section and add this script block:
html
<!-- Inside your existing form, replace the image_preview div -->
<div class="col-md-12">
<!-- Summary bar -->
<div class="d-flex align-items-center gap-3 my-2" id="upload-summary" style="display:none!important">
<span class="text-muted" id="s-total"></span>
<span class="text-success" id="s-ok"></span>
<span class="text-danger" id="s-fail"></span>
<button type="button" class="btn btn-sm btn-warning" id="btn-retry-all"
onclick="retryAllFailed()" style="display:none">
<i class="bi bi-arrow-repeat"></i> Retry All Failed
</button>
</div>
<!-- Per-file cards -->
<div id="file-list" class="my-2"></div>
</div>
Full JavaScript — paste before </body> or in your scripts section:
javascript
// ── Config ────────────────────────────────────────────────────────────────────
const CHUNK_SIZE = 2 * 1024 * 1024;
const API_BASE = 'https://your-node-server.com';
const SERVER_PATH = '\\\\NAS\\share';
const UPLOAD_URL = 'images/2024';
const THUMB_URL = 'images/2024/thumbs';
// ── State — keyed by fileId ───────────────────────────────────────────────────
// { id, file, status, pct, avifName, error, aborted }
const uploadRegistry = {};
let fileSeq = 0;
// ── File input change ─────────────────────────────────────────────────────────
document.getElementById('taskattachments').addEventListener('change', function () {
Array.from(this.files).forEach(file => {
const id = 'f' + (++fileSeq);
uploadRegistry[id] = {
id, file,
status : 'pending',
pct : 0,
avifName: null,
error : '',
aborted : false,
};
renderFileCard(id);
startUpload(id); // auto-start immediately
});
this.value = ''; // reset so same file can be re-selected
updateSummary();
document.getElementById('upload-summary').style.display = 'flex';
});
// ── Render one file card ──────────────────────────────────────────────────────
function renderFileCard(id) {
const d = uploadRegistry[id];
const isImg = d.file.type.startsWith('image/');
const thumbHtml = isImg
? `<img src="${URL.createObjectURL(d.file)}"
style="width:54px;height:54px;object-fit:cover;border-radius:6px;flex-shrink:0"
alt="${d.file.name}">`
: `<div style="width:54px;height:54px;background:var(--bs-secondary-bg,#f0f0f0);
border-radius:6px;display:flex;align-items:center;justify-content:center;flex-shrink:0">
<i class="bi bi-file-earmark fs-4 text-secondary"></i>
</div>`;
const card = document.createElement('div');
card.id = `card-${id}`;
card.className = 'border rounded p-2 mb-2 d-flex align-items-center gap-2';
card.style.cssText = 'background:#fff;transition:border-color .2s;';
card.innerHTML = `
${thumbHtml}
<div style="flex:1;min-width:0">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-truncate fw-500" style="font-size:13px;max-width:240px"
title="${d.file.name}">${d.file.name}</span>
<span class="text-muted" style="font-size:11px;flex-shrink:0;margin-left:8px">
${fmtSize(d.file.size)}
</span>
</div>
<div class="progress mb-1" style="height:5px;">
<div class="progress-bar" id="bar-${id}"
style="width:0%;transition:width .3s;"></div>
</div>
<div class="d-flex justify-content-between">
<span id="lbl-${id}" style="font-size:11px;" class="text-muted">Pending</span>
<span id="chunk-lbl-${id}" style="font-size:11px;color:#888"></span>
</div>
</div>
<div class="d-flex flex-column gap-1" style="flex-shrink:0">
<button type="button" id="btn-retry-${id}"
class="btn btn-sm btn-outline-warning py-0 px-2"
onclick="retryFile('${id}')"
style="display:none;font-size:11px">
<i class="bi bi-arrow-repeat"></i> Retry
</button>
<button type="button"
class="btn btn-sm btn-outline-danger py-0 px-2"
onclick="deleteFile('${id}')"
style="font-size:11px">
<i class="bi bi-trash"></i>
</button>
</div>`;
document.getElementById('file-list').appendChild(card);
}
// ── Update card visuals ───────────────────────────────────────────────────────
function updateCard(id) {
const d = uploadRegistry[id];
const card = document.getElementById(`card-${id}`);
const bar = document.getElementById(`bar-${id}`);
const lbl = document.getElementById(`lbl-${id}`);
const retryBtn = document.getElementById(`btn-retry-${id}`);
if (!card) return;
// Progress bar color + width
bar.style.width = d.pct + '%';
bar.className = 'progress-bar';
if (d.status === 'uploading') bar.classList.add('bg-primary', 'progress-bar-striped', 'progress-bar-animated');
else if (d.status === 'success') bar.classList.add('bg-success');
else if (d.status === 'failed') bar.classList.add('bg-danger');
// Card border
card.style.borderColor =
d.status === 'success' ? '#198754' :
d.status === 'failed' ? '#dc3545' : '';
// Label
lbl.className = '';
if (d.status === 'uploading') { lbl.className = 'text-primary'; lbl.textContent = d.pct + '%'; }
else if (d.status === 'success') { lbl.className = 'text-success'; lbl.textContent = '✔ Uploaded'; }
else if (d.status === 'failed') { lbl.className = 'text-danger'; lbl.textContent = '✖ ' + (d.error || 'Failed'); }
else { lbl.className = 'text-muted'; lbl.textContent = 'Pending'; }
// Retry button
retryBtn.style.display = d.status === 'failed' ? '' : 'none';
}
function updateChunkLabel(id, done, total, failedArr) {
const el = document.getElementById(`chunk-lbl-${id}`);
if (!el) return;
let t = `chunk ${done}/${total}`;
if (failedArr && failedArr.length) t += ` · retrying [${failedArr.join(',')}]`;
el.textContent = t;
}
function clearChunkLabel(id) {
const el = document.getElementById(`chunk-lbl-${id}`);
if (el) el.textContent = '';
}
// ── Delete a file card ────────────────────────────────────────────────────────
function deleteFile(id) {
if (uploadRegistry[id]) uploadRegistry[id].aborted = true;
document.getElementById(`card-${id}`)?.remove();
delete uploadRegistry[id];
updateSummary();
}
// ── Retry one file ────────────────────────────────────────────────────────────
function retryFile(id) {
const d = uploadRegistry[id];
if (!d) return;
d.status = 'pending';
d.pct = 0;
d.error = '';
d.aborted = false;
updateCard(id);
startUpload(id);
updateSummary();
}
// ── Retry all failed ──────────────────────────────────────────────────────────
function retryAllFailed() {
Object.values(uploadRegistry)
.filter(d => d.status === 'failed')
.forEach(d => retryFile(d.id));
}
// ── Summary bar ───────────────────────────────────────────────────────────────
function updateSummary() {
const all = Object.values(uploadRegistry);
const ok = all.filter(d => d.status === 'success').length;
const fail = all.filter(d => d.status === 'failed').length;
document.getElementById('s-total').textContent = `${all.length} file${all.length !== 1 ? 's' : ''}`;
document.getElementById('s-ok').textContent = `✔ ${ok} uploaded`;
document.getElementById('s-fail').textContent = fail ? `✖ ${fail} failed` : '';
const retryAllBtn = document.getElementById('btn-retry-all');
retryAllBtn.style.display = fail > 0 ? '' : 'none';
if (all.length === 0)
document.getElementById('upload-summary').style.display = 'none';
}
// ── Core upload ───────────────────────────────────────────────────────────────
async function startUpload(id) {
const d = uploadRegistry[id];
const file = d.file;
const RESUME_KEY = `upload_${file.name}_${file.size}`;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
d.status = 'uploading';
updateCard(id);
// ── Resume: check saved uploadId ─────────────────────────────────────────
let uploadId = localStorage.getItem(RESUME_KEY);
let alreadyReceived = new Set();
if (uploadId) {
try {
const res = await fetch(`${API_BASE}/chunk-status?uploadId=${uploadId}`);
const status = await res.json();
if (status.ok && status.canResume) {
status.receivedChunks.forEach(i => alreadyReceived.add(i));
} else {
uploadId = null;
}
} catch {
uploadId = null;
}
}
if (!uploadId) {
uploadId = crypto.randomUUID().replace(/-/g, '');
localStorage.setItem(RESUME_KEY, uploadId);
}
// ── Round 1: send pending chunks ─────────────────────────────────────────
const failedChunks = [];
for (let i = 0; i < totalChunks; i++) {
if (d.aborted) return;
if (alreadyReceived.has(i)) {
d.pct = Math.round((i + 1) / totalChunks * 100);
updateChunkLabel(id, i + 1, totalChunks, []);
updateCard(id);
continue;
}
const start = i * CHUNK_SIZE;
const blob = file.slice(start, start + CHUNK_SIZE);
const sent = await sendChunkWithRetry(uploadId, i, totalChunks, file, blob, 3);
if (d.aborted) return;
if (!sent) {
failedChunks.push(i);
}
d.pct = Math.round((i + 1) / totalChunks * 100);
updateChunkLabel(id, i + 1, totalChunks, failedChunks);
updateCard(id);
}
// ── Round 2: retry failed chunks ─────────────────────────────────────────
if (failedChunks.length > 0 && !d.aborted) {
await new Promise(r => setTimeout(r, 1000));
const stillFailed = [];
for (const i of failedChunks) {
if (d.aborted) return;
const start = i * CHUNK_SIZE;
const blob = file.slice(start, start + CHUNK_SIZE);
const sent = await sendChunkWithRetry(uploadId, i, totalChunks, file, blob, 5);
if (!sent) stillFailed.push(i);
updateChunkLabel(id, totalChunks, totalChunks, stillFailed);
}
if (stillFailed.length > 0) {
d.status = 'failed';
d.error = `Chunks [${stillFailed}] failed`;
updateCard(id);
updateSummary();
return;
}
}
// ── Finalize ─────────────────────────────────────────────────────────────
if (d.aborted) return;
try {
const res = await fetch(`${API_BASE}/finalize-upload`, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
uploadId,
originalname: file.name,
mimetype : file.type,
serverpath : SERVER_PATH,
uploadUrl : UPLOAD_URL,
thumbUrl : THUMB_URL,
filetype : 'image',
}),
});
const data = await res.json();
if (data.ok) {
d.status = 'success';
d.pct = 100;
d.avifName = data.result?.fileName;
localStorage.removeItem(RESUME_KEY);
clearChunkLabel(id);
} else {
d.status = 'failed';
d.error = data.error || 'Finalize failed';
}
} catch (err) {
d.status = 'failed';
d.error = err.message;
}
updateCard(id);
updateSummary();
}
// ── Send one chunk with retry + back-off ──────────────────────────────────────
async function sendChunkWithRetry(uploadId, index, total, file, blob, maxRetries) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const form = new FormData();
form.append('uploadId', uploadId);
form.append('chunkIndex', index);
form.append('totalChunks', total);
form.append('originalname', file.name);
form.append('mimetype', file.type);
form.append('chunk', blob, file.name);
const res = await fetch(`${API_BASE}/upload-chunk`, { method: 'POST', body: form });
if (res.ok) return true;
} catch (e) {
console.warn(`chunk ${index} attempt ${attempt + 1}:`, e.message);
}
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
}
return false;
}
// ── savetask — inject uploaded filenames then submit ─────────────────────────
async function savetask() {
const all = Object.values(uploadRegistry);
const failed = all.filter(d => d.status === 'failed');
const pending = all.filter(d => d.status === 'uploading' || d.status === 'pending');
if (pending.length > 0) {
alert(`${pending.length} file(s) still uploading. Please wait.`);
return;
}
if (failed.length > 0) {
alert(`${failed.length} file(s) failed. Please retry or remove them first.`);
return;
}
const form = document.getElementById('form-create-task');
// Remove any previously injected hidden inputs
form.querySelectorAll('input[name="uploadedFiles"]').forEach(el => el.remove());
// Inject avif filenames
all.filter(d => d.status === 'success' && d.avifName).forEach(d => {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = 'uploadedFiles';
inp.value = d.avifName;
form.appendChild(inp);
});
form.submit();
}
// ── Helper ────────────────────────────────────────────────────────────────────
function fmtSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
What each part does
Per-file card shows thumbnail, filename, filesize, animated progress bar, chunk counter (chunk 3/5), and status label. When a chunk fails mid-upload it shows retrying [3,4] inline next to the progress bar.
Delete button — available at all times including while uploading. Sets aborted = true which the upload loop checks between every chunk, so the upload stops cleanly without a hard cancel. The card and registry entry are removed immediately.
Retry button — appears only on failed cards. Resets state and calls startUpload again. Because localStorage still has the uploadId, the server's /chunk-status route returns which chunks already arrived and the uploader skips them, only sending the missing ones.
Retry All Failed button appears in the summary bar when any file has failed. Calls retryFile on every failed entry at once.
savetask() blocks submission if anything is still uploading or failed, then injects the server-returned .avif filenames as hidden uploadedFiles inputs and clears the file input before submitting, so no raw binary data is posted to your ASP.NET controller.
document.getElementById('file-list').appendChild(card); Uncaught TypeError: Cannot read properties of null (reading 'appendChild')
May 19
The error means the script runs before the DOM is ready. The file-list div doesn't exist yet when the script executes.
Fix — wrap everything in DOMContentLoaded:
javascript
document.addEventListener('DOMContentLoaded', function () {
// ── Config ────────────────────────────────────────────────────────────────
const CHUNK_SIZE = 2 * 1024 * 1024;
const API_BASE = 'https://your-node-server.com';
const SERVER_PATH = '\\\\NAS\\share';
const UPLOAD_URL = 'images/2024';
const THUMB_URL = 'images/2024/thumbs';
const uploadRegistry = {};
let fileSeq = 0;
// ── File input change ─────────────────────────────────────────────────────
const attachInput = document.getElementById('taskattachments');
if (!attachInput) {
console.warn('taskattachments input not found');
return;
}
attachInput.addEventListener('change', function () {
Array.from(this.files).forEach(file => {
const id = 'f' + (++fileSeq);
uploadRegistry[id] = {
id, file,
status : 'pending',
pct : 0,
avifName: null,
error : '',
aborted : false,
};
renderFileCard(id);
startUpload(id);
});
this.value = '';
updateSummary();
document.getElementById('upload-summary').style.display = 'flex';
});
// ── All your functions go INSIDE DOMContentLoaded ─────────────────────────
function renderFileCard(id) {
const container = document.getElementById('file-list');
if (!container) {
console.warn('file-list not found');
return;
}
const d = uploadRegistry[id];
const isImg = d.file.type.startsWith('image/');
const thumbHtml = isImg
? `<img src="${URL.createObjectURL(d.file)}"
style="width:54px;height:54px;object-fit:cover;border-radius:6px;flex-shrink:0"
alt="">`
: `<div style="width:54px;height:54px;background:#f0f0f0;border-radius:6px;
display:flex;align-items:center;justify-content:center;flex-shrink:0">
<i class="bi bi-file-earmark fs-4 text-secondary"></i>
</div>`;
const card = document.createElement('div');
card.id = `card-${id}`;
card.className = 'border rounded p-2 mb-2 d-flex align-items-center gap-2';
card.style.cssText = 'background:#fff;transition:border-color .2s;';
card.innerHTML = `
${thumbHtml}
<div style="flex:1;min-width:0">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-truncate fw-500"
style="font-size:13px;max-width:240px"
title="${d.file.name}">${d.file.name}</span>
<span class="text-muted"
style="font-size:11px;flex-shrink:0;margin-left:8px">
${fmtSize(d.file.size)}
</span>
</div>
<div class="progress mb-1" style="height:5px;">
<div class="progress-bar" id="bar-${id}"
style="width:0%;transition:width .3s;"></div>
</div>
<div class="d-flex justify-content-between">
<span id="lbl-${id}" style="font-size:11px;" class="text-muted">Pending</span>
<span id="chunk-lbl-${id}" style="font-size:11px;color:#888"></span>
</div>
</div>
<div class="d-flex flex-column gap-1" style="flex-shrink:0">
<button type="button" id="btn-retry-${id}"
class="btn btn-sm btn-outline-warning py-0 px-2"
style="display:none;font-size:11px">
<i class="bi bi-arrow-repeat"></i> Retry
</button>
<button type="button"
class="btn btn-sm btn-outline-danger py-0 px-2"
style="font-size:11px">
<i class="bi bi-trash"></i>
</button>
</div>`;
// ✅ Attach click handlers after innerHTML set — avoids inline onclick
// scope issues inside DOMContentLoaded
card.querySelector(`#btn-retry-${id}`)
.addEventListener('click', () => retryFile(id));
card.querySelectorAll('.btn-outline-danger')[0]
.addEventListener('click', () => deleteFile(id));
container.appendChild(card);
}
function updateCard(id) {
const d = uploadRegistry[id];
const card = document.getElementById(`card-${id}`);
const bar = document.getElementById(`bar-${id}`);
const lbl = document.getElementById(`lbl-${id}`);
const retryBtn = document.getElementById(`btn-retry-${id}`);
if (!card) return;
bar.style.width = d.pct + '%';
bar.className = 'progress-bar';
if (d.status === 'uploading') bar.classList.add('bg-primary','progress-bar-striped','progress-bar-animated');
else if (d.status === 'success') bar.classList.add('bg-success');
else if (d.status === 'failed') bar.classList.add('bg-danger');
card.style.borderColor =
d.status === 'success' ? '#198754' :
d.status === 'failed' ? '#dc3545' : '';
lbl.className = '';
if (d.status === 'uploading') { lbl.className = 'text-primary'; lbl.textContent = d.pct + '%'; }
else if (d.status === 'success') { lbl.className = 'text-success'; lbl.textContent = '✔ Uploaded'; }
else if (d.status === 'failed') { lbl.className = 'text-danger'; lbl.textContent = '✖ ' + (d.error || 'Failed'); }
else { lbl.className = 'text-muted'; lbl.textContent = 'Pending'; }
if (retryBtn) retryBtn.style.display = d.status === 'failed' ? '' : 'none';
}
function updateChunkLabel(id, done, total, failedArr) {
const el = document.getElementById(`chunk-lbl-${id}`);
if (!el) return;
let t = `chunk ${done}/${total}`;
if (failedArr && failedArr.length) t += ` · retrying [${failedArr.join(',')}]`;
el.textContent = t;
}
function clearChunkLabel(id) {
const el = document.getElementById(`chunk-lbl-${id}`);
if (el) el.textContent = '';
}
function deleteFile(id) {
if (uploadRegistry[id]) uploadRegistry[id].aborted = true;
document.getElementById(`card-${id}`)?.remove();
delete uploadRegistry[id];
updateSummary();
}
function retryFile(id) {
const d = uploadRegistry[id];
if (!d) return;
d.status = 'pending';
d.pct = 0;
d.error = '';
d.aborted = false;
updateCard(id);
startUpload(id);
updateSummary();
}
function retryAllFailed() {
Object.values(uploadRegistry)
.filter(d => d.status === 'failed')
.forEach(d => retryFile(d.id));
}
function updateSummary() {
const all = Object.values(uploadRegistry);
const ok = all.filter(d => d.status === 'success').length;
const fail = all.filter(d => d.status === 'failed').length;
const elTotal = document.getElementById('s-total');
const elOk = document.getElementById('s-ok');
const elFail = document.getElementById('s-fail');
const retryBtn = document.getElementById('btn-retry-all');
const summary = document.getElementById('upload-summary');
if (elTotal) elTotal.textContent = `${all.length} file${all.length !== 1 ? 's' : ''}`;
if (elOk) elOk.textContent = `✔ ${ok} uploaded`;
if (elFail) elFail.textContent = fail ? `✖ ${fail} failed` : '';
if (retryBtn) retryBtn.style.display = fail > 0 ? '' : 'none';
if (summary && all.length === 0) summary.style.display = 'none';
}
// ── Expose savetask globally so onclick="savetask()" still works ───────────
window.savetask = async function () {
const all = Object.values(uploadRegistry);
const failed = all.filter(d => d.status === 'failed');
const pending = all.filter(d => d.status === 'uploading' || d.status === 'pending');
if (pending.length > 0) {
alert(`${pending.length} file(s) still uploading. Please wait.`);
return;
}
if (failed.length > 0) {
alert(`${failed.length} file(s) failed. Please retry or remove them.`);
return;
}
const form = document.getElementById('form-create-task');
form.querySelectorAll('input[name="uploadedFiles"]').forEach(el => el.remove());
all.filter(d => d.status === 'success' && d.avifName).forEach(d => {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = 'uploadedFiles';
inp.value = d.avifName;
form.appendChild(inp);
});
form.submit();
};
// ── Also expose retryAllFailed for the button onclick ─────────────────────
window.retryAllFailed = retryAllFailed;
// ── startUpload, sendChunkWithRetry, fmtSize (unchanged from before) ───────
async function startUpload(id) {
const d = uploadRegistry[id];
const file = d.file;
const RESUME_KEY = `upload_${file.name}_${file.size}`;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
d.status = 'uploading';
updateCard(id);
let uploadId = localStorage.getItem(RESUME_KEY);
let alreadyReceived = new Set();
if (uploadId) {
try {
const res = await fetch(`${API_BASE}/chunk-status?uploadId=${uploadId}`);
const status = await res.json();
if (status.ok && status.canResume) {
status.receivedChunks.forEach(i => alreadyReceived.add(i));
} else {
uploadId = null;
}
} catch { uploadId = null; }
}
if (!uploadId) {
uploadId = crypto.randomUUID().replace(/-/g, '');
localStorage.setItem(RESUME_KEY, uploadId);
}
const failedChunks = [];
for (let i = 0; i < totalChunks; i++) {
if (d.aborted) return;
if (alreadyReceived.has(i)) {
d.pct = Math.round((i + 1) / totalChunks * 100);
updateChunkLabel(id, i + 1, totalChunks, []);
updateCard(id);
continue;
}
const blob = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
const sent = await sendChunkWithRetry(uploadId, i, totalChunks, file, blob, 3);
if (d.aborted) return;
if (!sent) failedChunks.push(i);
d.pct = Math.round((i + 1) / totalChunks * 100);
updateChunkLabel(id, i + 1, totalChunks, failedChunks);
updateCard(id);
}
if (failedChunks.length > 0 && !d.aborted) {
await new Promise(r => setTimeout(r, 1000));
const stillFailed = [];
for (const i of failedChunks) {
if (d.aborted) return;
const blob = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
const sent = await sendChunkWithRetry(uploadId, i, totalChunks, file, blob, 5);
if (!sent) stillFailed.push(i);
updateChunkLabel(id, totalChunks, totalChunks, stillFailed);
}
if (stillFailed.length > 0) {
d.status = 'failed';
d.error = `Chunks [${stillFailed}] failed`;
updateCard(id);
updateSummary();
return;
}
}
if (d.aborted) return;
try {
const res = await fetch(`${API_BASE}/finalize-upload`, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
uploadId,
originalname: file.name,
mimetype : file.type,
serverpath : SERVER_PATH,
uploadUrl : UPLOAD_URL,
thumbUrl : THUMB_URL,
filetype : 'image',
}),
});
const data = await res.json();
if (data.ok) {
d.status = 'success';
d.pct = 100;
d.avifName = data.result?.fileName;
localStorage.removeItem(RESUME_KEY);
clearChunkLabel(id);
} else {
d.status = 'failed';
d.error = data.error || 'Finalize failed';
}
} catch (err) {
d.status = 'failed';
d.error = err.message;
}
updateCard(id);
updateSummary();
}
async function sendChunkWithRetry(uploadId, index, total, file, blob, maxRetries) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const form = new FormData();
form.append('uploadId', uploadId);
form.append('chunkIndex', index);
form.append('totalChunks', total);
form.append('originalname', file.name);
form.append('mimetype', file.type);
form.append('chunk', blob, file.name);
const res = await fetch(`${API_BASE}/upload-chunk`, { method: 'POST', body: form });
if (res.ok) return true;
} catch (e) {
console.warn(`chunk ${index} attempt ${attempt + 1}:`, e.message);
}
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
}
return false;
}
function fmtSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
}); // end DOMContentLoaded
Why this fixes it
The root cause is scope — when your script runs at page load, the Razor view hasn't finished rendering the file-list div yet, so getElementById returns null.
Three things the fix does together:
DOMContentLoaded — wraps everything so no code runs until the full HTML is parsed and all elements exist in the DOM.
window.savetask and window.retryAllFailed — since your button uses onclick="savetask()" in the HTML, that function must be on window (global scope). Functions declared inside DOMContentLoaded are local by default, so they're explicitly assigned to window.
addEventListener instead of onclick in innerHTML — inline onclick strings inside DOMContentLoaded lose their closure scope. Using .addEventListener('click', () => retryFile(id)) after the card is created keeps the correct id reference.
throwing task:3486 file-list not found
renderFileCard @ task:3486
(anonymous) @ task:3472
(anonymous) @ task:3462Understand this warning
task:3478 Uncaught TypeError: Cannot read properties of null (reading 'style')
at HTMLInputElement.<anonymous> (task:3478:86)
May 19
The file-list div and upload-summary div are missing from your HTML. Add them to your form:
In your view, find this section and replace:
html
<!-- BEFORE (your current code) -->
<div class="col-md-12">
<div id="image_preview" class="my-3 row"></div>
</div>
html
<!-- AFTER — replace with this -->
<div class="col-md-12">
<!-- Summary bar -->
<div class="d-flex align-items-center gap-3 my-2"
id="upload-summary"
style="display:none!important">
<span class="text-muted" id="s-total"></span>
<span class="text-success" id="s-ok"></span>
<span class="text-danger" id="s-fail"></span>
<button type="button"
class="btn btn-sm btn-warning"
id="btn-retry-all"
onclick="retryAllFailed()"
style="display:none">
<i class="bi bi-arrow-repeat"></i> Retry All Failed
</button>
</div>
<!-- Per-file cards render here -->
<div id="file-list" class="my-2"></div>
</div>
Then fix the style null error — line 3478 is this line:
javascript
// ❌ fails because display:none!important blocks the style assignment
document.getElementById('upload-summary').style.display = 'flex';
The !important in the inline style fights the JS assignment. Fix by using a CSS class toggle instead:
html
<!-- Add this inside your <style> or <head> -->
<style>
#upload-summary { display: none; }
#upload-summary.visible { display: flex !important; }
</style>
Then in the JS replace every upload-summary style line:
javascript
// Find and replace these two lines in the script:
// ❌ Old — causes null crash if element missing, fights !important
document.getElementById('upload-summary').style.display = 'flex';
document.getElementById('upload-summary').style.display = 'none'; // in updateSummary
// ✅ New — safe null check + class toggle
document.getElementById('upload-summary')?.classList.add('visible');
document.getElementById('upload-summary')?.classList.remove('visible');
Exact locations in the script to update:
javascript
// 1. Inside the 'change' event listener (around line where files are added):
attachInput.addEventListener('change', function () {
Array.from(this.files).forEach(file => {
const id = 'f' + (++fileSeq);
uploadRegistry[id] = {
id, file,
status : 'pending',
pct : 0,
avifName: null,
error : '',
aborted : false,
};
renderFileCard(id);
startUpload(id);
});
this.value = '';
updateSummary();
document.getElementById('upload-summary')?.classList.add('visible'); // ✅ fixed
});
// 2. Inside updateSummary() function:
function updateSummary() {
const all = Object.values(uploadRegistry);
const ok = all.filter(d => d.status === 'success').length;
const fail = all.filter(d => d.status === 'failed').length;
const elTotal = document.getElementById('s-total');
const elOk = document.getElementById('s-ok');
const elFail = document.getElementById('s-fail');
const retryBtn = document.getElementById('btn-retry-all');
const summary = document.getElementById('upload-summary');
if (elTotal) elTotal.textContent = `${all.length} file${all.length !== 1 ? 's' : ''}`;
if (elOk) elOk.textContent = `✔ ${ok} uploaded`;
if (elFail) elFail.textContent = fail ? `✖ ${fail} failed` : '';
if (retryBtn) retryBtn.style.display = fail > 0 ? '' : 'none';
// ✅ fixed — toggle class instead of style
if (summary) {
if (all.length === 0) summary.classList.remove('visible');
else summary.classList.add('visible');
}
}
Quick checklist — verify your HTML has all required IDs
Required IDs in your Razor view:
✅ id="form-create-task" ← your <form> tag
✅ id="taskattachments" ← the file input
✅ id="upload-summary" ← summary bar div ← was missing
✅ id="file-list" ← cards container ← was missing
✅ id="s-total" ← inside upload-summary
✅ id="s-ok" ← inside upload-summary
✅ id="s-fail" ← inside upload-summary
✅ id="btn-retry-all" ← inside upload-summary
✅ id="btnsave" ← your Send Work button
All these must exist in the HTML before the </body> tag where your script runs. The DOMContentLoaded wrapper handles timing, but the elements still need to physically be in the Razor template.
delete file in server path also
May 19
Add a delete route in Node.js and call it from the JS delete button:
Node.js — add delete route:
javascript
// ─── POST /delete-file ────────────────────────────────────────────────────────
// JSON body: { fileName, serverpath, uploadUrl, thumbUrl }
const deleteFile = async (req, res) => {
const { fileName, serverpath, uploadUrl, thumbUrl } = req.body;
if (!fileName || !serverpath || !uploadUrl) {
return res.status(400).json({ ok: false, error: 'Missing required fields' });
}
const results = {
main : { deleted: false, error: null },
thumb: { deleted: false, error: null },
};
// ── Delete main file ────────────────────────────────────────────────────────
try {
const mainPath = path.join(`\\${serverpath}\\${uploadUrl}`, fileName);
logToFile(`🗑 Deleting main: ${mainPath}`);
if (fs.existsSync(mainPath)) {
await fss.unlink(mainPath);
results.main.deleted = true;
logToFile(`✔ Deleted main: ${mainPath}`);
} else {
results.main.error = 'File not found on server';
logToFile(`⚠ Main file not found: ${mainPath}`);
}
} catch (err) {
results.main.error = err.message;
logToFile(`❌ Delete main failed: ${err.message}`);
}
// ── Delete thumb file ───────────────────────────────────────────────────────
if (thumbUrl) {
try {
const thumbPath = path.join(`\\${serverpath}\\${thumbUrl}`, fileName);
logToFile(`🗑 Deleting thumb: ${thumbPath}`);
if (fs.existsSync(thumbPath)) {
await fss.unlink(thumbPath);
results.thumb.deleted = true;
logToFile(`✔ Deleted thumb: ${thumbPath}`);
} else {
results.thumb.error = 'Thumb not found';
logToFile(`⚠ Thumb not found: ${thumbPath}`);
}
} catch (err) {
results.thumb.error = err.message;
logToFile(`❌ Delete thumb failed: ${err.message}`);
}
}
res.json({
ok : results.main.deleted,
results,
});
};
Register the route:
javascript
router.post('/delete-file', express.json(), deleteFile);
JavaScript — update deleteFile function:
javascript
function deleteFile(id) {
const d = uploadRegistry[id];
if (!d) return;
// Mark aborted so any in-progress upload loop stops
d.aborted = true;
// Visually remove card immediately — don't wait for server
document.getElementById(`card-${id}`)?.remove();
delete uploadRegistry[id];
updateSummary();
// ── Only call server delete if file was successfully uploaded ───────────────
// (if still pending/uploading/failed, nothing was saved on server yet)
if (d.status === 'success' && d.avifName) {
deleteFromServer(d.avifName);
}
}
// ── Call Node.js delete endpoint ──────────────────────────────────────────────
async function deleteFromServer(avifName) {
try {
const res = await fetch(`${API_BASE}/delete-file`, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
fileName : avifName,
serverpath: SERVER_PATH,
uploadUrl : UPLOAD_URL,
thumbUrl : THUMB_URL,
}),
});
const data = await res.json();
if (data.ok) {
console.log(`✔ Server deleted: ${avifName}`);
} else {
// Non-critical — file already removed from UI, just log
console.warn(`⚠ Server delete issue for ${avifName}:`, data.results);
}
} catch (err) {
console.warn(`⚠ Server delete failed for ${avifName}:`, err.message);
}
}
What happens for each file state when delete is clicked
status = 'pending' → upload never started → just remove card, no server call
status = 'uploading' → set aborted=true → upload loop stops between chunks
→ chunks are in /tmp only, not NAS
→ just remove card, no server call
status = 'failed' → nothing saved on NAS → just remove card, no server call
status = 'success' → avif saved on NAS → DELETE main + thumb from NAS
→ remove card
The card disappears instantly in all cases — the server delete runs silently in the background for successful uploads only, so the user never waits for it.
also need when network comes failed file chunks automatically upload
May 21
Add a network monitor that watches online/offline events and auto-resumes failed files when connection restores:
javascript
// ── Network monitor — add inside DOMContentLoaded ─────────────────────────────
let isOnline = navigator.onLine;
let autoResumeTimer = null;
// ── Show/hide network banner ──────────────────────────────────────────────────
function showNetworkBanner(online) {
let banner = document.getElementById('network-banner');
if (!banner) {
banner = document.createElement('div');
banner.id = 'network-banner';
banner.style.cssText = `
position : fixed;
bottom : 20px;
left : 50%;
transform : translateX(-50%);
padding : 8px 20px;
border-radius : 99px;
font-size : 13px;
font-weight : 500;
z-index : 9999;
transition: opacity .4s;
display : flex;
align-items: center;
gap : 8px;
`;
document.body.appendChild(banner);
}
if (online) {
banner.style.background = '#198754';
banner.style.color = '#fff';
banner.innerHTML = '<i class="bi bi-wifi"></i> Back online — resuming uploads...';
banner.style.opacity = '1';
// Auto-hide after 3s
setTimeout(() => { banner.style.opacity = '0'; }, 3000);
} else {
banner.style.background = '#dc3545';
banner.style.color = '#fff';
banner.innerHTML = '<i class="bi bi-wifi-off"></i> No network — uploads paused';
banner.style.opacity = '1';
}
}
// ── Network went offline ──────────────────────────────────────────────────────
window.addEventListener('offline', () => {
isOnline = false;
showNetworkBanner(false);
console.warn('📵 Network offline — uploads will resume when back online');
// Mark any currently uploading files as failed so they show retry state
Object.values(uploadRegistry).forEach(d => {
if (d.status === 'uploading') {
d.aborted = true; // stop the current upload loop
d.status = 'failed';
d.error = 'Network lost — will auto-resume';
updateCard(d.id);
}
});
updateSummary();
});
// ── Network came back online ──────────────────────────────────────────────────
window.addEventListener('online', () => {
isOnline = true;
showNetworkBanner(true);
console.log('✅ Network restored');
// Small delay — let the connection stabilise before retrying
clearTimeout(autoResumeTimer);
autoResumeTimer = setTimeout(() => {
autoResumeFailedUploads();
}, 1500);
});
// ── Auto-resume all failed uploads ────────────────────────────────────────────
function autoResumeFailedUploads() {
const failed = Object.values(uploadRegistry).filter(d => d.status === 'failed');
if (failed.length === 0) return;
console.log(`🔄 Auto-resuming ${failed.length} failed upload(s)`);
failed.forEach((d, index) => {
// Stagger restarts so they don't all hammer the server at once
setTimeout(() => {
if (!isOnline) return; // went offline again during stagger
retryFile(d.id);
}, index * 800);
});
}
// ── Periodic check — catches cases where online event doesn't fire ─────────────
// (Some mobile browsers don't fire 'online' reliably)
setInterval(() => {
const currentlyOnline = navigator.onLine;
// Transition: was offline → now online
if (!isOnline && currentlyOnline) {
isOnline = true;
showNetworkBanner(true);
clearTimeout(autoResumeTimer);
autoResumeTimer = setTimeout(autoResumeFailedUploads, 1500);
}
// Transition: was online → now offline
if (isOnline && !currentlyOnline) {
isOnline = false;
showNetworkBanner(false);
}
isOnline = currentlyOnline;
}, 3000); // check every 3 seconds
Also update sendChunkWithRetry to respect isOnline — stops retrying immediately when offline:
javascript
async function sendChunkWithRetry(uploadId, index, total, file, blob, maxRetries) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
// ✅ Stop immediately if offline — no point burning retries
if (!isOnline) {
console.warn(`chunk ${index} skipped — offline`);
return false;
}
try {
const form = new FormData();
form.append('uploadId', uploadId);
form.append('chunkIndex', index);
form.append('totalChunks', total);
form.append('originalname', file.name);
form.append('mimetype', file.type);
form.append('chunk', blob, file.name);
const res = await fetch(`${API_BASE}/upload-chunk`, { method: 'POST', body: form });
if (res.ok) return true;
console.warn(`chunk ${index} attempt ${attempt + 1} HTTP ${res.status}`);
} catch (e) {
console.warn(`chunk ${index} attempt ${attempt + 1}:`, e.message);
// ✅ If fetch throws a network error, check online status immediately
if (!navigator.onLine) {
isOnline = false;
return false;
}
}
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
}
return false;
}
Also update startUpload to check isOnline before starting:
javascript
async function startUpload(id) {
const d = uploadRegistry[id];
// ✅ Don't even start if offline — stays as failed until network returns
if (!isOnline) {
d.status = 'failed';
d.error = 'No network — will auto-resume';
updateCard(id);
updateSummary();
return;
}
// ... rest of startUpload unchanged
}
Full flow
User selects image1.jpg + image2.jpg
→ both start uploading
Network cuts at chunk 3/5 of image2.jpg:
offline event fires
→ banner: "No network — uploads paused"
→ image2.jpg marked failed: "Network lost — will auto-resume"
→ upload loop aborted (aborted = true)
→ image1.jpg (still uploading) also marked failed
Network restores after 30 seconds:
online event fires
→ banner: "Back online — resuming uploads..."
→ autoResumeFailedUploads() called after 1.5s delay
image1.jpg resumes:
GET /chunk-status → server has [0,1,2]
skips 0,1,2 ⏭
sends 3 ✔ 4 ✔
finalize ✔ → avif saved
image2.jpg resumes (800ms later):
GET /chunk-status → server has [0,1,2,3]
skips 0,1,2,3 ⏭
sends 4 ✔
finalize ✔ → avif saved
Both cards → green ✔ Uploaded
"Send Work" button re-enabled
otherwise button click
May 21
Add a manual "Resume Uploads" button that appears when there are failed uploads:
Add this button next to your Send Work button in the form:
html
<div class="col-md-12 text-end">
<a href="#" id="btnproof" value="ProofRequired" class="va-bottom">
<i class="bi bi-file-check"></i> <span>Proof Not Required</span>
</a>
<a href="#" id="btnonetime" value="Onetime" class="va-bottom ms-4">
<i class="bi bi-clock"></i> <span>One Time</span>
</a>
<!-- ✅ Add this resume button -->
<button type="button"
class="btn btn-lg btn-warning ms-4"
id="btn-resume-uploads"
onclick="manualResumeUploads()"
style="display:none">
<i class="bi bi-arrow-repeat"></i> Resume Uploads
</button>
<button type="button"
class="btn btn-lg btn-theme ms-4 modal-submit-btn"
onclick="savetask()"
id="btnsave">
Send Work
</button>
</div>
Add these functions inside your DOMContentLoaded block:
javascript
// ── Show/hide resume button ───────────────────────────────────────────────────
function updateResumeButton() {
const btn = document.getElementById('btn-resume-uploads');
const failed = Object.values(uploadRegistry).filter(d => d.status === 'failed');
const pending = Object.values(uploadRegistry).filter(d => d.status === 'uploading');
if (!btn) return;
// Show only when there are failed files and nothing currently uploading
if (failed.length > 0 && pending.length === 0) {
btn.style.display = '';
btn.innerHTML = `<i class="bi bi-arrow-repeat"></i> Resume ${failed.length} Upload${failed.length > 1 ? 's' : ''}`;
} else {
btn.style.display = 'none';
}
}
// ── Manual resume — triggered by button click ─────────────────────────────────
window.manualResumeUploads = function () {
const failed = Object.values(uploadRegistry).filter(d => d.status === 'failed');
if (failed.length === 0) return;
// Check network first
if (!navigator.onLine) {
showNetworkBanner(false);
alert('Still no network connection. Please check your internet and try again.');
return;
}
const btn = document.getElementById('btn-resume-uploads');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Resuming...';
}
console.log(`▶ Manual resume: ${failed.length} file(s)`);
// Stagger restarts
failed.forEach((d, index) => {
setTimeout(() => {
retryFile(d.id);
}, index * 800);
});
};
Update updateSummary to also call updateResumeButton:
javascript
function updateSummary() {
const all = Object.values(uploadRegistry);
const ok = all.filter(d => d.status === 'success').length;
const fail = all.filter(d => d.status === 'failed').length;
const elTotal = document.getElementById('s-total');
const elOk = document.getElementById('s-ok');
const elFail = document.getElementById('s-fail');
const retryBtn = document.getElementById('btn-retry-all');
const summary = document.getElementById('upload-summary');
if (elTotal) elTotal.textContent = `${all.length} file${all.length !== 1 ? 's' : ''}`;
if (elOk) elOk.textContent = `✔ ${ok} uploaded`;
if (elFail) elFail.textContent = fail ? `✖ ${fail} failed` : '';
if (retryBtn) retryBtn.style.display = fail > 0 ? '' : 'none';
if (summary) {
if (all.length === 0) summary.classList.remove('visible');
else summary.classList.add('visible');
}
// ✅ sync resume button state
updateResumeButton();
}
Update updateCard to reset the resume button when upload finishes:
javascript
function updateCard(id) {
const d = uploadRegistry[id];
const card = document.getElementById(`card-${id}`);
const bar = document.getElementById(`bar-${id}`);
const lbl = document.getElementById(`lbl-${id}`);
const retryBtn = document.getElementById(`btn-retry-${id}`);
if (!card) return;
bar.style.width = d.pct + '%';
bar.className = 'progress-bar';
if (d.status === 'uploading') bar.classList.add('bg-primary','progress-bar-striped','progress-bar-animated');
else if (d.status === 'success') bar.classList.add('bg-success');
else if (d.status === 'failed') bar.classList.add('bg-danger');
card.style.borderColor =
d.status === 'success' ? '#198754' :
d.status === 'failed' ? '#dc3545' : '';
lbl.className = '';
if (d.status === 'uploading') { lbl.className = 'text-primary'; lbl.textContent = d.pct + '%'; }
else if (d.status === 'success') { lbl.className = 'text-success'; lbl.textContent = '✔ Uploaded'; }
else if (d.status === 'failed') { lbl.className = 'text-danger'; lbl.textContent = '✖ ' + (d.error || 'Failed'); }
else { lbl.className = 'text-muted'; lbl.textContent = 'Pending'; }
if (retryBtn) retryBtn.style.display = d.status === 'failed' ? '' : 'none';
// ✅ re-evaluate resume button on every card state change
updateResumeButton();
}
Button states at a glance
All uploading normally:
[Resume Uploads] hidden
[Send Work] disabled (uploads in progress)
Network cuts — files fail:
[Resume Uploads] visible "Resume 2 Uploads"
[Send Work] blocked (failed files present)
User clicks Resume Uploads:
[Resume Uploads] "Resuming..." disabled
→ retryFile() called for each failed file
→ chunks resume from where server left off
All recovered:
[Resume Uploads] hidden again
[Send Work] enabled ✔
when all chunks uploaded taskattachments no files noy presnet why
May 25
The issue is this line that clears the file input after selecting:
javascript
// This runs immediately after files are selected — clears input before upload finishes
this.value = '';
The file input is empty when savetask() runs because you cleared it right away. But that's intentional — you don't want to re-POST the raw files. The real issue is the form still has taskattachments as a required file input, so the browser blocks submit when it's empty.
Fix — three things:
1. Remove required from the file input in your Razor view:
html
<!-- BEFORE -->
<input class="form-control" type="file" multiple id="taskattachments" name="taskattachments" />
<!-- AFTER — remove name so it never posts, validation handled by JS -->
<input class="form-control" type="file" multiple id="taskattachments" />
Removing name means the file input is never included in the form POST at all — the uploaded avif filenames come from the hidden inputs injected by savetask() instead.
2. Update savetask() to validate uploads before submit:
javascript
window.savetask = async function () {
const all = Object.values(uploadRegistry);
const failed = all.filter(d => d.status === 'failed');
const pending = all.filter(d => d.status === 'uploading' || d.status === 'pending');
const success = all.filter(d => d.status === 'success' && d.avifName);
// ── Still uploading ───────────────────────────────────────────────────────
if (pending.length > 0) {
alert(`${pending.length} file(s) still uploading. Please wait.`);
return;
}
// ── Some failed ───────────────────────────────────────────────────────────
if (failed.length > 0) {
alert(`${failed.length} file(s) not uploaded. Please retry or remove them.`);
return;
}
// ── Files were selected but none succeeded ────────────────────────────────
if (all.length > 0 && success.length === 0) {
alert('No files uploaded successfully. Please retry.');
return;
}
const form = document.getElementById('form-create-task');
// Remove any previously injected hidden inputs from a previous submit attempt
form.querySelectorAll('input[name="uploadedFiles"]').forEach(el => el.remove());
// Inject one hidden input per successfully uploaded avif
success.forEach(d => {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = 'uploadedFiles';
inp.value = d.avifName;
form.appendChild(inp);
});
// Log for debug — remove in production
console.log('Submitting with files:', success.map(d => d.avifName));
form.submit();
};
3. Update the change handler — keep files in memory, only clear input value:
javascript
attachInput.addEventListener('change', function () {
if (!this.files || this.files.length === 0) return;
Array.from(this.files).forEach(file => {
// ── Skip if same file already registered (re-select guard) ───────────────
const alreadyExists = Object.values(uploadRegistry)
.some(d => d.file.name === file.name && d.file.size === file.size);
if (alreadyExists) {
console.log(`Skipped duplicate: ${file.name}`);
return;
}
const id = 'f' + (++fileSeq);
uploadRegistry[id] = {
id,
file, // ✅ File object stays in uploadRegistry memory
status : 'pending',
pct : 0,
avifName: null,
error : '',
aborted : false,
};
renderFileCard(id);
startUpload(id);
});
// ✅ Clear input VALUE only — the File objects are safe in uploadRegistry
// This allows the same file to be re-selected if deleted from the list
this.value = '';
updateSummary();
document.getElementById('upload-summary')?.classList.add('visible');
});
ASP.NET Controller — receive the hidden uploadedFiles:
csharp
[HttpPost]
public async Task<IActionResult> TaskCreate(
TaskCreateViewModel model,
[FromForm] List<string> uploadedFiles)
{
// uploadedFiles = ["photo1.avif", "photo2.avif"]
// taskattachments will be null/empty — that's expected
model.Attachments = uploadedFiles ?? new List<string>();
// save to DB...
return RedirectToAction("Index");
}
Why this works
User selects image1.jpg + image2.jpg
↓
change fires:
File objects stored in uploadRegistry[f1].file
uploadRegistry[f2].file
input.value = '' ← input visually cleared
but File objects live in memory ✔
Chunks upload using uploadRegistry[id].file.slice(...)
← reads directly from the File object in memory, not the input
All chunks done → finalize → avifName stored in uploadRegistry
savetask() called:
reads uploadRegistry → finds f1.avifName = "image1.avif"
f2.avifName = "image2.avif"
injects hidden inputs:
<input type="hidden" name="uploadedFiles" value="image1.avif">
<input type="hidden" name="uploadedFiles" value="image2.avif">
form.submit() → POST to /Home/TaskCreate
controller receives: uploadedFiles = ["image1.avif", "image2.avif"] ✔
[Route("/getfolderpath")]
public async Task<IActionResult> GetFolderPath()
{
folderpath = $"{_profile.GetProfilePath(profile.cc, profile._id)}\\work\\{DateTime.Now.ToString("MMyyyy")}";
folderpath_thumb = $"{_profile.GetProfilePath(profile.cc, profile._id)}\\work\\{DateTime.Now.ToString("MMyyyy")}\\thumb";
}
return Ok(new { folderpath, folderpath_thumb });
} get folder and thumbbath getting here
May 25
Get the folder paths from your API before starting upload, then use them in every chunk and finalize call:
Update your JS — add getFolderPaths() and call it on file select:
javascript
// ── Folder paths — fetched once from server ───────────────────────────────────
let SERVER_PATH = '';
let UPLOAD_URL = '';
let THUMB_URL = '';
let pathsLoaded = false;
async function getFolderPaths() {
if (pathsLoaded) return true; // already fetched
try {
const res = await fetch('/getfolderpath');
const data = await res.json();
if (!data.folderpath || !data.folderpath_thumb) {
console.error('Invalid folder path response', data);
return false;
}
// folderpath = "\\NAS\share\work\052025"
// folderpath_thumb = "\\NAS\share\work\052025\thumb"
// Split into serverpath + uploadUrl for the Node upload API
SERVER_PATH = ''; // not needed — full path comes from server
UPLOAD_URL = data.folderpath;
THUMB_URL = data.folderpath_thumb;
pathsLoaded = true;
console.log('✔ Folder paths loaded:', UPLOAD_URL, THUMB_URL);
return true;
} catch (err) {
console.error('getFolderPaths failed:', err.message);
return false;
}
}
Update the change handler to fetch paths first:
javascript
attachInput.addEventListener('change', async function () {
if (!this.files || this.files.length === 0) return;
// ── Disable input while fetching paths ───────────────────────────────────
const btn = document.getElementById('btnsave');
if (btn) { btn.disabled = true; btn.innerText = 'Loading...'; }
// ── Fetch folder paths before any upload starts ───────────────────────────
const ok = await getFolderPaths();
if (!ok) {
alert('Could not load upload folder. Please try again.');
if (btn) { btn.disabled = false; btn.innerText = 'Send Work'; }
this.value = '';
return;
}
if (btn) { btn.disabled = false; btn.innerText = 'Send Work'; }
Array.from(this.files).forEach(file => {
const alreadyExists = Object.values(uploadRegistry)
.some(d => d.file.name === file.name && d.file.size === file.size);
if (alreadyExists) {
console.log(`Skipped duplicate: ${file.name}`);
return;
}
const id = 'f' + (++fileSeq);
uploadRegistry[id] = {
id,
file,
status : 'pending',
pct : 0,
avifName: null,
error : '',
aborted : false,
};
renderFileCard(id);
startUpload(id);
});
this.value = '';
updateSummary();
document.getElementById('upload-summary')?.classList.add('visible');
});
Update startUpload — pass UPLOAD_URL and THUMB_URL into finalize:
javascript
async function startUpload(id) {
const d = uploadRegistry[id];
if (!isOnline) {
d.status = 'failed';
d.error = 'No network — will auto-resume';
updateCard(id);
updateSummary();
return;
}
// ── Guard: paths must be loaded before upload ─────────────────────────────
if (!pathsLoaded) {
const ok = await getFolderPaths();
if (!ok) {
d.status = 'failed';
d.error = 'Could not load folder path';
updateCard(id);
updateSummary();
return;
}
}
const file = d.file;
const RESUME_KEY = `upload_${file.name}_${file.size}`;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
d.status = 'uploading';
updateCard(id);
// ── Resume check ──────────────────────────────────────────────────────────
let uploadId = localStorage.getItem(RESUME_KEY);
let alreadyReceived = new Set();
if (uploadId) {
try {
const res = await fetch(`${API_BASE}/chunk-status?uploadId=${uploadId}`);
const status = await res.json();
if (status.ok && status.canResume) {
status.receivedChunks.forEach(i => alreadyReceived.add(i));
} else {
uploadId = null;
}
} catch { uploadId = null; }
}
if (!uploadId) {
uploadId = crypto.randomUUID().replace(/-/g, '');
localStorage.setItem(RESUME_KEY, uploadId);
}
// ── Round 1: send pending chunks ──────────────────────────────────────────
const failedChunks = [];
for (let i = 0; i < totalChunks; i++) {
if (d.aborted) return;
if (alreadyReceived.has(i)) {
d.pct = Math.round((i + 1) / totalChunks * 100);
updateChunkLabel(id, i + 1, totalChunks, []);
updateCard(id);
continue;
}
const blob = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
const sent = await sendChunkWithRetry(uploadId, i, totalChunks, file, blob, 3);
if (d.aborted) return;
if (!sent) failedChunks.push(i);
d.pct = Math.round((i + 1) / totalChunks * 100);
updateChunkLabel(id, i + 1, totalChunks, failedChunks);
updateCard(id);
}
// ── Round 2: retry failed chunks ──────────────────────────────────────────
if (failedChunks.length > 0 && !d.aborted) {
await new Promise(r => setTimeout(r, 1000));
const stillFailed = [];
for (const i of failedChunks) {
if (d.aborted) return;
const blob = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
const sent = await sendChunkWithRetry(uploadId, i, totalChunks, file, blob, 5);
if (!sent) stillFailed.push(i);
updateChunkLabel(id, totalChunks, totalChunks, stillFailed);
}
if (stillFailed.length > 0) {
d.status = 'failed';
d.error = `Chunks [${stillFailed}] failed`;
updateCard(id);
updateSummary();
return;
}
}
if (d.aborted) return;
// ── Finalize — use paths fetched from /getfolderpath ─────────────────────
try {
const res = await fetch(`${API_BASE}/finalize-upload`, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
uploadId,
originalname: file.name,
mimetype : file.type,
serverpath : '', // empty — full path is in uploadUrl
uploadUrl : UPLOAD_URL, // ✅ from /getfolderpath
thumbUrl : THUMB_URL, // ✅ from /getfolderpath
filetype : 'image',
}),
});
const data = await res.json();
if (data.ok) {
d.status = 'success';
d.pct = 100;
d.avifName = data.result?.fileName;
localStorage.removeItem(RESUME_KEY);
clearChunkLabel(id);
} else {
d.status = 'failed';
d.error = data.error || 'Finalize failed';
}
} catch (err) {
d.status = 'failed';
d.error = err.message;
}
updateCard(id);
updateSummary();
}
Update deleteFromServer — use same paths:
javascript
async function deleteFromServer(avifName) {
try {
const res = await fetch(`${API_BASE}/delete-file`, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
fileName : avifName,
serverpath: '', // empty — full path in uploadUrl
uploadUrl : UPLOAD_URL, // ✅ from /getfolderpath
thumbUrl : THUMB_URL, // ✅ from /getfolderpath
}),
});
const data = await res.json();
if (data.ok) console.log(`✔ Server deleted: ${avifName}`);
else console.warn(`⚠ Server delete issue:`, data.results);
} catch (err) {
console.warn(`⚠ Server delete failed:`, err.message);
}
}
Node.js — update processSingleImage to handle full path in uploadUrl:
javascript
async function processSingleImage(req, body) {
const buffer = req.buffer;
const { uploadUrl, serverpath, filetype, thumbUrl } = body;
const fileName = req.originalname.replace(/\.(jpg|jpeg|png|webp|gif)$/i, '.avif');
try {
// ✅ If serverpath is empty, uploadUrl is already the full path
const SHARE_ROOT = serverpath
? `\\${serverpath}\\${uploadUrl}`
: uploadUrl; // e.g. \\NAS\share\work\052025
const THUMB_ROOT = serverpath
? `\\${serverpath}\\${thumbUrl}`
: thumbUrl; // e.g. \\NAS\share\work\052025\thumb
if (!fs.existsSync(SHARE_ROOT)) {
fs.mkdirSync(SHARE_ROOT, { recursive: true });
}
// ... rest of processSingleImage unchanged
Full flow
User selects files
↓
GET /getfolderpath
← { folderpath: "\\NAS\share\work\052025",
folderpath_thumb: "\\NAS\share\work\052025\thumb" }
UPLOAD_URL = "\\NAS\share\work\052025"
THUMB_URL = "\\NAS\share\work\052025\thumb"
pathsLoaded = true
Chunks upload to Node → POST /upload-chunk (no path needed)
POST /finalize-upload
{ uploadUrl: "\\NAS\share\work\052025",
thumbUrl: "\\NAS\share\work\052025\thumb" }
Node saves:
main → \\NAS\share\work\052025\image1.avif
thumb → \\NAS\share\work\052025\thumb\image1.avif ✔
async function getFolderPaths() { define with in document.addEventListener('DOMContentLoaded', function () { or outside
May 25
Define it outside DOMContentLoaded — here's why and the correct structure:
javascript
// ── Config — only API_BASE here, paths come from server ──────────────────────
const CHUNK_SIZE = 2 * 1024 * 1024;
const API_BASE = 'https://your-node-server.com';
// ── Folder paths — filled by getFolderPaths() ─────────────────────────────────
let SERVER_PATH = '';
let UPLOAD_URL = '';
let THUMB_URL = '';
let pathsLoaded = false;
// ── Upload state ──────────────────────────────────────────────────────────────
const uploadRegistry = {};
let fileSeq = 0;
let isOnline = navigator.onLine;
let autoResumeTimer = null;
// ─────────────────────────────────────────────────────────────────────────────
// OUTSIDE DOMContentLoaded — can be called from anywhere (retry, resume, etc.)
// ─────────────────────────────────────────────────────────────────────────────
async function getFolderPaths() {
if (pathsLoaded) return true;
try {
const res = await fetch('/getfolderpath');
const data = await res.json();
if (!data.folderpath || !data.folderpath_thumb) {
console.error('Invalid folder path response', data);
return false;
}
UPLOAD_URL = data.folderpath;
THUMB_URL = data.folderpath_thumb;
pathsLoaded = true;
console.log('✔ Paths loaded:', UPLOAD_URL, THUMB_URL);
return true;
} catch (err) {
console.error('getFolderPaths failed:', err.message);
return false;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// INSIDE DOMContentLoaded — only DOM-dependent code goes here
// ─────────────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', function () {
const attachInput = document.getElementById('taskattachments');
if (!attachInput) return;
attachInput.addEventListener('change', async function () {
if (!this.files || this.files.length === 0) return;
// ✅ getFolderPaths() accessible here because it's in outer scope
const ok = await getFolderPaths();
if (!ok) {
alert('Could not load upload folder. Please try again.');
this.value = '';
return;
}
Array.from(this.files).forEach(file => {
const alreadyExists = Object.values(uploadRegistry)
.some(d => d.file.name === file.name && d.file.size === file.size);
if (alreadyExists) return;
const id = 'f' + (++fileSeq);
uploadRegistry[id] = {
id, file,
status : 'pending',
pct : 0,
avifName: null,
error : '',
aborted : false,
};
renderFileCard(id);
startUpload(id);
});
this.value = '';
updateSummary();
document.getElementById('upload-summary')?.classList.add('visible');
});
// all other functions: renderFileCard, updateCard,
// startUpload, deleteFile, retryFile ... unchanged
});
The rule
Outside DOMContentLoaded:
✅ getFolderPaths() — needs no DOM, called from multiple places
✅ uploadRegistry — shared state
✅ CHUNK_SIZE, API_BASE — constants
✅ isOnline, fileSeq — shared variables
✅ sendChunkWithRetry() — pure async, no DOM
✅ fmtSize() — pure helper
Inside DOMContentLoaded:
✅ renderFileCard() — touches getElementById
✅ updateCard() — touches getElementById
✅ updateSummary() — touches getElementById
✅ startUpload() — calls renderFileCard / updateCard
✅ deleteFile() — removes DOM element
✅ retryFile() — calls updateCard
✅ event listeners — need DOM elements to exist first
✅ window.savetask — uses form.submit()
await is only valid in async functions and the top level bodies of modules
May 25
The error happens because DOMContentLoaded callback is not async. Fix:
javascript
// ❌ Wrong
document.addEventListener('DOMContentLoaded', function () {
const ok = await getFolderPaths(); // ← error here
});
// ✅ Fix — add async to the callback
document.addEventListener('DOMContentLoaded', async function () {
const ok = await getFolderPaths();
});
But in your case the await is inside the change handler, so make sure both are async:
javascript
document.addEventListener('DOMContentLoaded', async function () { // ✅ async
const attachInput = document.getElementById('taskattachments');
if (!attachInput) return;
attachInput.addEventListener('change', async function () { // ✅ async
if (!this.files || this.files.length === 0) return;
const ok = await getFolderPaths(); // ✅ valid now
if (!ok) {
alert('Could not load upload folder. Please try again.');
this.value = '';
return;
}
Array.from(this.files).forEach(file => {
const alreadyExists = Object.values(uploadRegistry)
.some(d => d.file.name === file.name && d.file.size === file.size);
if (alreadyExists) return;
const id = 'f' + (++fileSeq);
uploadRegistry[id] = {
id, file,
status : 'pending',
pct : 0,
avifName: null,
error : '',
aborted : false,
};
renderFileCard(id);
startUpload(id);
});
this.value = '';
updateSummary();
document.getElementById('upload-summary')?.classList.add('visible');
});
// rest of your functions...
});
Quick reference — where async is needed
javascript
// ✅ Top level async functions — always fine
async function getFolderPaths() { await fetch(...) }
async function startUpload(id) { await fetch(...) }
// ✅ Event callbacks — must add async if using await inside
document.addEventListener('DOMContentLoaded', async function () { await ... });
attachInput.addEventListener('change', async function () { await ... });
window.addEventListener('online', async function () { await ... });
// ✅ Arrow function version — same rule
attachInput.addEventListener('change', async (e) => { await ... });
// ✅ window.savetask — already async
window.savetask = async function () { ... };
// ✅ manualResumeUploads — add async if it awaits getFolderPaths
window.manualResumeUploads = async function () {
const ok = await getFolderPaths();
...
};
[Route("/savefileuploadlog")]
public async Task<IActionResult> SaveFileUploadLog(string folderpath, string folderpath_thumb, string filename, string type)
{
return Ok(new { folderpath, folderpath_thumb });
} call each file upload success
May 25
Call /savefileuploadlog after each file successfully finalizes:
Update startUpload — add the log call after finalize success:
javascript
// ── After finalize success, save the log ─────────────────────────────────────
if (data.ok) {
d.status = 'success';
d.pct = 100;
d.avifName = data.result?.fileName;
localStorage.removeItem(RESUME_KEY);
clearChunkLabel(id);
// ✅ Call log API after each successful upload
await saveFileUploadLog(d.avifName, file.type);
}
Add saveFileUploadLog function outside DOMContentLoaded:
javascript
async function saveFileUploadLog(fileName, fileType) {
try {
const params = new URLSearchParams({
folderpath : UPLOAD_URL,
folderpath_thumb: THUMB_URL,
filename : fileName,
type : fileType,
});
const res = await fetch(`/savefileuploadlog?${params}`);
const data = await res.json();
console.log(`✔ Log saved: ${fileName}`, data);
return true;
} catch (err) {
// Non-critical — don't fail the upload if log fails
console.warn(`⚠ Log failed for ${fileName}:`, err.message);
return false;
}
}
Full startUpload finalize block with log call:
javascript
// ── Finalize ───────────────────────────────────────────────────────────────
try {
const res = await fetch(`${API_BASE}/finalize-upload`, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
uploadId,
originalname: file.name,
mimetype : file.type,
serverpath : '',
uploadUrl : UPLOAD_URL,
thumbUrl : THUMB_URL,
filetype : 'image',
}),
});
const data = await res.json();
if (data.ok) {
d.status = 'success';
d.pct = 100;
d.avifName = data.result?.fileName;
localStorage.removeItem(RESUME_KEY);
clearChunkLabel(id);
// ✅ Log each successful upload individually
await saveFileUploadLog(d.avifName, file.type);
} else {
d.status = 'failed';
d.error = data.error || 'Finalize failed';
}
} catch (err) {
d.status = 'failed';
d.error = err.message;
}
updateCard(id);
updateSummary();
ASP.NET — update the controller to actually save the log:
csharp
[Route("/savefileuploadlog")]
public async Task<IActionResult> SaveFileUploadLog(
string folderpath,
string folderpath_thumb,
string filename,
string type)
{
// Save to DB
var log = new FileUploadLog
{
FolderPath = folderpath,
FolderPathThumb = folderpath_thumb,
FileName = filename,
FileType = type,
UploadedAt = DateTime.UtcNow,
UploadedBy = profile._id,
};
await _db.FileUploadLogs.AddAsync(log);
await _db.SaveChangesAsync();
return Ok(new { ok = true, folderpath, folderpath_thumb, filename });
}
Flow per file
image1.jpg chunks upload → finalize ✔
→ POST /savefileuploadlog
?folderpath=\\NAS\share\work\052025
&folderpath_thumb=\\NAS\share\work\052025\thumb
&filename=image1.avif
&type=image/jpeg
← { ok: true }
card → green ✔ Uploaded
image2.jpg chunks upload → finalize ✔
→ POST /savefileuploadlog
?filename=image2.avif ...
← { ok: true }
card → green ✔ Uploaded
savetask() called:
hidden inputs: image1.avif, image2.avif
form.submit() → POST /Home/TaskCreate
create thump file to pdf and video files
May 28
Update processSingleImage in Node.js to handle PDF and video thumbnails:
Install required packages:
bash
npm install sharp pdf-poppler fluent-ffmpeg
npm install @ffmpeg-installer/ffmpeg
Node.js — updated processSingleImage:
javascript
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
const fss = require('fs').promises;
const pdf = require('pdf-poppler');
const ffmpeg = require('fluent-ffmpeg');
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg');
// Set ffmpeg path
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
async function processSingleImage(req, body) {
logToFile(`processSingleImage IN ${new Date().toISOString()}`);
const buffer = req.buffer;
const mimetype = req.mimetype;
const { uploadUrl, serverpath, filetype, thumbUrl } = body;
// ── Build paths ─────────────────────────────────────────────────────────────
const SHARE_ROOT = serverpath ? `\\${serverpath}\\${uploadUrl}` : uploadUrl;
const THUMB_ROOT = serverpath ? `\\${serverpath}\\${thumbUrl}` : thumbUrl;
if (!fs.existsSync(SHARE_ROOT)) fs.mkdirSync(SHARE_ROOT, { recursive: true });
if (!fs.existsSync(THUMB_ROOT)) fs.mkdirSync(THUMB_ROOT, { recursive: true });
logToFile(`mimetype: ${mimetype}`);
try {
// ────────────────────────────────────────────────────────────────────────
// IMAGE
// ────────────────────────────────────────────────────────────────────────
if (mimetype.includes('image')) {
return await processImage(buffer, req.originalname, SHARE_ROOT, THUMB_ROOT);
}
// ────────────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────────────
if (mimetype === 'application/pdf') {
return await processPdf(buffer, req.originalname, SHARE_ROOT, THUMB_ROOT);
}
// ────────────────────────────────────────────────────────────────────────
// VIDEO
// ────────────────────────────────────────────────────────────────────────
if (mimetype.startsWith('video/')) {
return await processVideo(buffer, req.originalname, SHARE_ROOT, THUMB_ROOT);
}
// ────────────────────────────────────────────────────────────────────────
// OTHER — save raw
// ────────────────────────────────────────────────────────────────────────
await fss.writeFile(path.join(SHARE_ROOT, req.originalname), buffer);
return { fileName: req.originalname, status: 'success' };
} catch (err) {
logToFile(`❌ processSingleImage: ${err.stack || err.message}`);
return { fileName: req.originalname, status: 'failed', error: err.message };
}
}
// ─────────────────────────────────────────────────────────────────────────────
// IMAGE processor
// ─────────────────────────────────────────────────────────────────────────────
async function processImage(buffer, originalname, SHARE_ROOT, THUMB_ROOT) {
const fileName = originalname.replace(/\.(jpg|jpeg|png|webp|gif)$/i, '.avif');
sharp.concurrency(4);
sharp.cache(false);
const meta = await sharp(buffer, { limitInputPixels: false }).metadata();
const imgl = resizeByMax(Math.trunc(meta.width), Math.trunc(meta.height), 1500);
let pipeline = sharp(buffer, { limitInputPixels: false });
if (meta.hasAlpha) pipeline = pipeline.flatten({ background: '#fff' });
// Main image → avif
const resizedBuffer = await pipeline
.toColourspace('srgb')
.resize(imgl.width, imgl.height, { fit: 'inside', withoutEnlargement: true })
.avif({ quality: 45, effort: 2 })
.toBuffer();
await fss.writeFile(path.join(SHARE_ROOT, fileName), resizedBuffer);
logToFile(`✔ Image saved: ${fileName}`);
// Thumb → avif 200×200
const thumbBuffer = await sharp(resizedBuffer, { limitInputPixels: false })
.resize(200, 200, { fit: 'cover', position: 'centre' })
.avif({ quality: 40, effort: 2 })
.toBuffer();
await fss.writeFile(path.join(THUMB_ROOT, fileName), thumbBuffer);
logToFile(`✔ Image thumb saved: ${fileName}`);
return { fileName, status: 'success' };
}
// ─────────────────────────────────────────────────────────────────────────────
// PDF processor
// ─────────────────────────────────────────────────────────────────────────────
async function processPdf(buffer, originalname, SHARE_ROOT, THUMB_ROOT) {
const fileName = originalname; // keep .pdf
const thumbFileName = originalname.replace(/\.pdf$/i, '.avif'); // thumb as avif
const tmpDir = path.join(require('os').tmpdir(), `pdf_${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
try {
// Save PDF buffer to temp so pdf-poppler can read it
const tmpPdf = path.join(tmpDir, originalname);
await fss.writeFile(tmpPdf, buffer);
// Save original PDF to share root
await fss.writeFile(path.join(SHARE_ROOT, fileName), buffer);
logToFile(`✔ PDF saved: ${fileName}`);
// ── Convert first page to PNG using pdf-poppler ─────────────────────────
const opts = {
format : 'png',
out_dir : tmpDir,
out_prefix : 'page',
page : 1, // first page only for thumb
scale : 400, // render width
};
await pdf.convert(tmpPdf, opts);
// pdf-poppler outputs: page-1.png
const renderedPage = path.join(tmpDir, 'page-1.png');
if (!fs.existsSync(renderedPage)) {
throw new Error('PDF page render failed — page-1.png not found');
}
// ── Convert rendered PNG → avif thumb ──────────────────────────────────
const thumbBuffer = await sharp(renderedPage)
.resize(200, 200, { fit: 'cover', position: 'centre' })
.avif({ quality: 40, effort: 2 })
.toBuffer();
await fss.writeFile(path.join(THUMB_ROOT, thumbFileName), thumbBuffer);
logToFile(`✔ PDF thumb saved: ${thumbFileName}`);
return { fileName, thumbFileName, status: 'success' };
} finally {
// Clean up temp folder
await fss.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
// ─────────────────────────────────────────────────────────────────────────────
// VIDEO processor
// ─────────────────────────────────────────────────────────────────────────────
async function processVideo(buffer, originalname, SHARE_ROOT, THUMB_ROOT) {
const fileName = originalname; // keep original .mp4 etc
const thumbFileName = originalname.replace(/\.[^.]+$/, '.avif'); // thumb as avif
const tmpDir = path.join(require('os').tmpdir(), `vid_${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
try {
// Save video buffer to temp so ffmpeg can read it
const tmpVideo = path.join(tmpDir, originalname);
await fss.writeFile(tmpVideo, buffer);
// Save original video to share root
await fss.writeFile(path.join(SHARE_ROOT, fileName), buffer);
logToFile(`✔ Video saved: ${fileName}`);
// ── Extract frame at 1 second using ffmpeg ──────────────────────────────
const tmpFrame = path.join(tmpDir, 'frame.png');
await new Promise((resolve, reject) => {
ffmpeg(tmpVideo)
.on('end', resolve)
.on('error', reject)
.screenshots({
timestamps : ['00:00:01'], // grab frame at 1 second
filename : 'frame.png',
folder : tmpDir,
size : '400x?', // width 400, auto height
});
});
if (!fs.existsSync(tmpFrame)) {
// Fallback — try frame at 0 seconds if video is very short
await new Promise((resolve, reject) => {
ffmpeg(tmpVideo)
.on('end', resolve)
.on('error', reject)
.screenshots({
timestamps : ['00:00:00'],
filename : 'frame.png',
folder : tmpDir,
size : '400x?',
});
});
}
if (!fs.existsSync(tmpFrame)) {
throw new Error('Video frame extraction failed');
}
// ── Convert extracted frame → avif thumb ───────────────────────────────
const thumbBuffer = await sharp(tmpFrame)
.resize(200, 200, { fit: 'cover', position: 'centre' })
.avif({ quality: 40, effort: 2 })
.toBuffer();
await fss.writeFile(path.join(THUMB_ROOT, thumbFileName), thumbBuffer);
logToFile(`✔ Video thumb saved: ${thumbFileName}`);
// ── Get video duration for logging ─────────────────────────────────────
const duration = await getVideoDuration(tmpVideo);
logToFile(`✔ Video duration: ${duration}s`);
return { fileName, thumbFileName, duration, status: 'success' };
} finally {
await fss.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Get video duration helper
// ─────────────────────────────────────────────────────────────────────────────
function getVideoDuration(filePath) {
return new Promise((resolve) => {
ffmpeg.ffprobe(filePath, (err, metadata) => {
if (err) { resolve(0); return; }
resolve(Math.round(metadata.format.duration || 0));
});
});
}
Register routes — unchanged, processSingleImage handles all types now:
javascript
router.post('/upload-chunk', upload.array('chunk', 1), uploadChunk);
router.post('/finalize-upload', express.json(), finalizeUpload);
router.post('/delete-file', express.json(), deleteFile);
Update multer limits to allow larger video files:
javascript
const upload = multer({
storage: multer.memoryStorage(),
limits : { fileSize: 500 * 1024 * 1024 }, // 500 MB for videos
});
JS — update CHUNK_SIZE for video files and saveFileUploadLog to pass file type:
javascript
// ── Use larger chunks for video ───────────────────────────────────────────────
function getChunkSize(fileType) {
if (fileType.startsWith('video/')) return 5 * 1024 * 1024; // 5 MB for video
if (fileType === 'application/pdf') return 2 * 1024 * 1024; // 2 MB for pdf
return 2 * 1024 * 1024; // 2 MB for image
}
// ── In startUpload — use dynamic chunk size ───────────────────────────────────
async function startUpload(id) {
const d = uploadRegistry[id];
const file = d.file;
const CHUNK_SIZE = getChunkSize(file.type); // ✅ dynamic per file type
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
// ... rest unchanged
}
Update renderFileCard to show correct icon per file type:
javascript
function getFileIcon(file) {
if (file.type.startsWith('image/')) return '<i class="bi bi-image text-primary fs-4"></i>';
if (file.type === 'application/pdf') return '<i class="bi bi-file-pdf text-danger fs-4"></i>';
if (file.type.startsWith('video/')) return '<i class="bi bi-camera-video text-success fs-4"></i>';
return '<i class="bi bi-file-earmark text-secondary fs-4"></i>';
}
function renderFileCard(id) {
const d = uploadRegistry[id];
const isImg = d.file.type.startsWith('image/');
const thumbHtml = isImg
? `<img src="${URL.createObjectURL(d.file)}"
style="width:54px;height:54px;object-fit:cover;border-radius:6px;flex-shrink:0" alt="">`
: `<div style="width:54px;height:54px;background:#f0f0f0;border-radius:6px;
display:flex;align-items:center;justify-content:center;flex-shrink:0">
${getFileIcon(d.file)}
</div>`;
// ... rest of renderFileCard unchanged
}
What each type produces
image.jpg → main: image.avif (max 1500px)
thumb: image.avif (200×200 cover)
document.pdf → main: document.pdf (original)
thumb: document.avif (first page → 200×200 cover)
video.mp4 → main: video.mp4 (original)
thumb: video.avif (frame at 1s → 200×200 cover)
function renderFileCard(id) {
const container = document.getElementById('file-list');
if (!container) {
console.warn('file-list not found');
return;
}
const d = uploadRegistry[id];
const isImg = d.file.type.startsWith('image/');
// const thumbHtml = isImg
// ? <img src="${URL.createObjectURL(d.file)}"
// style="width:54px;height:54px;object-fit:cover;border-radius:6px;flex-shrink:0"
// alt="">
// : <div style="width:54px;height:54px;background:#f0f0f0;border-radius:6px;
// display:flex;align-items:center;justify-content:center;flex-shrink:0">
// <i class="bi bi-file-earmark fs-4 text-secondary"></i>
// </div>;
const thumbHtml = isImg
? <img src="${URL.createObjectURL(d.file)}"
style="width:54px;height:54px;object-fit:cover;border-radius:6px;flex-shrink:0" alt="">
: <div style="width:54px;height:54px;background:#f0f0f0;border-radius:6px;
display:flex;align-items:center;justify-content:center;flex-shrink:0">
${getFileIcon(d.file)}
</div>;
const card = document.createElement('div');
card.id = card-${id};
card.className = 'border rounded p-2 mb-2 d-flex align-items-center gap-2';
card.style.cssText = 'background:#fff;transition:border-color .2s;';
card.innerHTML =
${thumbHtml}
<div style="flex:1;min-width:0">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-truncate fw-500"
style="font-size:13px;max-width:240px"
title="${d.file.name}">${d.file.name}</span>
<span class="text-muted"
style="font-size:11px;flex-shrink:0;margin-left:8px">
${fmtSize(d.file.size)}
</span>
</div>
<div class="progress mb-1" style="height:5px;">
<div class="progress-bar" id="bar-${id}"
style="width:0%;transition:width .3s;"></div>
</div>
<div class="d-flex justify-content-between">
<span id="lbl-${id}" style="font-size:11px;" class="text-muted">Pending</span>
<span id="chunk-lbl-${id}" style="font-size:11px;color:#888"></span>
</div>
</div>
<div class="d-flex flex-column gap-1" style="flex-shrink:0">
<button type="button" id="btn-retry-${id}"
class="btn btn-sm btn-outline-warning py-0 px-2"
style="display:none;font-size:11px">
<i class="bi bi-arrow-repeat"></i> Retry
</button>
<button type="button"
class="btn btn-sm btn-outline-danger py-0 px-2"
style="font-size:11px">
<i class="bi bi-trash"></i>
</button>
</div>;
// ✅ Attach click handlers after innerHTML set — avoids inline onclick
// scope issues inside DOMContentLoaded
card.querySelector(#btn-retry-${id})
.addEventListener('click', () => retryFile(id));
card.querySelectorAll('.btn-outline-danger')[0]
.addEventListener('click', () => deleteFile(id));
container.appendChild(card);
} thum not loaded
Show more
May 28
The issue is URL.createObjectURL works only for images. For PDF and video you need different preview approaches:
javascript
function getFileIcon(file) {
if (file.type.startsWith('image/')) return '<i class="bi bi-image text-primary" style="font-size:24px"></i>';
if (file.type === 'application/pdf') return '<i class="bi bi-file-pdf text-danger" style="font-size:24px"></i>';
if (file.type.startsWith('video/')) return '<i class="bi bi-camera-video text-success" style="font-size:24px"></i>';
return '<i class="bi bi-file-earmark text-secondary" style="font-size:24px"></i>';
}
function getThumbBg(file) {
if (file.type === 'application/pdf') return '#fff0f0';
if (file.type.startsWith('video/')) return '#f0fff4';
return '#f0f0f0';
}
function renderFileCard(id) {
const container = document.getElementById('file-list');
if (!container) { console.warn('file-list not found'); return; }
const d = uploadRegistry[id];
const file = d.file;
const isImg = file.type.startsWith('image/');
const isVid = file.type.startsWith('video/');
// ── Build thumb HTML based on file type ──────────────────────────────────
let thumbHtml = '';
if (isImg) {
// ✅ Image — createObjectURL works fine
thumbHtml = `
<img src="${URL.createObjectURL(file)}"
style="width:54px;height:54px;object-fit:cover;border-radius:6px;flex-shrink:0"
alt="">`;
} else if (isVid) {
// ✅ Video — use canvas to extract first frame
thumbHtml = `
<div id="thumb-${id}"
style="width:54px;height:54px;background:${getThumbBg(file)};border-radius:6px;
display:flex;align-items:center;justify-content:center;flex-shrink:0;
position:relative;overflow:hidden;">
${getFileIcon(file)}
</div>`;
} else {
// ✅ PDF and others — icon only
thumbHtml = `
<div style="width:54px;height:54px;background:${getThumbBg(file)};border-radius:6px;
display:flex;align-items:center;justify-content:center;flex-shrink:0;">
${getFileIcon(file)}
</div>`;
}
const card = document.createElement('div');
card.id = `card-${id}`;
card.className = 'border rounded p-2 mb-2 d-flex align-items-center gap-2';
card.style.cssText = 'background:#fff;transition:border-color .2s;';
card.innerHTML = `
${thumbHtml}
<div style="flex:1;min-width:0">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-truncate fw-500"
style="font-size:13px;max-width:240px"
title="${file.name}">${file.name}</span>
<span class="text-muted" style="font-size:11px;flex-shrink:0;margin-left:8px">
${fmtSize(file.size)}
</span>
</div>
<div class="progress mb-1" style="height:5px;">
<div class="progress-bar" id="bar-${id}" style="width:0%;transition:width .3s;"></div>
</div>
<div class="d-flex justify-content-between">
<span id="lbl-${id}" style="font-size:11px;" class="text-muted">Pending</span>
<span id="chunk-lbl-${id}" style="font-size:11px;color:#888"></span>
</div>
</div>
<div class="d-flex flex-column gap-1" style="flex-shrink:0">
<button type="button" id="btn-retry-${id}"
class="btn btn-sm btn-outline-warning py-0 px-2"
style="display:none;font-size:11px">
<i class="bi bi-arrow-repeat"></i> Retry
</button>
<button type="button"
class="btn btn-sm btn-outline-danger py-0 px-2"
style="font-size:11px">
<i class="bi bi-trash"></i>
</button>
</div>`;
card.querySelector(`#btn-retry-${id}`)
.addEventListener('click', () => retryFile(id));
card.querySelectorAll('.btn-outline-danger')[0]
.addEventListener('click', () => deleteFile(id));
container.appendChild(card);
// ✅ Extract video frame AFTER card is in DOM
if (isVid) {
extractVideoFrame(file, id);
}
}
// ── Extract first frame from video using canvas ───────────────────────────────
function extractVideoFrame(file, id) {
const url = URL.createObjectURL(file);
const video = document.createElement('video');
video.src = url;
video.muted = true;
video.preload = 'metadata';
video.addEventListener('loadeddata', function () {
// Seek to 1 second or 10% of duration, whichever is smaller
video.currentTime = Math.min(1, video.duration * 0.1);
});
video.addEventListener('seeked', function () {
try {
const canvas = document.createElement('canvas');
canvas.width = 54;
canvas.height = 54;
const ctx = canvas.getContext('2d');
// Crop centre of frame into 54×54
const vw = video.videoWidth;
const vh = video.videoHeight;
const side = Math.min(vw, vh);
const sx = (vw - side) / 2;
const sy = (vh - side) / 2;
ctx.drawImage(video, sx, sy, side, side, 0, 0, 54, 54);
const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
const thumbDiv = document.getElementById(`thumb-${id}`);
if (thumbDiv) {
thumbDiv.innerHTML = `
<img src="${dataUrl}"
style="width:54px;height:54px;object-fit:cover;border-radius:6px;"
alt="">
<div style="position:absolute;bottom:2px;right:2px;
background:rgba(0,0,0,0.55);border-radius:3px;
padding:1px 3px;font-size:9px;color:#fff;">
<i class="bi bi-play-fill"></i>
</div>`;
}
} catch (e) {
console.warn('Frame extract failed:', e.message);
} finally {
URL.revokeObjectURL(url);
}
});
video.addEventListener('error', function () {
console.warn('Video load error for thumb');
URL.revokeObjectURL(url);
});
// Must call load() to start loading in some browsers
video.load();
}
What each type shows
image.jpg → actual image preview (URL.createObjectURL)
video.mp4 → real first frame (canvas frame extract)
+ small ▶ play badge overlay
document.pdf → red PDF icon on light red background
other files → grey file icon
The video frame extraction works by creating a hidden <video> element, seeking to 1 second, then painting that frame onto a <canvas> and converting it to a data: URL — no server round-trip needed.
pdf thump created but thumbHtml =
<div style="width:54px;height:54px;background:${getThumbBg(file)};border-radius:6px;
display:flex;align-items:center;justify-content:center;flex-shrink:0;">
${getFileIcon(file)}
</div>; not updated
12:42 PM
The PDF thumb div has no id so you can't update it after the server returns the avif. Fix by adding an id and updating it after finalize:
Update renderFileCard — add id to PDF thumb div:
javascript
} else {
// ✅ PDF and others — icon with id so we can swap it later
thumbHtml = `
<div id="thumb-${id}"
style="width:54px;height:54px;background:${getThumbBg(file)};border-radius:6px;
display:flex;align-items:center;justify-content:center;flex-shrink:0;">
${getFileIcon(file)}
</div>`;
}
Add updateThumb function — call after finalize success:
javascript
function updateThumb(id) {
const d = uploadRegistry[id];
const thumbDiv = document.getElementById(`thumb-${id}`);
if (!thumbDiv || !d.avifName) return;
const isImg = d.file.type.startsWith('image/');
const isVid = d.file.type.startsWith('video/');
// Images already show local preview — no update needed
if (isImg) return;
// ── Build thumb URL pointing to Node server thumb folder ─────────────────
// Adjust this URL pattern to match how your Node server serves static files
const thumbUrl = `${API_BASE}/thumb/${d.avifName}`;
thumbDiv.innerHTML = `
<img src="${thumbUrl}"
style="width:54px;height:54px;object-fit:cover;border-radius:6px;"
alt=""
onerror="this.parentElement.innerHTML='${getFileIcon(d.file).replace(/'/g, "\\'")}'">
${isVid
? `<div style="position:absolute;bottom:2px;right:2px;
background:rgba(0,0,0,0.55);border-radius:3px;
padding:1px 3px;font-size:9px;color:#fff;">
<i class="bi bi-play-fill"></i>
</div>`
: ''}`;
}
Update startUpload — call updateThumb after finalize success:
javascript
if (data.ok) {
d.status = 'success';
d.pct = 100;
d.avifName = data.result?.fileName;
localStorage.removeItem(RESUME_KEY);
clearChunkLabel(id);
await saveFileUploadLog(d.avifName, d.file.type);
// ✅ Update thumb for PDF and video after server generates it
updateThumb(id);
}
Node.js — serve thumb folder as static so the browser can load it:
javascript
// In your Express app setup
const express = require('express');
const app = express();
// Serve thumbs as static files
// THUMB_ROOT is your actual NAS thumb path
app.use('/thumb', express.static('\\\\NAS\\share\\work\\052025\\thumb'));
// OR if path is dynamic — add a dedicated thumb route
app.get('/thumb/:filename', (req, res) => {
const thumbPath = path.join(THUMB_ROOT, req.params.filename);
if (!fs.existsSync(thumbPath)) {
return res.status(404).json({ error: 'Thumb not found' });
}
res.setHeader('Content-Type', 'image/avif');
res.sendFile(thumbPath);
});
If your thumb path is dynamic (changes per user/date), pass thumbUrl back from finalize and store it:
javascript
// Node.js — finalizeUpload: include thumbUrl in response
res.json({
ok: true,
result: {
fileName : result.fileName,
thumbFileName: result.thumbFileName,
thumbUrl : thumbUrl, // ✅ full thumb folder path
}
});
javascript
// JS — store thumbFileName separately
if (data.ok) {
d.status = 'success';
d.pct = 100;
d.avifName = data.result?.fileName;
d.thumbFileName = data.result?.thumbFileName ?? data.result?.fileName;
d.thumbUrl = data.result?.thumbUrl;
localStorage.removeItem(RESUME_KEY);
clearChunkLabel(id);
await saveFileUploadLog(d.avifName, d.file.type);
updateThumb(id);
}
javascript
// Update updateThumb to use stored thumbUrl
function updateThumb(id) {
const d = uploadRegistry[id];
const thumbDiv = document.getElementById(`thumb-${id}`);
if (!thumbDiv) return;
const isImg = d.file.type.startsWith('image/');
const isVid = d.file.type.startsWith('video/');
if (isImg) return;
if (!d.avifName) return;
// ✅ Use thumbFileName (pdf/video may differ from main fileName)
const thumbFile = d.thumbFileName || d.avifName;
const thumbSrc = `${API_BASE}/thumb/${thumbFile}`;
thumbDiv.style.position = 'relative'; // needed for video play badge
thumbDiv.style.overflow = 'hidden';
thumbDiv.innerHTML = `
<img src="${thumbSrc}"
style="width:54px;height:54px;object-fit:cover;border-radius:6px;display:block;"
alt="${d.file.name}"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
<div style="display:none;width:54px;height:54px;align-items:center;
justify-content:center;border-radius:6px;
background:${getThumbBg(d.file)}">
${getFileIcon(d.file)}
</div>
${isVid
? `<div style="position:absolute;bottom:2px;right:2px;
background:rgba(0,0,0,0.55);border-radius:3px;
padding:1px 3px;font-size:9px;color:#fff;pointer-events:none;">
<i class="bi bi-play-fill"></i>
</div>`
: ''}`;
}
Full flow
PDF selected:
card renders → shows red PDF icon (icon placeholder)
Chunks upload → finalize → Node generates thumb:
\\NAS\share\work\052025\thumb\document.avif ✔
JS receives finalize response:
d.thumbFileName = "document.avif"
updateThumb(id) called
→ thumbDiv.innerHTML updated
→ <src="/thumb/document.avif"> ← real PDF first-page preview ✔
→ if load fails → falls back to PDF icon
Video selected:
card renders → canvas extracts frame immediately (client side)
Chunks upload → finalize → Node generates thumb from ffmpeg frame
updateThumb(id) → swaps canvas frame with server avif thumb ✔
Sign up here with your email
1 comments:
Write commentsnice very useful
ReplyConversionConversion EmoticonEmoticon