diff --git a/src/main.cpp b/src/main.cpp index 204673d..18f6c04 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -330,7 +330,9 @@ void handleMoveFile(AsyncWebServerRequest *request) if (SD.exists(from)) { + sd_lock_acquire(); SD.rename(from, to); + sd_lock_release(); Serial.println("Moved file: " + from + " to " + to); // Rebuild directory tree to update file list rootNode.buildDirectoryTree("/"); @@ -351,7 +353,9 @@ void handleDeleteFile(AsyncWebServerRequest *request) if (SD.exists(filename)) { + sd_lock_acquire(); SD.remove(filename.c_str()); + sd_lock_release(); Serial.println("Deleted file: " + filename); // Rebuild directory tree to update file list rootNode.buildDirectoryTree("/"); @@ -800,6 +804,8 @@ void saveMappingToFile(const String filename) // Function to handle edit requests void editMapping(AsyncWebServerRequest *request) { + webreq_enter(); + request->onDisconnect([](){ webreq_exit(); }); if (request->hasParam("rfid", true) && request->hasParam("song", true)) { String rfid = request->getParam("rfid", true)->value(); @@ -1166,6 +1172,7 @@ server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) if (SD.exists(htmlPath)) { AsyncWebServerResponse *response = request->beginResponse(SD, htmlPath, "text/html"); + response->addHeader("Cache-Control", "no-store"); request->send(response); } else @@ -1186,7 +1193,11 @@ server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) String cssPath = getSysDir("style.css"); if (SD.exists(cssPath)) { - request->send(SD, cssPath, "text/css"); + { + AsyncWebServerResponse *resp = request->beginResponse(SD, cssPath, "text/css"); + resp->addHeader("Cache-Control", "public, max-age=300"); + request->send(resp); + } } else { @@ -1205,7 +1216,11 @@ server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) String jsPath = getSysDir("script.js"); if (SD.exists(jsPath)) { - request->send(SD, jsPath, "application/javascript"); + { + AsyncWebServerResponse *resp = request->beginResponse(SD, jsPath, "application/javascript"); + resp->addHeader("Cache-Control", "public, max-age=300"); + request->send(resp); + } } else { @@ -1218,48 +1233,59 @@ server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) // Dynamic endpoints to avoid template processing heap spikes server.on("/directory", HTTP_GET, [](AsyncWebServerRequest *request) { + webreq_enter(); + request->onDisconnect([](){ webreq_exit(); }); String html = processor(String("DIRECTORY")); request->send(200, "text/html; charset=UTF-8", html); }); server.on("/mapping", HTTP_GET, [](AsyncWebServerRequest *request) { + webreq_enter(); + request->onDisconnect([](){ webreq_exit(); }); String html = processor(String("MAPPING")); request->send(200, "text/html; charset=UTF-8", html); }); server.on("/state", HTTP_GET, [](AsyncWebServerRequest *request) { + webreq_enter(); + request->onDisconnect([](){ webreq_exit(); }); String state = getState(); - request->send(200, "application/json charset=UTF-8", state.c_str()); }); + request->send(200, "application/json; charset=UTF-8", state.c_str()); }); server.on("/start", HTTP_GET, [](AsyncWebServerRequest *request) { - - request->send(200, "text/plain charset=UTF-8", "start"); + webreq_enter(); + request->onDisconnect([](){ webreq_exit(); }); + request->send(200, "text/plain; charset=UTF-8", "start"); start(); }); server.on("/toggleplaypause", HTTP_GET, [](AsyncWebServerRequest *request) { - - request->send(200, "text/plain charset=UTF-8", "toggleplaypause"); + webreq_enter(); + request->onDisconnect([](){ webreq_exit(); }); + request->send(200, "text/plain; charset=UTF-8", "toggleplaypause"); togglePlayPause(); }); server.on("/stop", HTTP_GET, [](AsyncWebServerRequest *request) { - + webreq_enter(); + request->onDisconnect([](){ webreq_exit(); }); request->send(200, "text/plain", "stop"); stop(); }); server.on("/next", HTTP_GET, [](AsyncWebServerRequest *request) { - + webreq_enter(); + request->onDisconnect([](){ webreq_exit(); }); request->send(200, "text/plain", "next"); next(); }); server.on("/previous", HTTP_GET, [](AsyncWebServerRequest *request) { - + webreq_enter(); + request->onDisconnect([](){ webreq_exit(); }); request->send(200, "text/plain", "previous"); previous(); }); @@ -1410,6 +1436,8 @@ void setup() void id_song_action(AsyncWebServerRequest *request) { + webreq_enter(); + request->onDisconnect([](){ webreq_exit(); }); int params = request->params(); for (int i = 0; i < params; i++) { @@ -1425,6 +1453,8 @@ void id_song_action(AsyncWebServerRequest *request) void progress_action(AsyncWebServerRequest *request) { + webreq_enter(); + request->onDisconnect([](){ webreq_exit(); }); int params = request->params(); for (int i = 0; i < params; i++) @@ -1441,6 +1471,8 @@ void progress_action(AsyncWebServerRequest *request) void volume_action(AsyncWebServerRequest *request) { + webreq_enter(); + request->onDisconnect([](){ webreq_exit(); }); int params = request->params(); for (int i = 0; i < params; i++) diff --git a/web/script.js b/web/script.js index 0795d79..2931b22 100644 --- a/web/script.js +++ b/web/script.js @@ -1,6 +1,88 @@ setInterval(getState, 4000); setInterval(updateProgress, 500); // Update progress every second +/* Global single-flight queue for XMLHttpRequest + - Serializes all XHR to 1 at a time + - Adds timeouts (GET ~3.5s, POST ~6s, /upload 10min) + - Deduplicates idempotent GETs to same URL (drops duplicates) + This reduces concurrent load on the ESP32 web server and SD card. */ +(function(){ + var origOpen = XMLHttpRequest.prototype.open; + var origSend = XMLHttpRequest.prototype.send; + + var queue = []; + var active = null; + var inflightKeys = new Set(); + + function keyOf(xhr){ return (xhr.__method || 'GET') + ' ' + (xhr.__url || ''); } + + function startNext(){ + if (active || queue.length === 0) return; + var item = queue.shift(); + active = item; + inflightKeys.add(item.key); + + var xhr = item.xhr; + var timeoutMs = item.timeoutMs; + var timer = null; + + function cleanup() { + active = null; + inflightKeys.delete(item.key); + if (timer) { clearTimeout(timer); timer = null; } + setTimeout(startNext, 0); + } + + xhr.addEventListener('loadend', cleanup); + + if (timeoutMs > 0 && !xhr.__skipTimeout) { + timer = setTimeout(function(){ + try { xhr.abort(); } catch(e){} + }, timeoutMs); + } + + item.origSend.call(xhr, item.body); + } + + XMLHttpRequest.prototype.open = function(method, url, async){ + this.__method = (method || 'GET').toUpperCase(); + this.__url = url || ''; + return origOpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function(body){ + var key = keyOf(this); + var isIdempotentGET = (this.__method === 'GET'); + + var timeoutMs; + if ((this.__url || '').indexOf('/upload') !== -1) { + timeoutMs = 600000; // 10 minutes for uploads + } else if (this.__method === 'GET') { + timeoutMs = 3500; + } else { + timeoutMs = 6000; + } + + if (isIdempotentGET && inflightKeys.has(key)) { + // Drop duplicate GET to same resource + return; + } + if (isIdempotentGET) { + for (var i = 0; i < queue.length; i++) { + if (queue[i].key === key) { + // Already queued; keep most recent body if any + queue[i].body = body; + return; + } + } + } + + var item = { xhr: this, body: body, key: key, timeoutMs: timeoutMs, origSend: origSend }; + queue.push(item); + startNext(); + }; +})(); + /* Dynamic content loaders for playlist and mapping (avoid heavy template processing on server) */ function bindPlaylistClicks() { var container = document.getElementById('playlistContainer'); @@ -107,16 +189,22 @@ function getState() { var xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { if (xhr.readyState === 4) { - var state = JSON.parse(xhr.response); - isPlaying = state['playing']; - if (isPlaying) { - songStartTime = Date.now() - state['time'] * 1000; - currentSongLength = state['length'] * 1000; + if (xhr.status >= 200 && xhr.status < 300) { + try { + var state = JSON.parse(xhr.responseText || xhr.response || '{}'); + isPlaying = !!state['playing']; + if (isPlaying) { + songStartTime = Date.now() - ((state['time'] || 0) * 1000); + currentSongLength = ((state['length'] || 0) * 1000); + } + lastStateUpdateTime = Date.now(); + displayState(state); + } catch (e) { + // Ignore parse errors; will retry on next poll + } } - lastStateUpdateTime = Date.now(); - displayState(state); } - } + }; xhr.open("GET","/state", true); xhr.send(); }