Skip to content

Commit e02c517

Browse files
authored
Fix private feed URLs with parens (#127)
* fix: encode private feed urls * fix: keep encoded delimiters
1 parent 87a7def commit e02c517

File tree

3 files changed

+78
-8
lines changed

3 files changed

+78
-8
lines changed

src/downloadEpisode.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Notice, TFile, requestUrl } from "obsidian";
22
import { downloadedEpisodes } from "./store";
33
import { DownloadPathTemplateEngine } from "./TemplateEngine";
44
import type { Episode } from "./types/Episode";
5+
import { encodeUrlForRequest } from "./utility/encodeUrlForRequest";
56
import getUrlExtension from "./utility/getUrlExtension";
67
import getExtensionFromContentType from "./utility/getExtensionFromContentType";
78

@@ -16,8 +17,9 @@ async function downloadFile(
1617
onError: (error: Error) => void;
1718
}>,
1819
) {
20+
const encodedUrl = encodeUrlForRequest(url);
1921
try {
20-
const response = await requestUrl({ url, method: "GET" });
22+
const response = await requestUrl({ url: encodedUrl, method: "GET" });
2123

2224
if (response.status !== 200) {
2325
throw new Error("Could not download episode.");
@@ -36,7 +38,7 @@ async function downloadFile(
3638
}),
3739
contentLength,
3840
receivedLength: contentLength,
39-
responseUrl: url,
41+
responseUrl: encodedUrl,
4042
};
4143
} catch (error: unknown) {
4244
const err = new Error(
@@ -229,16 +231,21 @@ export async function downloadEpisode(
229231
}
230232

231233
async function getFileExtension(url: string): Promise<string> {
232-
const urlExtension = getUrlExtension(url);
234+
const encodedUrl = encodeUrlForRequest(url);
235+
const urlExtension = getUrlExtension(encodedUrl);
233236
if (urlExtension) return urlExtension;
234237

235238
// If URL doesn't have an extension, fetch headers to determine content type
236-
const response = await fetch(url, { method: "HEAD" });
237-
const contentType = response.headers.get("content-type");
239+
try {
240+
const response = await fetch(encodedUrl, { method: "HEAD" });
241+
const contentType = response.headers.get("content-type");
238242

239-
const extensionFromContentType = getExtensionFromContentType(contentType);
240-
if (extensionFromContentType) {
241-
return extensionFromContentType;
243+
const extensionFromContentType = getExtensionFromContentType(contentType);
244+
if (extensionFromContentType) {
245+
return extensionFromContentType;
246+
}
247+
} catch (error) {
248+
console.error(`HEAD request failed for ${encodedUrl}`, error);
242249
}
243250

244251
// Default to mp3 if we can't determine the type
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from "vitest";
2+
import { encodeUrlForRequest } from "./encodeUrlForRequest";
3+
4+
describe("encodeUrlForRequest", () => {
5+
it("encodes parentheses and whitespace while leaving structure intact", () => {
6+
const raw =
7+
"https://example.com/podcast/Episode (Part 1).mp3?token=(abc123)";
8+
expect(encodeUrlForRequest(raw)).toBe(
9+
"https://example.com/podcast/Episode%20%28Part%201%29.mp3?token=%28abc123%29",
10+
);
11+
});
12+
13+
it("returns already encoded urls untouched", () => {
14+
const alreadyEncoded =
15+
"https://example.com/audio/Episode%20%28Part%201%29.mp3";
16+
expect(encodeUrlForRequest(alreadyEncoded)).toBe(alreadyEncoded);
17+
});
18+
19+
it("keeps intentionally encoded delimiters intact", () => {
20+
const signedUrl =
21+
"https://example.com/path%2Fsegment%3Fsignature%3Dabc123%23hash";
22+
expect(encodeUrlForRequest(signedUrl)).toBe(signedUrl);
23+
});
24+
25+
it("handles empty strings safely", () => {
26+
expect(encodeUrlForRequest("")).toBe("");
27+
expect(encodeUrlForRequest(" ")).toBe("");
28+
});
29+
});

src/utility/encodeUrlForRequest.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
const PARENTHESIS_REGEXP = /[()]/g;
2+
const PARENTHESIS_LOOKUP: Record<string, string> = {
3+
"(": "%28",
4+
")": "%29",
5+
};
6+
7+
/**
8+
* Ensures URLs are safe to send over the network by percent-encoding
9+
* whitespace plus characters that certain private feed hosts require,
10+
* such as parentheses.
11+
*/
12+
export function encodeUrlForRequest(rawUrl: string): string {
13+
const trimmed = rawUrl.trim();
14+
if (!trimmed) return trimmed;
15+
16+
let normalized: string;
17+
try {
18+
normalized = new URL(trimmed).toString();
19+
} catch {
20+
normalized = encodeWhitespace(trimmed);
21+
}
22+
23+
const encoded = normalized;
24+
return encoded.replace(
25+
PARENTHESIS_REGEXP,
26+
(char) => PARENTHESIS_LOOKUP[char] ?? char,
27+
);
28+
}
29+
30+
function encodeWhitespace(value: string): string {
31+
return value.replace(/\s/g, (char) =>
32+
`%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`,
33+
);
34+
}

0 commit comments

Comments
 (0)