Skip to content

Commit 46a6486

Browse files
authored
fix(download): default per-episode download path and migrate empty default (#183) (#186)
download.path defaulted to "" (empty), so the Download command resolved every episode to ".mp3" at the vault root — a dotfile Obsidian never indexes. The first download wrote hidden junk and the second threw "File already exists", blocking all further downloads until the user manually deleted the dotfile. - constants: default download.path is now "PodNotes/{{podcast}}/{{title}}". - settingsMigrations: on load, upgrade a persisted empty/absent path to the new default. Only the exact legacy "" (and null/undefined) is migrated, so a custom path is preserved. Needed because the whole settings object is persisted, so existing users already have download.path:"" overriding the new default. - main: apply the migration in loadSettings and rebuild settings.download via spread so the shared DEFAULT_SETTINGS.download is never mutated. - downloadEpisode: safeDownloadBasename guarantees a per-episode file name even for a misconfigured (empty/trailing-slash) template — never writes ".<ext>". - settings UI: warn inline when the configured path can't resolve {{title}}. - context menu: drop the now-stale empty-path guard and rely on the safety net, so the menu and the Download command behave consistently. - docs + tests (migration, safe basename, default).
1 parent cf3d73c commit 46a6486

9 files changed

Lines changed: 230 additions & 20 deletions

File tree

docs/docs/commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ This will skip the current episode forward by the amount of seconds specified in
2323
## Download Playing Episode
2424
This will download the currently playing episode.
2525

26-
Downloads are stored in the location specified in settings.
26+
Downloads are stored in the location specified by the **Episode download path** setting. This path is a template and **must include a per-episode token** such as `{{title}}` (the default is `PodNotes/{{podcast}}/{{title}}`). A path without `{{title}}` makes every episode resolve to the same file, so downloads overwrite each other or fail. The file extension is added automatically — do not include one.
2727

2828
## Reload Podnotes
2929
This will reload PodNotes.

src/constants.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,11 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = {
6868
},
6969

7070
download: {
71-
path: "",
71+
// Must include a per-episode token ({{title}}). An empty or token-less path
72+
// resolves every episode to the same name — e.g. "" -> ".mp3", a hidden
73+
// dotfile at the vault root that Obsidian never indexes — so the first
74+
// download writes junk and the second throws "File already exists" (#183).
75+
path: "PodNotes/{{podcast}}/{{title}}",
7276
},
7377
downloadedEpisodes: {},
7478
localFiles: {

src/downloadEpisode.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
detectAudioFileExtension,
66
downloadEpisode,
77
getEpisodeAudioBuffer,
8+
safeDownloadBasename,
89
} from "./downloadEpisode";
910
import { downloadedEpisodes } from "./store";
1011
import type { Episode } from "./types/Episode";
@@ -128,6 +129,52 @@ describe("downloadEpisode (API path)", () => {
128129
expect(requestUrlMock).not.toHaveBeenCalled();
129130
expect(createBinary).not.toHaveBeenCalled();
130131
});
132+
133+
it("never writes a '.<ext>' dotfile when the path template is empty (#183)", async () => {
134+
const { createBinary } = setupVault();
135+
const buffer = bytes(0x49, 0x44, 0x33, 0x01);
136+
requestUrlMock.mockResolvedValue({
137+
status: 200,
138+
headers: { "content-type": "audio/mpeg" },
139+
arrayBuffer: buffer,
140+
} as unknown as Awaited<ReturnType<typeof requestUrl>>);
141+
142+
const path = await downloadEpisode(makeEpisode(), "");
143+
144+
// Falls back to a per-episode name instead of the un-indexable ".mp3".
145+
expect(path).toBe("My Title.mp3");
146+
expect(createBinary).toHaveBeenCalledWith("My Title.mp3", buffer);
147+
const [writtenPath] = createBinary.mock.calls[0];
148+
expect(writtenPath).not.toBe(".mp3");
149+
});
150+
});
151+
152+
describe("safeDownloadBasename (#183)", () => {
153+
it("falls back to the title when the template resolves to an empty name", () => {
154+
expect(safeDownloadBasename("", makeEpisode())).toBe("My Title");
155+
});
156+
157+
it("replaces an empty trailing segment but keeps the folder", () => {
158+
expect(safeDownloadBasename("Downloads/", makeEpisode())).toBe(
159+
"Downloads/My Title",
160+
);
161+
});
162+
163+
it("drops a stray leading slash instead of writing an absolute path", () => {
164+
expect(safeDownloadBasename("/{{title}}", makeEpisode())).toBe("My Title");
165+
});
166+
167+
it("falls back to 'episode' when the title is empty/all-illegal", () => {
168+
expect(safeDownloadBasename("", makeEpisode({ title: "??" }))).toBe(
169+
"episode",
170+
);
171+
});
172+
173+
it("leaves a valid per-episode template untouched", () => {
174+
expect(
175+
safeDownloadBasename("podcast/{{podcast}}/{{title}}", makeEpisode()),
176+
).toBe("podcast/Pod/My Title");
177+
});
131178
});
132179

