Add HTTP range request support for video streaming
Safari and other browsers require Accept-Ranges: bytes and 206 Partial Content responses to play video. Without this, large videos fail to load (especially in Safari) because the entire file had to buffer in memory before sending. - Replace readFile + Buffer with createReadStream for efficient streaming - Parse Range header (start-end, start-, and suffix -N forms) - Return 206 Partial Content with Content-Range for range requests - Return 416 Range Not Satisfiable for out-of-bounds ranges - Add Accept-Ranges: bytes to all raw file responses Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import { extname } from 'path';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { createReadStream } from 'fs';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { Config } from '../config.ts';
|
||||
@@ -23,6 +23,29 @@ interface Deps {
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
function parseRangeHeader(header: string, fileSize: number): { start: number; end: number } | null {
|
||||
const match = header.match(/^bytes=(\d*)-(\d*)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const [, rawStart, rawEnd] = match;
|
||||
|
||||
let start: number;
|
||||
let end: number;
|
||||
|
||||
if (rawStart === '') {
|
||||
// Suffix range: bytes=-N (last N bytes)
|
||||
const suffix = parseInt(rawEnd, 10);
|
||||
start = Math.max(0, fileSize - suffix);
|
||||
end = fileSize - 1;
|
||||
} else {
|
||||
start = parseInt(rawStart, 10);
|
||||
end = rawEnd === '' ? fileSize - 1 : parseInt(rawEnd, 10);
|
||||
}
|
||||
|
||||
if (start > end || end >= fileSize) return null;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => {
|
||||
const { db, config, logger } = deps;
|
||||
|
||||
@@ -140,7 +163,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
|
||||
reply.type('text/html').send(fileViewPage(file, isOwner));
|
||||
});
|
||||
|
||||
// GET /f/:id/raw — serve raw file
|
||||
// GET /f/:id/raw — serve raw file with range request support
|
||||
app.get<{ Params: { id: string } }>('/f/:id/raw', async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const file = getFileById(db, id);
|
||||
@@ -151,12 +174,37 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
|
||||
}
|
||||
|
||||
const filePath = getFilePath(config.uploadDir, file.stored_name);
|
||||
const data = await readFile(filePath);
|
||||
const safeFilename = file.original_name.replace(/["\\\r\n]/g, '_');
|
||||
|
||||
reply
|
||||
.header('Content-Type', file.mime_type)
|
||||
.header('Content-Disposition', `inline; filename="${safeFilename}"`)
|
||||
.send(data);
|
||||
.header('Accept-Ranges', 'bytes');
|
||||
|
||||
const rangeHeader = request.headers['range'];
|
||||
|
||||
if (!rangeHeader) {
|
||||
reply.header('Content-Length', file.size);
|
||||
return reply.send(createReadStream(filePath));
|
||||
}
|
||||
|
||||
const range = parseRangeHeader(rangeHeader, file.size);
|
||||
|
||||
if (!range) {
|
||||
return reply
|
||||
.status(416)
|
||||
.header('Content-Range', `bytes */${file.size}`)
|
||||
.send();
|
||||
}
|
||||
|
||||
const { start, end } = range;
|
||||
const chunkSize = end - start + 1;
|
||||
|
||||
return reply
|
||||
.status(206)
|
||||
.header('Content-Range', `bytes ${start}-${end}/${file.size}`)
|
||||
.header('Content-Length', chunkSize)
|
||||
.send(createReadStream(filePath, { start, end }));
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
|
||||
@@ -125,6 +125,51 @@ describe('GET /f/:id and GET /f/:id/raw', () => {
|
||||
const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}/raw` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe('hello!');
|
||||
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||
});
|
||||
|
||||
it('returns 206 for a byte range request', async () => {
|
||||
const res = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/f/${fileId}/raw`,
|
||||
headers: { range: 'bytes=0-3' },
|
||||
});
|
||||
expect(res.statusCode).toBe(206);
|
||||
expect(res.headers['content-range']).toBe('bytes 0-3/6');
|
||||
expect(res.headers['content-length']).toBe('4');
|
||||
expect(res.body).toBe('hell');
|
||||
});
|
||||
|
||||
it('returns 206 for an open-ended range request', async () => {
|
||||
const res = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/f/${fileId}/raw`,
|
||||
headers: { range: 'bytes=2-' },
|
||||
});
|
||||
expect(res.statusCode).toBe(206);
|
||||
expect(res.headers['content-range']).toBe('bytes 2-5/6');
|
||||
expect(res.body).toBe('llo!');
|
||||
});
|
||||
|
||||
it('returns 206 for a suffix range request', async () => {
|
||||
const res = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/f/${fileId}/raw`,
|
||||
headers: { range: 'bytes=-3' },
|
||||
});
|
||||
expect(res.statusCode).toBe(206);
|
||||
expect(res.headers['content-range']).toBe('bytes 3-5/6');
|
||||
expect(res.body).toBe('lo!');
|
||||
});
|
||||
|
||||
it('returns 416 for an unsatisfiable range', async () => {
|
||||
const res = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/f/${fileId}/raw`,
|
||||
headers: { range: 'bytes=100-200' },
|
||||
});
|
||||
expect(res.statusCode).toBe(416);
|
||||
expect(res.headers['content-range']).toBe('bytes */6');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown file', async () => {
|
||||
|
||||
Reference in New Issue
Block a user