From cf0a432027b4ad1b0db936b27872de978460c1d9 Mon Sep 17 00:00:00 2001 From: Trevor Renshaw <8571163+trevren11@users.noreply.github.com> Date: Sat, 16 May 2026 08:50:36 -0600 Subject: [PATCH] Fix slow downloads: larger buffers, Content-Length headers, parallel streams --- server/controllers/LibraryItemController.js | 11 +++++++++++ server/utils/fileUtils.js | 5 +++-- server/utils/zipHelpers.js | 10 ++++++++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 07b4ee67f..ee8e15d77 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -184,6 +184,11 @@ class LibraryItemController { if (audioMimeType) { res.setHeader('Content-Type', audioMimeType) } + // Set Content-Length so clients can show accurate progress bars. + const fileStat = await fs.stat(libraryItemPath).catch(() => null) + if (fileStat) { + res.setHeader('Content-Length', fileStat.size) + } await new Promise((resolve, reject) => res.download(libraryItemPath, req.libraryItem.relPath, (error) => (error ? reject(error) : resolve()))) } else { const filename = `${itemTitle}.zip` @@ -1097,6 +1102,12 @@ class LibraryItemController { } try { + // Set Content-Length so clients (including the Android app) can show accurate + // download progress and avoid chunked-transfer encoding when the file size is known. + const fileStat = await fs.stat(libraryFile.metadata.path).catch(() => null) + if (fileStat) { + res.setHeader('Content-Length', fileStat.size) + } await new Promise((resolve, reject) => res.download(libraryFile.metadata.path, libraryFile.metadata.filename, (error) => (error ? reject(error) : resolve()))) Logger.info(`[LibraryItemController] Downloaded file "${libraryFile.metadata.path}"`) } catch (error) { diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 9a349bd54..6164efd0e 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -318,8 +318,9 @@ module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => { const totalSize = parseInt(response.headers['content-length'], 10) let downloadedSize = 0 - // Write to filepath - const writer = fs.createWriteStream(filepath) + // Use a 512 KiB write buffer. Node's default 16 KiB highWaterMark causes + // many small write syscalls for large audio files and limits throughput. + const writer = fs.createWriteStream(filepath, { highWaterMark: 524288 }) response.data.pipe(writer) let lastProgress = 0 diff --git a/server/utils/zipHelpers.js b/server/utils/zipHelpers.js index 421ca73cd..10fab4d1a 100644 --- a/server/utils/zipHelpers.js +++ b/server/utils/zipHelpers.js @@ -9,7 +9,11 @@ module.exports.zipDirectoryPipe = (path, filename, res) => { res.attachment(filename) const archive = archiver('zip', { - zlib: { level: 0 } // Sets the compression level. + zlib: { level: 0 }, // Sets the compression level (0 = store, no CPU overhead). + // Increase the internal pipeline highWaterMark from 16 KiB to 512 KiB. + // This allows archiver to buffer more data before applying backpressure, + // keeping the read-from-disk and write-to-network pipelines busy. + highWaterMark: 524288 }) // listen for all archive data to be written @@ -67,7 +71,9 @@ module.exports.zipDirectoriesPipe = (pathObjects, filename, res) => { res.attachment(filename) const archive = archiver('zip', { - zlib: { level: 0 } // Sets the compression level. + zlib: { level: 0 }, // Sets the compression level (0 = store, no CPU overhead). + // Increase the internal pipeline highWaterMark from 16 KiB to 512 KiB. + highWaterMark: 524288 }) // listen for all archive data to be written