fix: 웹에서 mp4/mp3 재생이 아니라 다운로드로 떨어지던 문제

- /api/files 라우트가 영상·오디오를 attachment + application/octet-stream으로
  서빙해서 브라우저가 재생 대신 다운로드만 했던 것(2026-09-25 사용자 실측).
- 영상·오디오 MIME 추가(video/mp4, video/webm, audio/mpeg 등) + inline 처리.
- Range 요청(206 Partial) 지원 — 영상 시킹 시 처음부터 전체 재수신 안 함.
- 회귀 확인: PDF inline 뷰어, PPTX 강제 다운로드 기존 동작 유지.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-25 18:01:35 +09:00
co-authored by Claude Code
parent 875b08e70a
commit 2a72388f9c
+30 -4
View File
@@ -494,6 +494,10 @@ const IMAGE_TYPES: Record<string, string> = {
// 보내면 브라우저가 latin1로 해석해 작업실 개요/readme.md의 한글이 전부 깨져 보였음.
'.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8', '.json': 'application/json; charset=utf-8',
'.csv': 'text/csv; charset=utf-8', '.html': 'text/html; charset=utf-8', '.md': 'text/plain; charset=utf-8',
// 영상·오디오 — octet-stream으로 보내면 브라우저가 재생을 포기하고 다운로드만 한다.
'.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
'.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo',
'.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
};
// Code-editor sessions whose files live in the browser's local folder (File System
@@ -2976,10 +2980,11 @@ app.get('/api/files/{*filePath}', (req: express.Request, res: express.Response)
const ext = path.extname(filePath).toLowerCase();
const contentType = IMAGE_TYPES[ext] || 'application/octet-stream';
const filename = path.basename(filePath);
// Force download for non-image files (pptx, pdf, xlsx, docx, zip, etc.)
// PDF/txt/csv: inline (browser viewer); other binary files: attachment (force download)
const inlineExts = ['.pdf', '.txt', '.csv', '.md'];
const downloadExts = ['.pptx', '.xlsx', '.xls', '.docx', '.doc', '.zip', '.mp4', '.mp3'];
// Force download for non-previewable files (pptx, xlsx, docx, zip, etc.)
// PDF/txt/csv: inline (browser viewer); 영상·오디오(mp4/mp3/…)도 inline — attachment로
// 밀면 <video>/<audio> 재생 대신 무조건 다운로드로 떨어진다(2026-09-25 사용자 실측).
const inlineExts = ['.pdf', '.txt', '.csv', '.md', '.mp4', '.m4v', '.webm', '.mov', '.mkv', '.avi', '.mp3', '.m4a', '.wav', '.ogg'];
const downloadExts = ['.pptx', '.xlsx', '.xls', '.docx', '.doc', '.zip'];
if (inlineExts.includes(ext)) {
const encodedFilename = encodeURIComponent(filename);
res.setHeader('Content-Disposition', `inline; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`);
@@ -2989,9 +2994,30 @@ app.get('/api/files/{*filePath}', (req: express.Request, res: express.Response)
}
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=60');
res.setHeader('Accept-Ranges', 'bytes');
// Use createReadStream instead of sendFile for cross-platform reliability (Express 5)
try {
const stat = fs.statSync(filePath);
// Range 요청(영상/오디오 시킹) — 206 부분 응답. 없으면 재생은 돼도 시킹할 때마다 처음부터 전체 재수신.
const rangeReq = String((req as any).headers?.range || '');
const rm = /^bytes=(\d*)-(\d*)$/.exec(rangeReq);
if (rm && stat.size > 0) {
let start = rm[1] === '' ? Math.max(0, stat.size - parseInt(rm[2], 10)) : parseInt(rm[1], 10);
const end = (rm[1] !== '' && rm[2] !== '') ? Math.min(parseInt(rm[2], 10), stat.size - 1) : stat.size - 1;
if (!Number.isFinite(start) || start > end || start >= stat.size) {
res.status(416); res.setHeader('Content-Range', `bytes */${stat.size}`); return;
}
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`);
res.setHeader('Content-Length', end - start + 1);
const rs = fs.createReadStream(filePath, { start, end });
rs.on('error', (streamErr: any) => {
console.error('[files] stream error:', streamErr.message);
if (!res.headersSent) res.status(500).json({ error: 'Failed to stream file' });
});
rs.pipe(res);
return;
}
res.setHeader('Content-Length', stat.size);
const stream = fs.createReadStream(filePath);
stream.on('error', (streamErr: any) => {