diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts
index 7b1f57a5..4bcb2410 100644
--- a/server/src/__tests__/issue-attachment-routes.test.ts
+++ b/server/src/__tests__/issue-attachment-routes.test.ts
@@ -412,6 +412,25 @@ describe("issue attachment routes", () => {
);
});
+ it("serves mp4 attachments inline when stored with a generic binary content type", async () => {
+ const storage = createStorageService(Buffer.from("abcdef"));
+ mockIssueService.getAttachmentById.mockResolvedValue({
+ ...makeAttachment("application/octet-stream", "clip.mp4"),
+ byteSize: 6,
+ });
+
+ const app = await createApp(storage);
+ const res = await request(app)
+ .get("/api/attachments/attachment-1/content")
+ .set("Range", "bytes=1-3");
+
+ expect(res.status).toBe(206);
+ expect(res.headers["content-type"]).toContain("video/mp4");
+ expect(res.headers["content-disposition"]).toBe('inline; filename="clip.mp4"');
+ expect(res.headers["content-range"]).toBe("bytes 1-3/6");
+ expect(Buffer.from(res.body).toString("utf8")).toBe("bcd");
+ });
+
it("forces video downloads when the download path is requested", async () => {
const storage = createStorageService();
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("video/webm", "clip.webm"));
diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts
index ba07190f..85492638 100644
--- a/server/src/routes/issues.ts
+++ b/server/src/routes/issues.ts
@@ -185,6 +185,30 @@ function buildAttachmentContentPath(attachmentId: string): string {
return `/api/attachments/${attachmentId}/content`;
}
+const GENERIC_ATTACHMENT_CONTENT_TYPES = new Set([
+ "application/octet-stream",
+ "binary/octet-stream",
+ "application/x-binary",
+]);
+
+function inferVideoContentTypeFromFilename(filename: string | null | undefined): string | null {
+ const lower = (filename ?? "").toLowerCase();
+ if (lower.endsWith(".mp4") || lower.endsWith(".m4v")) return "video/mp4";
+ if (lower.endsWith(".webm")) return "video/webm";
+ if (lower.endsWith(".mov") || lower.endsWith(".qt") || lower.endsWith(".quicktime")) return "video/quicktime";
+ return null;
+}
+
+function resolveAttachmentResponseContentType(input: {
+ storedContentType: string | null | undefined;
+ objectContentType?: string | null;
+ originalFilename?: string | null;
+}) {
+ const storedContentType = normalizeContentType(input.storedContentType || input.objectContentType);
+ if (!GENERIC_ATTACHMENT_CONTENT_TYPES.has(storedContentType)) return storedContentType;
+ return inferVideoContentTypeFromFilename(input.originalFilename) ?? storedContentType;
+}
+
function requiresPaperclipAttachmentMetadata(input: {
type?: unknown;
provider?: unknown;
@@ -6267,7 +6291,11 @@ export function issueRoutes(
attachment.objectKey,
range.kind === "range" ? { range: { start: range.start, end: range.end } } : undefined,
);
- const responseContentType = normalizeContentType(attachment.contentType || object.contentType);
+ const responseContentType = resolveAttachmentResponseContentType({
+ storedContentType: attachment.contentType,
+ objectContentType: object.contentType,
+ originalFilename: attachment.originalFilename,
+ });
res.setHeader("Content-Type", responseContentType);
res.setHeader("Cache-Control", "private, max-age=60");
res.setHeader("X-Content-Type-Options", "nosniff");
diff --git a/ui/src/components/IssueAttachmentsSection.test.tsx b/ui/src/components/IssueAttachmentsSection.test.tsx
index 7cec72e6..4e54e05f 100644
--- a/ui/src/components/IssueAttachmentsSection.test.tsx
+++ b/ui/src/components/IssueAttachmentsSection.test.tsx
@@ -155,6 +155,33 @@ describe("IssueAttachmentsSection", () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
+ it("treats mp4 filenames as playable videos even with a generic binary content type", async () => {
+ const attachment = makeAttachment({
+ id: "misclassified-mp4",
+ originalFilename: "demo.mp4",
+ contentType: "application/octet-stream",
+ contentPath: "/api/attachments/misclassified-mp4/content",
+ });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+
+ const video = container.querySelector("video");
+ expect(video?.getAttribute("src")).toBe("/api/attachments/misclassified-mp4/content");
+ expect(container.textContent).toContain("application/octet-stream");
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
it("keeps generic attachments as compact file rows with open and download actions", async () => {
const attachment = makeAttachment({
id: "pdf-attachment",
diff --git a/ui/src/lib/issue-attachments.ts b/ui/src/lib/issue-attachments.ts
index b9dc1565..29219c46 100644
--- a/ui/src/lib/issue-attachments.ts
+++ b/ui/src/lib/issue-attachments.ts
@@ -25,8 +25,20 @@ export function isImageAttachment(attachment: Pick) {
- return isVideoContentType(normalizedContentType(attachment));
+export function isVideoAttachment(
+ attachment: Pick,
+) {
+ if (isVideoContentType(normalizedContentType(attachment))) return true;
+
+ const filename = (attachment.originalFilename ?? "").toLowerCase();
+ return (
+ filename.endsWith(".mp4") ||
+ filename.endsWith(".m4v") ||
+ filename.endsWith(".webm") ||
+ filename.endsWith(".mov") ||
+ filename.endsWith(".qt") ||
+ filename.endsWith(".quicktime")
+ );
}
export function isMarkdownAttachment(