add manual range request implementation for /download endpoint

AuthorKonata <konata@posteo.jp>
Date
Commit10fc463c3ac882acb43a9592cbbd7c5e7f3dd3ee
Parent495ee5b
4 files changed, 39 insertions(+), 6 deletions(-)
▾Mserver/package.json
@@ -16,9 +16,11 @@
"@elysiajs/static": "^1.2.0",
"elysia": "^1.2.0",
"file-type": "^20.0.1",
"music-server-shared": "file:../shared"
"music-server-shared": "file:../shared",
"range-parser": "^1.2.1"
},
"devDependencies": {
"@types/range-parser": "^1.2.7",
"bun-types": "latest",
"concurrently": "^9.1.2"
},
▾Mserver/src/index.ts
@@ -12,6 +12,10 @@ import {
needsTranscoding,
convertWithFFmpeg,
} from "./utils";
import { stat } from "node:fs/promises";
import parseRange from "range-parser";
import { createReadStream } from "node:fs";
//TODO: transcoding cache?
//TODO: add reasonable timeouts for caches
@@ -39,7 +43,7 @@ const app = new Elysia()
fileTypeCache.clear();
probeCache.clear();
})
.get("/download/*", async ({ params, set }) => {
.get("/download/*", async ({ headers, params, set }) => {
const filePath = path.join(musicRoot, decodeURI(params["*"]));
const fileScan = await getPathInfo(filePath);
if (!fileScan || fileScan instanceof ServerError) {
@@ -52,8 +56,33 @@ const app = new Elysia()
}
set.status = "OK";
set.headers["content-type"] = fileScan.mimeType;
return Bun.file(filePath);
if (fileScan.mimeType === "audio/x-flac") fileScan.mimeType = "audio/flac"; //seems to be better supported
set.headers["Content-Type"] = fileScan.mimeType;
if (!headers.range) {
//elysias automatically sets Accept-Ranges here, adding it manually causes it to duplicate
set.status = "OK";
return Bun.file(filePath);
}
set.headers["Accept-Ranges"] = "bytes";
// Range support, elysia advertises ranges, but doesn't actually fulfill them, which breaks e.g. safari
const statResult = await stat(filePath);
const fileSize = statResult.size;
const rangeData = parseRange(fileSize, headers.range, { combine: true });
if (rangeData === -1 || rangeData === -2 || rangeData.length > 1) {
set.status = "Range Not Satisfiable";
return "Range Not Satisfiable";
}
const { start, end } = rangeData[0];
const chunkSize = end - start + 1;
const fileStream = createReadStream(filePath, { start, end });
set.status = "Partial Content";
set.headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
set.headers["Content-Length"] = chunkSize.toString();
return fileStream;
})
.get("/transcode/*", async ({ request, query, set, params }) => {
const filePath = path.join(musicRoot, decodeURI(params["*"]));
▾Mwebclient/src/App.tsx
@@ -11,6 +11,7 @@ import { getSrc } from "./offline";
//TODO: playlist management
//TODO: implement playlist item download server side
//TODO: remember scroll position for parent directories
//TODO: when downloading dirs, maybe put all subfiles in a "downloading" state to not accidentally trigger them twice
//TODO: better touch controls?
//TODO: Add documentation to functions
@@ -254,7 +255,6 @@ const App: Component = () => {
<CogSVG />
</button>
</div>
<audio ref={audioElement} preload="auto" />
<div class="main-content">
<FileBrowser
@@ -280,6 +280,8 @@ const App: Component = () => {
/>
</div>
<audio ref={audioElement} preload="auto" />
<PlayerControls
playerState={playerState}
setPlayerState={setPlayerState}
▾Mwebclient/src/utils.ts
@@ -67,5 +67,5 @@ export function formatBytes(bytes: number): string {
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / k ** i;
return `${Number.parseFloat(value.toFixed(2))} ${sizes[i]}`;
return `${value.toFixed(2)} ${sizes[i]}`;
}