Skip to content

Commit 3a5c34a

Browse files
authored
fix: ensure downloads get extension (#126)
1 parent 55c3f1b commit 3a5c34a

File tree

3 files changed

+78
-16
lines changed

3 files changed

+78
-16
lines changed

src/downloadEpisode.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { downloadedEpisodes } from "./store";
33
import { DownloadPathTemplateEngine } from "./TemplateEngine";
44
import type { Episode } from "./types/Episode";
55
import getUrlExtension from "./utility/getUrlExtension";
6+
import getExtensionFromContentType from "./utility/getExtensionFromContentType";
67

78
function getErrorMessage(error: unknown): string {
89
return error instanceof Error ? error.message : String(error);
@@ -74,18 +75,11 @@ export default async function downloadEpisodeWithNotice(
7475
},
7576
});
7677

77-
const fileExtension = await detectAudioFileExtension(blob);
78-
if (!fileExtension) {
79-
update((bodyEl) => {
80-
bodyEl.createEl("p", {
81-
text: `Could not determine file extension for downloaded file. Blob: ${blob.size} bytes.`,
82-
});
83-
});
78+
const inferredExtension = await inferFileExtensionFromDownload(episode, blob);
79+
const normalizedType = (blob.type ?? "").toLowerCase();
80+
const typeAppearsAudio = normalizedType === "" || normalizedType.includes("audio");
8481

85-
throw new Error("Could not determine file extension");
86-
}
87-
88-
if (!blob.type.contains("audio") && !fileExtension) {
82+
if (!typeAppearsAudio && !inferredExtension) {
8983
update((bodyEl) => {
9084
bodyEl.createEl("p", {
9185
text: `Downloaded file is not an audio file. It is of type "${blob.type}". Blob: ${blob.size} bytes.`,
@@ -95,6 +89,8 @@ export default async function downloadEpisodeWithNotice(
9589
throw new Error("Not an audio file");
9690
}
9791

92+
const fileExtension = inferredExtension ?? "mp3";
93+
9894
try {
9995
update((bodyEl) => bodyEl.createEl("p", { text: "Creating file..." }));
10096

@@ -179,6 +175,23 @@ async function createEpisodeFile({
179175
downloadedEpisodes.addEpisode(episode, filePath, blob.size);
180176
}
181177

178+
async function inferFileExtensionFromDownload(
179+
episode: Episode,
180+
blob: Blob,
181+
): Promise<string | null> {
182+
const signatureExtension = await detectAudioFileExtension(blob);
183+
if (signatureExtension) {
184+
return signatureExtension;
185+
}
186+
187+
const urlExtension = getUrlExtension(episode.streamUrl);
188+
if (urlExtension) {
189+
return urlExtension;
190+
}
191+
192+
return getExtensionFromContentType(blob.type);
193+
}
194+
182195
export async function downloadEpisode(
183196
episode: Episode,
184197
downloadPathTemplate: string,
@@ -223,11 +236,10 @@ async function getFileExtension(url: string): Promise<string> {
223236
const response = await fetch(url, { method: "HEAD" });
224237
const contentType = response.headers.get("content-type");
225238

226-
if (contentType?.includes("audio/mpeg")) return "mp3";
227-
if (contentType?.includes("audio/mp4")) return "m4a";
228-
if (contentType?.includes("audio/ogg")) return "ogg";
229-
if (contentType?.includes("audio/wav")) return "wav";
230-
if (contentType?.includes("audio/x-m4a")) return "m4a";
239+
const extensionFromContentType = getExtensionFromContentType(contentType);
240+
if (extensionFromContentType) {
241+
return extensionFromContentType;
242+
}
231243

232244
// Default to mp3 if we can't determine the type
233245
return "mp3";
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, test } from "vitest";
2+
import getExtensionFromContentType from "./getExtensionFromContentType";
3+
4+
describe("getExtensionFromContentType", () => {
5+
test("detects mp3 from standard mime type", () => {
6+
expect(getExtensionFromContentType("audio/mpeg")).toBe("mp3");
7+
});
8+
9+
test("handles mixed case mime types", () => {
10+
expect(getExtensionFromContentType("Audio/X-M4A")).toBe("m4a");
11+
});
12+
13+
test("returns null when mime type is not audio", () => {
14+
expect(getExtensionFromContentType("text/html")).toBeNull();
15+
});
16+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
const CONTENT_TYPE_EXTENSION_MAP: Array<{
2+
pattern: RegExp;
3+
extension: string;
4+
}> = [
5+
{ pattern: /audio\/mpeg/i, extension: "mp3" },
6+
{ pattern: /audio\/mp3/i, extension: "mp3" },
7+
{ pattern: /audio\/mp4/i, extension: "m4a" },
8+
{ pattern: /audio\/x-m4a/i, extension: "m4a" },
9+
{ pattern: /audio\/aac/i, extension: "aac" },
10+
{ pattern: /audio\/ogg/i, extension: "ogg" },
11+
{ pattern: /audio\/wav/i, extension: "wav" },
12+
{ pattern: /audio\/x-wav/i, extension: "wav" },
13+
{ pattern: /audio\/flac/i, extension: "flac" },
14+
{ pattern: /audio\/x-flac/i, extension: "flac" },
15+
{ pattern: /audio\/x-ms-wma/i, extension: "wma" },
16+
{ pattern: /audio\/wma/i, extension: "wma" },
17+
{ pattern: /audio\/amr/i, extension: "amr" },
18+
];
19+
20+
export default function getExtensionFromContentType(
21+
contentType?: string | null,
22+
): string | null {
23+
if (!contentType) {
24+
return null;
25+
}
26+
27+
for (const { pattern, extension } of CONTENT_TYPE_EXTENSION_MAP) {
28+
if (pattern.test(contentType)) {
29+
return extension;
30+
}
31+
}
32+
33+
return null;
34+
}

0 commit comments

Comments
 (0)