133180
describe("getEpisodeAudioBuffer (issue #107)", () => {

src/downloadEpisode.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,36 @@ function createNoticeDoc(title: string) {
169169
};
170170
}
171171

172+
/**
173+
* Resolves the on-disk basename for a downloaded episode, guaranteeing a
174+
* per-episode file name even when the template is misconfigured.
175+
*
176+
* A template that resolves to an empty final segment — the legacy empty default
177+
* `""`, or any path ending in `/` — would otherwise produce a hidden `".<ext>"`
178+
* dotfile at the vault root that Obsidian never indexes, so the first download
179+
* silently writes junk and the second throws "File already exists" (#183). In
180+
* that case we fall back to the episode title (then a literal "episode" when the
181+
* title is empty or all-illegal). Leading/interior empty segments are dropped so
182+
* a stray slash can never yield an absolute-looking or double-slashed path.
183+
*/
184+
export function safeDownloadBasename(
185+
downloadPathTemplate: string,
186+
episode: Episode,
187+
): string {
188+
const resolved = DownloadPathTemplateEngine(downloadPathTemplate, episode);
189+
const segments = resolved.split("/");
190+
const lastIndex = segments.length - 1;
191+
192+
if (segments[lastIndex].trim() === "") {
193+
segments[lastIndex] =
194+
replaceIllegalFileNameCharactersInString(episode.title) || "episode";
195+
}
196+
197+
return segments
198+
.filter((segment, index) => index === lastIndex || segment.trim() !== "")
199+
.join("/");
200+
}
201+
172202
async function createEpisodeFile({
173203
episode,
174204
downloadPathTemplate,
@@ -180,7 +210,7 @@ async function createEpisodeFile({
180210
data: ArrayBuffer;
181211
extension: string;
182212
}) {
183-
const basename = DownloadPathTemplateEngine(downloadPathTemplate, episode);
213+
const basename = safeDownloadBasename(downloadPathTemplate, episode);
184214
const filePath = `${basename}.${extension}`;
185215

186216
// `createBinary` throws if a parent folder is missing, which previously left
@@ -291,7 +321,7 @@ export async function downloadEpisode(
291321
return localFilePath;
292322
}
293323

294-
const basename = DownloadPathTemplateEngine(downloadPathTemplate, episode);
324+
const basename = safeDownloadBasename(downloadPathTemplate, episode);
295325
const fileExtension = await getFileExtension(episode.streamUrl);
296326
const filePath = `${basename}.${fileExtension}`;
297327

src/main.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Plugin, type WorkspaceLeaf } from "obsidian";
1414
import { API } from "src/API/API";
1515
import type { IAPI } from "src/API/IAPI";
1616
import { DEFAULT_SETTINGS, VIEW_TYPE } from "src/constants";
17+
import { migrateDownloadPath } from "src/settingsMigrations";
1718
import { PodNotesSettingsTab } from "src/ui/settings/PodNotesSettingsTab";
1819
import { MainView } from "src/ui/PodcastView";
1920
import { QueueReorderModal } from "src/ui/QueueReorderModal";
@@ -411,6 +412,15 @@ export default class PodNotes extends Plugin implements IPodNotes {
411412
...DEFAULT_SETTINGS.timestamp,
412413
...(loadedData?.timestamp ?? {}),
413414
};
415+
// Build a fresh download object so we never mutate the shared
416+
// DEFAULT_SETTINGS.download, then migrate the legacy empty default (#183).
417+
this.settings.download = {
418+
...DEFAULT_SETTINGS.download,
419+
...(loadedData?.download ?? {}),
420+
};
421+
this.settings.download.path = migrateDownloadPath(
422+
this.settings.download.path,
423+
);
414424
}
415425

416426
async saveSettings() {

src/settingsMigrations.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it } from "vitest";
2+
import { DEFAULT_SETTINGS } from "./constants";
3+
import {
4+
LEGACY_EMPTY_DOWNLOAD_PATH,
5+
migrateDownloadPath,
6+
} from "./settingsMigrations";
7+
8+
describe("download path default (#183)", () => {
9+
it("is non-empty and contains a per-episode {{title}} token", () => {
10+
expect(DEFAULT_SETTINGS.download.path).not.toBe("");
11+
expect(DEFAULT_SETTINGS.download.path).toMatch(/\{\{\s*title\b/i);
12+
});
13+
14+
it("groups by podcast", () => {
15+
expect(DEFAULT_SETTINGS.download.path).toMatch(/\{\{\s*podcast\b/i);
16+
});
17+
});
18+
19+
describe("migrateDownloadPath (#183)", () => {
20+
it("upgrades the legacy empty default to the current per-episode default", () => {
21+
expect(migrateDownloadPath(LEGACY_EMPTY_DOWNLOAD_PATH)).toBe(
22+
DEFAULT_SETTINGS.download.path,
23+
);
24+
expect(migrateDownloadPath("")).toBe(DEFAULT_SETTINGS.download.path);
25+
});
26+
27+
it("treats an absent value (undefined/null) as the legacy default", () => {
28+
expect(migrateDownloadPath(undefined)).toBe(
29+
DEFAULT_SETTINGS.download.path,
30+
);
31+
// null is reachable via a corrupted/hand-edited data.json; mapping it to the
32+
// default also keeps null out of DownloadPathTemplateEngine (would crash).
33+
expect(migrateDownloadPath(null)).toBe(DEFAULT_SETTINGS.download.path);
34+
});
35+
36+
it("preserves any non-empty custom path verbatim", () => {
37+
expect(migrateDownloadPath("inputs/{{podcast}} - {{title}}")).toBe(
38+
"inputs/{{podcast}} - {{title}}",
39+
);
40+
// Even an unusual (token-less) path the user chose is preserved — the
41+
// migration only ever touches the exact legacy empty value.
42+
expect(migrateDownloadPath("Downloads")).toBe("Downloads");
43+
});
44+
45+
it("is idempotent on the current default", () => {
46+
const once = migrateDownloadPath(DEFAULT_SETTINGS.download.path);
47+
expect(migrateDownloadPath(once)).toBe(DEFAULT_SETTINGS.download.path);
48+
});
49+
});

src/settingsMigrations.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { DEFAULT_SETTINGS } from "./constants";
2+
3+
/**
4+
* Settings migrations applied when settings are loaded from disk.
5+
*
6+
* These run in `PodNotes.loadSettings` after the persisted `data.json` is merged
7+
* over `DEFAULT_SETTINGS`. Changing a default alone is NOT enough for existing
8+
* users: the plugin persists the whole settings object, so anyone who has used
9+
* PodNotes already has the old value written to `data.json`, which overrides the
10+
* new default on load. A migration rewrites that stored value in memory so the
11+
* change actually reaches existing users (it is re-persisted on the next save).
12+
*
13+
* This module is pure (no Obsidian/UI dependencies) so the risky rewrite logic is
14+
* unit-testable.
15+
*/
16+
17+
/**
18+
* The download path used to default to "" (empty). With an empty template the
19+
* Download command resolved every episode to ".mp3" at the vault root — a dotfile
20+
* Obsidian never indexes, so the first download wrote junk and the second threw
21+
* "File already exists" (issue #183). Users who never customized the path have
22+
* this exact value persisted, so simply changing the default does not help them.
23+
*/
24+
export const LEGACY_EMPTY_DOWNLOAD_PATH = "";
25+
26+
/**
27+
* Upgrades the legacy empty download path to the current per-episode default.
28+
*
29+
* ONLY the exact legacy empty value (or an absent value) is migrated, so any
30+
* non-empty path the user deliberately configured is preserved untouched — even
31+
* an unusual one. An empty path is always broken (it can only ever write the
32+
* ".mp3" dotfile), so replacing it is strictly an improvement.
33+
*
34+
* `null`/`undefined` are treated as absence (a missing key, or a corrupted /
35+
* hand-edited data.json), not as a configured path: they map to the default both
36+
* to apply the intended value and to keep a `null` from reaching
37+
* DownloadPathTemplateEngine, where `null.replace(...)` would throw.
38+
*/
39+
export function migrateDownloadPath(
40+
storedPath: string | null | undefined,
41+
): string {
42+
if (
43+
storedPath === undefined ||
44+
storedPath === null ||
45+
storedPath === LEGACY_EMPTY_DOWNLOAD_PATH
46+
) {
47+
return DEFAULT_SETTINGS.download.path;
48+
}
49+
50+
return storedPath;
51+
}

src/ui/PodcastView/spawnEpisodeContextMenu.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,13 @@ export default function spawnEpisodeContextMenu(
6868
if (isDownloaded) {
6969
downloadedEpisodes.removeEpisode(episode, true);
7070
} else {
71-
const downloadPath = get(plugin).settings.download.path;
72-
if (!downloadPath) {
73-
new Notice(`Please set a download path in the settings.`);
74-
return;
75-
}
76-
77-
downloadEpisodeWithProgessNotice(episode, downloadPath);
71+
// The path template always yields a per-episode file via
72+
// safeDownloadBasename (#183), so no empty-path guard is needed —
73+
// this matches the Download command in main.ts.
74+
downloadEpisodeWithProgessNotice(
75+
episode,
76+
get(plugin).settings.download.path,
77+
);
7878
}
7979
}));
8080
}

src/ui/settings/PodNotesSettingsTab.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -322,15 +322,7 @@ export class PodNotesSettingsTab extends PluginSettingTab {
322322
textComponent.onChange((value) => {
323323
this.plugin.settings.download.path = value;
324324
this.plugin.saveSettings();
325-
326-
const demoVal = DownloadPathTemplateEngine(value, randomEpisode);
327-
downloadFilePathDemoEl.empty();
328-
MarkdownRenderer.renderMarkdown(
329-
`${demoVal}.mp3`,
330-
downloadFilePathDemoEl,
331-
"",
332-
new Component(),
333-
);
325+
refreshDownloadPathHints(value);
334326
});
335327
textComponent.inputEl.style.width = "100%";
336328
});
@@ -340,6 +332,33 @@ export class PodNotesSettingsTab extends PluginSettingTab {
340332
downloadPathSetting.settingEl.style.gap = "10px";
341333

342334
const downloadFilePathDemoEl = container.createDiv();
335+
const downloadFilePathWarningEl = container.createDiv();
336+
downloadFilePathWarningEl.style.color = "var(--text-error)";
337+
338+
// A download path without a per-episode token ({{title}}) resolves every
339+
// episode to the same file, so downloads overwrite each other or fail; an
340+
// empty path resolves to ".mp3" at the vault root (#183). Warn inline.
341+
const refreshDownloadPathHints = (value: string) => {
342+
const demoVal = DownloadPathTemplateEngine(value, randomEpisode);
343+
downloadFilePathDemoEl.empty();
344+
MarkdownRenderer.renderMarkdown(
345+
`${demoVal}.mp3`,
346+
downloadFilePathDemoEl,
347+
"",
348+
new Component(),
349+
);
350+
351+
// Match only the forms DownloadPathTemplateEngine actually resolves —
352+
// {{title}} or {{title:...}}. A looser test (e.g. \s* after {{, or \b)
353+
// would wrongly stay silent for "{{ title }}" / "{{title-ish}}", which the
354+
// engine leaves literal so every episode still collides.
355+
downloadFilePathWarningEl.toggle(!/\{\{title(:[^}]*)?\}\}/i.test(value));
356+
downloadFilePathWarningEl.setText(
357+
"⚠ This path has no {{title}}, so multiple episodes can resolve to the same file — downloads will overwrite each other or fail. Add {{title}}, e.g. PodNotes/{{podcast}}/{{title}}.",
358+
);
359+
};
360+
361+
refreshDownloadPathHints(this.plugin.settings.download.path);
343362
}
344363

345364
private addPerformanceSettings(container: HTMLDivElement) {

0 commit comments

Comments
 (0)