diff --git a/server/Server.js b/server/Server.js index d6f748a1e..b043bb9e5 100644 --- a/server/Server.js +++ b/server/Server.js @@ -301,6 +301,11 @@ class Server { app.disable('x-powered-by') this.server = http.createServer(app) + // Keep connections alive for 120s so multi-file downloads don't pay a new + // TCP handshake per file. Default Node.js keepAliveTimeout is only 5s, + // which causes connections to drop mid-download for files > 5s. + this.server.keepAliveTimeout = 120000 + this.server.headersTimeout = 125000 router.use( fileUpload({ diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index ee8e15d77..f589ee68f 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -184,12 +184,10 @@ 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()))) + // Use sendFile so the `send` module sets Content-Length, Accept-Ranges, + // and handles range requests natively — no extra stat() call needed. + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(Path.basename(req.libraryItem.relPath))}"`) + await new Promise((resolve, reject) => res.sendFile(libraryItemPath, { dotfiles: 'allow' }, (error) => (error ? reject(error) : resolve()))) } else { const filename = `${itemTitle}.zip` await zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res) @@ -1102,13 +1100,10 @@ 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()))) + // Use sendFile so the `send` module sets Content-Length, Accept-Ranges, + // and handles range requests natively — no extra stat() call needed. + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(libraryFile.metadata.filename)}"`) + await new Promise((resolve, reject) => res.sendFile(libraryFile.metadata.path, { dotfiles: 'allow' }, (error) => (error ? reject(error) : resolve()))) Logger.info(`[LibraryItemController] Downloaded file "${libraryFile.metadata.path}"`) } catch (error) { Logger.error(`[LibraryItemController] Failed to download file "${libraryFile.metadata.path}"`, error)