Skip to content

Commit 184188c

Browse files
authored
fix: show downloaded episodes in the Local Files playlist (#176) (#177)
* fix: show downloaded episodes in the Local Files playlist (#176) Downloaded episodes were recorded in the downloadedEpisodes store but never added to the localFiles playlist, so the Podcast grid's "Local Files" list stayed empty ("No episodes found") even though the file was on disk and the "Remove file" option appeared in the episode list. Make localFiles a change-gated projection of downloadedEpisodes -- the authoritative offline set, written by both downloads and "Play with PodNotes". Entries are copied verbatim so filePath/size and the real podcastName survive, keeping local-file playback working. Existing downloads are backfilled on load, and removing a downloaded file now also removes it from Local Files. * perf: skip Local Files store write on no-op sync (#177) Addresses PR review feedback: the membership change-gate returned the same playlist object from update(), but Svelte still notifies subscribers for any object value, so LocalFilesController.onChange + saveSettings re-ran on every downloadedEpisodes mutation. Compare against the current store value before calling update() so an unchanged membership skips the store write entirely. Strengthen the no-op test to assert no subscriber notification fires.
1 parent ecd09d5 commit 184188c

5 files changed

Lines changed: 288 additions & 22 deletions

File tree

docs/docs/local_files.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
PodNotes supports local files. You can right-click any audio file and click `Play with PodNotes`.
22

3-
In the podcast grid, you will see a playlist with a folder icon. This is a playlist consisting of all local files (non-downloaded episodes) you have played with PodNotes.
3+
In the podcast grid, you will see a playlist with a folder icon. This is a playlist of every episode you have available offline: both local audio files you have played with `Play with PodNotes` and episodes you have downloaded. Removing a downloaded file also removes it from this playlist.
44

55
PodNotes will attempt to treat all local files as podcast episodes. This means all standard features are available, such as timestamps, note creation, and persisting playback time. Any exceptions will be added here.

src/getContextMenuHandler.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { TFile } from "obsidian";
33
import { get } from "svelte/store";
44
import {
55
downloadedEpisodes,
6-
localFiles,
76
playedEpisodes,
87
currentEpisode,
98
viewState,
@@ -43,13 +42,14 @@ export default function getContextMenuHandler(app: App): EventRef {
4342
localEpisode
4443
)
4544
) {
45+
// The Local Files playlist is mirrored from downloadedEpisodes
46+
// (see localFiles.syncWithDownloaded), so this single write
47+
// surfaces the file there too.
4648
downloadedEpisodes.addEpisode(
4749
localEpisode,
4850
file.path,
4951
file.stat.size
5052
);
51-
52-
localFiles.addEpisode(localEpisode);
5353
}
5454

5555
// Fixes where the episode won't play if it has been played.

src/main.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export default class PodNotes extends Plugin implements IPodNotes {
7373
private hidePlayedEpisodesController?: StoreController<boolean>;
7474
private transcriptionService?: TranscriptionService;
7575
private volumeUnsubscribe?: Unsubscriber;
76+
private localFilesMirrorUnsubscribe?: Unsubscriber;
7677

7778
private maxLayoutReadyAttempts = 10;
7879
private layoutReadyAttempts = 0;
@@ -123,6 +124,14 @@ export default class PodNotes extends Plugin implements IPodNotes {
123124
this,
124125
).on();
125126

127+
// Keep the Local Files playlist in sync with downloaded episodes (issue #176).
128+
// downloadedEpisodes is the authoritative offline set, so mirror it into the
129+
// localFiles playlist that the Podcast grid renders. Svelte's immediate-fire
130+
// backfills already-downloaded episodes on load; later changes keep it current.
131+
this.localFilesMirrorUnsubscribe = downloadedEpisodes.subscribe(
132+
(downloaded) => localFiles.syncWithDownloaded(downloaded),
133+
);
134+
126135
this.api = new API();
127136
this.volumeUnsubscribe = volume.subscribe((value) => {
128137
const clamped = Math.min(1, Math.max(0, value));
@@ -389,6 +398,7 @@ export default class PodNotes extends Plugin implements IPodNotes {
389398
this.currentEpisodeController?.off();
390399
this.hidePlayedEpisodesController?.off();
391400
this.volumeUnsubscribe?.();
401+
this.localFilesMirrorUnsubscribe?.();
392402

393403
// Clean up any active blob URLs to prevent memory leaks
394404
blobUrlManager.revokeAll();

src/store/index.test.ts

Lines changed: 209 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
44
import type { Episode } from "src/types/Episode";
55
import type { IPodNotes } from "src/types/IPodNotes";
66
import type { PlayedEpisode } from "src/types/PlayedEpisode";
7-
import { QUEUE_SETTINGS } from "src/constants";
7+
import type DownloadedEpisode from "src/types/DownloadedEpisode";
8+
import { LOCAL_FILES_SETTINGS, QUEUE_SETTINGS } from "src/constants";
89
import { QueueController } from "src/store_controllers/QueueController";
910
import {
1011
currentEpisode,
1112
dedupeEpisodesByTitle,
13+
downloadedEpisodes,
14+
localFiles,
1215
playedEpisodes,
1316
queue,
1417
reorderEpisodes,
@@ -76,6 +79,211 @@ describe("playedEpisodes store", () => {
7679
});
7780
});
7881

82+
function downloadedEpisode(
83+
podcastName: string,
84+
title: string,
85+
overrides: Partial<DownloadedEpisode> = {},
86+
): DownloadedEpisode {
87+
return {
88+
title,
89+
streamUrl: `https://example.com/${title}.mp3`,
90+
url: `https://example.com/${title}`,
91+
description: "",
92+
content: "",
93+
podcastName,
94+
filePath: `podcasts/${podcastName} ${title}.mp3`,
95+
size: 1024,
96+
...overrides,
97+
};
98+
}
99+
100+
describe("localFiles store — syncWithDownloaded (issue #176)", () => {
101+
beforeEach(() => {
102+
downloadedEpisodes.set({});
103+
localFiles.set({
104+
...LOCAL_FILES_SETTINGS,
105+
episodes: [],
106+
});
107+
});
108+
109+
test("mirrors downloaded episodes across every podcast bucket", () => {
110+
localFiles.syncWithDownloaded({
111+
"Podcast A": [downloadedEpisode("Podcast A", "Ep 1")],
112+
"Podcast B": [
113+
downloadedEpisode("Podcast B", "Ep 2"),
114+
downloadedEpisode("Podcast B", "Ep 3"),
115+
],
116+
});
117+
118+
const titles = get(localFiles).episodes.map((ep) => ep.title);
119+
expect(titles).toEqual(["Ep 1", "Ep 2", "Ep 3"]);
120+
});
121+
122+
test("keeps same-title episodes from different podcasts (composite-key dedup)", () => {
123+
localFiles.syncWithDownloaded({
124+
"Podcast A": [downloadedEpisode("Podcast A", "Shared title")],
125+
"Podcast B": [downloadedEpisode("Podcast B", "Shared title")],
126+
});
127+
128+
const episodes = get(localFiles).episodes;
129+
expect(episodes).toHaveLength(2);
130+
expect(episodes.map((ep) => ep.podcastName).sort()).toEqual([
131+
"Podcast A",
132+
"Podcast B",
133+
]);
134+
});
135+
136+
test("preserves filePath/size and the real podcastName (not coerced)", () => {
137+
localFiles.syncWithDownloaded({
138+
"Real Podcast": [
139+
downloadedEpisode("Real Podcast", "Ep 1", {
140+
filePath: "podcasts/real.mp3",
141+
size: 4096,
142+
}),
143+
],
144+
});
145+
146+
const [ep] = get(localFiles).episodes;
147+
expect(ep.podcastName).toBe("Real Podcast");
148+
expect((ep as DownloadedEpisode).filePath).toBe("podcasts/real.mp3");
149+
expect((ep as DownloadedEpisode).size).toBe(4096);
150+
});
151+
152+
test("keeps manual local files with podcastName 'local file'", () => {
153+
localFiles.syncWithDownloaded({
154+
"local file": [downloadedEpisode("local file", "My Recording")],
155+
});
156+
157+
const [ep] = get(localFiles).episodes;
158+
expect(ep.podcastName).toBe("local file");
159+
});
160+
161+
test("removal from downloadedEpisodes propagates to the playlist", () => {
162+
const map = {
163+
"Podcast A": [
164+
downloadedEpisode("Podcast A", "Ep 1"),
165+
downloadedEpisode("Podcast A", "Ep 2"),
166+
],
167+
};
168+
localFiles.syncWithDownloaded(map);
169+
expect(get(localFiles).episodes).toHaveLength(2);
170+
171+
localFiles.syncWithDownloaded({
172+
"Podcast A": [downloadedEpisode("Podcast A", "Ep 2")],
173+
});
174+
175+
const titles = get(localFiles).episodes.map((ep) => ep.title);
176+
expect(titles).toEqual(["Ep 2"]);
177+
});
178+
179+
test("empty map clears the playlist but preserves its metadata", () => {
180+
localFiles.syncWithDownloaded({
181+
"Podcast A": [downloadedEpisode("Podcast A", "Ep 1")],
182+
});
183+
184+
localFiles.syncWithDownloaded({});
185+
186+
const playlist = get(localFiles);
187+
expect(playlist.episodes).toEqual([]);
188+
expect(playlist.name).toBe(LOCAL_FILES_SETTINGS.name);
189+
expect(playlist.icon).toBe(LOCAL_FILES_SETTINGS.icon);
190+
});
191+
192+
test("is a no-op when membership is unchanged (no store notification)", () => {
193+
localFiles.syncWithDownloaded({
194+
"Podcast A": [downloadedEpisode("Podcast A", "Ep 1")],
195+
});
196+
const before = get(localFiles);
197+
198+
// Svelte notifies subscribers for every object value (safe_not_equal treats
199+
// objects as always-changed), so a no-op must not touch the store at all --
200+
// otherwise LocalFilesController.onChange + saveSettings would re-run.
201+
let notifications = 0;
202+
const unsubscribe = localFiles.subscribe(() => {
203+
notifications += 1;
204+
});
205+
expect(notifications).toBe(1); // immediate fire on subscribe
206+
207+
localFiles.syncWithDownloaded({
208+
"Podcast A": [downloadedEpisode("Podcast A", "Ep 1")],
209+
});
210+
expect(notifications).toBe(1); // unchanged membership -> no extra notification
211+
expect(get(localFiles)).toBe(before); // store value object reused
212+
213+
// A real membership change does notify.
214+
localFiles.syncWithDownloaded({
215+
"Podcast A": [
216+
downloadedEpisode("Podcast A", "Ep 1"),
217+
downloadedEpisode("Podcast A", "Ep 2"),
218+
],
219+
});
220+
expect(notifications).toBe(2);
221+
222+
unsubscribe();
223+
});
224+
225+
test("getLocalEpisode prefers a manual local file over a same-titled download", () => {
226+
localFiles.syncWithDownloaded({
227+
"Real Podcast": [downloadedEpisode("Real Podcast", "Collision")],
228+
"local file": [
229+
downloadedEpisode("local file", "Collision", {
230+
filePath: "recordings/collision.mp3",
231+
}),
232+
],
233+
});
234+
235+
expect(localFiles.getLocalEpisode("Collision")?.podcastName).toBe(
236+
"local file",
237+
);
238+
});
239+
240+
test("dedupes identical composite keys, keeping the first occurrence", () => {
241+
localFiles.syncWithDownloaded({
242+
"Podcast A": [
243+
downloadedEpisode("Podcast A", "Dup", { filePath: "first.mp3" }),
244+
downloadedEpisode("Podcast A", "Dup", { filePath: "second.mp3" }),
245+
],
246+
});
247+
248+
const episodes = get(localFiles).episodes;
249+
expect(episodes).toHaveLength(1);
250+
expect((episodes[0] as DownloadedEpisode).filePath).toBe("first.mp3");
251+
});
252+
253+
test("skips entries with no usable episode key", () => {
254+
localFiles.syncWithDownloaded({
255+
"Podcast A": [
256+
downloadedEpisode("Podcast A", ""),
257+
downloadedEpisode("Podcast A", "Valid"),
258+
],
259+
});
260+
261+
const titles = get(localFiles).episodes.map((ep) => ep.title);
262+
expect(titles).toEqual(["Valid"]);
263+
});
264+
265+
test("surfaces a file added only to downloadedEpisodes (getContextMenuHandler flow)", () => {
266+
// getContextMenuHandler now writes a manual local file only to
267+
// downloadedEpisodes and relies on the projection to surface it.
268+
downloadedEpisodes.addEpisode(
269+
downloadedEpisode("local file", "My Recording", {
270+
filePath: "recordings/my recording.m4a",
271+
}),
272+
"recordings/my recording.m4a",
273+
2048,
274+
);
275+
276+
localFiles.syncWithDownloaded(get(downloadedEpisodes));
277+
278+
const [ep] = get(localFiles).episodes;
279+
expect(ep?.title).toBe("My Recording");
280+
expect(ep?.podcastName).toBe("local file");
281+
expect((ep as DownloadedEpisode).filePath).toBe(
282+
"recordings/my recording.m4a",
283+
);
284+
});
285+
});
286+
79287
function ep(title: string, podcastName = "Pod"): Episode {
80288
return {
81289
title,

0 commit comments

Comments
 (0)