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 type { FastifyPluginAsync } from 'fastify';
|
||||||
import { extname } from 'path';
|
import { extname } from 'path';
|
||||||
import { readFile } from 'fs/promises';
|
import { createReadStream } from 'fs';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
import type { Config } from '../config.ts';
|
import type { Config } from '../config.ts';
|
||||||
@@ -23,6 +23,29 @@ interface Deps {
|
|||||||
logger: Logger;
|
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 }) => {
|
export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => {
|
||||||
const { db, config, logger } = 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));
|
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) => {
|
app.get<{ Params: { id: string } }>('/f/:id/raw', async (request, reply) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const file = getFileById(db, id);
|
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 filePath = getFilePath(config.uploadDir, file.stored_name);
|
||||||
const data = await readFile(filePath);
|
|
||||||
const safeFilename = file.original_name.replace(/["\\\r\n]/g, '_');
|
const safeFilename = file.original_name.replace(/["\\\r\n]/g, '_');
|
||||||
|
|
||||||
reply
|
reply
|
||||||
.header('Content-Type', file.mime_type)
|
.header('Content-Type', file.mime_type)
|
||||||
.header('Content-Disposition', `inline; filename="${safeFilename}"`)
|
.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
|
// 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` });
|
const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}/raw` });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.body).toBe('hello!');
|
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 () => {
|
it('returns 404 for unknown file', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user