diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts
index c3a7a01..7538547 100644
--- a/src/TemplateEngine.ts
+++ b/src/TemplateEngine.ts
@@ -4,6 +4,7 @@ import { plugin } from "src/store";
import { get } from "svelte/store";
import type { Episode } from "src/types/Episode";
import getUrlExtension from "./utility/getUrlExtension";
+import { formatDate } from "./utility/formatDate";
type TagValue = string | ((...args: string[]) => string);
@@ -103,7 +104,7 @@ export function NoteTemplateEngine(template: string, episode: Episode) {
addTag("url", episode.url);
addTag("date", (format?: string) =>
episode.episodeDate
- ? window.moment(episode.episodeDate).format(format ?? "YYYY-MM-DD")
+ ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD")
: "",
);
addTag(
@@ -153,7 +154,7 @@ export function FilePathTemplateEngine(template: string, episode: Episode) {
});
addTag("date", (format?: string) =>
episode.episodeDate
- ? window.moment(episode.episodeDate).format(format ?? "YYYY-MM-DD")
+ ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD")
: "",
);
@@ -189,7 +190,7 @@ export function DownloadPathTemplateEngine(template: string, episode: Episode) {
});
addTag("date", (format?: string) =>
episode.episodeDate
- ? window.moment(episode.episodeDate).format(format ?? "YYYY-MM-DD")
+ ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD")
: "",
);
@@ -221,7 +222,7 @@ export function TranscriptTemplateEngine(
});
addTag("date", (format?: string) =>
episode.episodeDate
- ? window.moment(episode.episodeDate).format(format ?? "YYYY-MM-DD")
+ ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD")
: "",
);
addTag("transcript", transcription);
diff --git a/src/iTunesAPIConsumer.ts b/src/iTunesAPIConsumer.ts
index 75d707e..68f7eff 100644
--- a/src/iTunesAPIConsumer.ts
+++ b/src/iTunesAPIConsumer.ts
@@ -1,5 +1,16 @@
-import { requestUrl } from "obsidian";
import type { PodcastFeed } from "./types/PodcastFeed";
+import { requestWithTimeout, NetworkError } from "./utility/networkRequest";
+
+interface iTunesResult {
+ collectionName: string;
+ feedUrl: string;
+ artworkUrl100: string;
+ collectionId: string;
+}
+
+interface iTunesSearchResponse {
+ results: iTunesResult[];
+}
export async function queryiTunesPodcasts(query: string): Promise {
const url = new URL("https://itunes.apple.com/search?");
@@ -8,13 +19,20 @@ export async function queryiTunesPodcasts(query: string): Promise
url.searchParams.append("limit", "3");
url.searchParams.append("kind", "podcast");
- const res = await requestUrl({ url: url.href });
- const data = res.json.results;
+ try {
+ const response = await requestWithTimeout(url.href, { timeoutMs: 15000 });
+ const data = response.json as iTunesSearchResponse;
- return data.map((d: { collectionName: string, feedUrl: string, artworkUrl100: string, collectionId: string }) => ({
- title: d.collectionName,
- url: d.feedUrl,
- artworkUrl: d.artworkUrl100,
- collectionId: d.collectionId
- }));
+ return (data.results || []).map((d) => ({
+ title: d.collectionName,
+ url: d.feedUrl,
+ artworkUrl: d.artworkUrl100,
+ collectionId: d.collectionId,
+ }));
+ } catch (error) {
+ if (error instanceof NetworkError) {
+ console.error(`iTunes search failed: ${error.message}`);
+ }
+ return [];
+ }
}
diff --git a/src/main.ts b/src/main.ts
index 87f0d5f..8a97229 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -10,6 +10,7 @@ import {
hidePlayedEpisodes,
volume,
} from "src/store";
+import { blobUrlManager } from "src/utility/createMediaUrlObjectFromFilePath";
import { Plugin, type WorkspaceLeaf } from "obsidian";
import { API } from "src/API/API";
import type { IAPI } from "src/API/IAPI";
@@ -374,6 +375,12 @@ export default class PodNotes extends Plugin implements IPodNotes {
this.currentEpisodeController?.off();
this.hidePlayedEpisodesController?.off();
this.volumeUnsubscribe?.();
+
+ // Clean up any active blob URLs to prevent memory leaks
+ blobUrlManager.revokeAll();
+
+ // Detach all leaves of this view type to prevent duplicates on reload
+ this.app.workspace.detachLeavesOfType(VIEW_TYPE);
}
async loadSettings() {
diff --git a/src/opml.ts b/src/opml.ts
index 3347fcb..addfe99 100644
--- a/src/opml.ts
+++ b/src/opml.ts
@@ -7,7 +7,8 @@ import { get } from "svelte/store";
function TimerNotice(heading: string, initialMessage: string) {
let currentMessage = initialMessage;
const startTime = Date.now();
- let stopTime: number;
+ let stopTime: number | undefined;
+ let intervalId: ReturnType | undefined;
const notice = new Notice(initialMessage, 0);
function formatMsg(message: string): string {
@@ -19,20 +20,30 @@ function TimerNotice(heading: string, initialMessage: string) {
notice.setMessage(formatMsg(currentMessage));
}
- const interval = setInterval(() => {
- notice.setMessage(formatMsg(currentMessage));
- }, 1000);
-
function getTime(): string {
return formatTime(stopTime ? stopTime - startTime : Date.now() - startTime);
}
+ function clearTimer() {
+ if (intervalId !== undefined) {
+ clearInterval(intervalId);
+ intervalId = undefined;
+ }
+ }
+
+ intervalId = setInterval(() => {
+ notice.setMessage(formatMsg(currentMessage));
+ }, 1000);
+
return {
update,
- hide: () => notice.hide(),
+ hide: () => {
+ clearTimer();
+ notice.hide();
+ },
stop: () => {
stopTime = Date.now();
- clearInterval(interval);
+ clearTimer();
},
};
}
diff --git a/src/parser/feedParser.test.ts b/src/parser/feedParser.test.ts
new file mode 100644
index 0000000..5084f5e
--- /dev/null
+++ b/src/parser/feedParser.test.ts
@@ -0,0 +1,582 @@
+import { describe, expect, test, vi, beforeEach } from "vitest";
+import FeedParser from "./feedParser";
+import type { PodcastFeed } from "src/types/PodcastFeed";
+
+vi.mock("src/utility/networkRequest", () => ({
+ requestWithTimeout: vi.fn(),
+}));
+
+import { requestWithTimeout } from "src/utility/networkRequest";
+
+const mockRequestWithTimeout = vi.mocked(requestWithTimeout);
+
+const sampleRssFeed = `
+
+
+ Test Podcast
+ https://example.com
+
+ https://example.com/artwork.jpg
+
+ -
+ Episode 1
+
+ https://example.com/episode1
+ First episode description
+ Mon, 01 Jan 2024 00:00:00 GMT
+ Episode 1 iTunes Title
+
+ -
+ Episode 2
+
+ https://example.com/episode2
+ Second episode description
+ Tue, 02 Jan 2024 00:00:00 GMT
+
+
+`;
+
+const sampleRssFeedWithItunesImage = `
+
+
+ Test Podcast With iTunes Image
+ https://example.com
+
+ -
+ Episode 1
+
+ Mon, 01 Jan 2024 00:00:00 GMT
+
+
+
+`;
+
+const invalidRssFeed = `
+
+
+ Missing title and link
+
+`;
+
+const rssFeedWithInvalidItem = `
+
+
+ Test Podcast
+ https://example.com
+ -
+ Valid Episode
+
+ Mon, 01 Jan 2024 00:00:00 GMT
+
+ -
+ Invalid Episode - Missing enclosure
+ Tue, 02 Jan 2024 00:00:00 GMT
+
+ -
+ Invalid Episode - Missing pubDate
+
+
+
+`;
+
+describe("FeedParser", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("getFeed", () => {
+ test("parses feed title and URL correctly", async () => {
+ mockRequestWithTimeout.mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+ const feed = await parser.getFeed("https://example.com/feed.xml");
+
+ expect(feed.title).toBe("Test Podcast");
+ expect(feed.url).toBe("https://example.com/feed.xml");
+ });
+
+ test("parses artwork URL from image element", async () => {
+ mockRequestWithTimeout.mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+ const feed = await parser.getFeed("https://example.com/feed.xml");
+
+ expect(feed.artworkUrl).toBe("https://example.com/artwork.jpg");
+ });
+
+ test("parses artwork URL from itunes:image href attribute", async () => {
+ mockRequestWithTimeout.mockResolvedValueOnce({
+ text: sampleRssFeedWithItunesImage,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+ const feed = await parser.getFeed("https://example.com/feed.xml");
+
+ expect(feed.artworkUrl).toBe("https://example.com/itunes-artwork.jpg");
+ });
+
+ test("throws error for invalid RSS feed without title", async () => {
+ mockRequestWithTimeout.mockResolvedValueOnce({
+ text: invalidRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+
+ await expect(parser.getFeed("https://example.com/feed.xml")).rejects.toThrow(
+ "Invalid RSS feed",
+ );
+ });
+ });
+
+ describe("getEpisodes", () => {
+ test("parses all valid episodes from feed", async () => {
+ // getEpisodes now calls getFeed first, then parseFeed again
+ mockRequestWithTimeout
+ .mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ })
+ .mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+ const episodes = await parser.getEpisodes("https://example.com/feed.xml");
+
+ expect(episodes).toHaveLength(2);
+ expect(episodes[0].title).toBe("Episode 1");
+ expect(episodes[1].title).toBe("Episode 2");
+ });
+
+ test("parses episode properties correctly and populates feed metadata", async () => {
+ mockRequestWithTimeout
+ .mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ })
+ .mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+ const episodes = await parser.getEpisodes("https://example.com/feed.xml");
+
+ const episode = episodes[0];
+ expect(episode.title).toBe("Episode 1");
+ expect(episode.streamUrl).toBe("https://example.com/episode1.mp3");
+ expect(episode.url).toBe("https://example.com/episode1");
+ expect(episode.description).toBe("First episode description");
+ expect(episode.episodeDate).toEqual(new Date("Mon, 01 Jan 2024 00:00:00 GMT"));
+ expect(episode.itunesTitle).toBe("Episode 1 iTunes Title");
+ // Feed metadata should now be populated
+ expect(episode.podcastName).toBe("Test Podcast");
+ expect(episode.feedUrl).toBe("https://example.com/feed.xml");
+ });
+
+ test("filters out invalid episodes missing required fields", async () => {
+ mockRequestWithTimeout
+ .mockResolvedValueOnce({
+ text: rssFeedWithInvalidItem,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ })
+ .mockResolvedValueOnce({
+ text: rssFeedWithInvalidItem,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+ const episodes = await parser.getEpisodes("https://example.com/feed.xml");
+
+ expect(episodes).toHaveLength(1);
+ expect(episodes[0].title).toBe("Valid Episode");
+ });
+
+ test("uses feed artwork when episode has no artwork", async () => {
+ const mockFeed: PodcastFeed = {
+ title: "Test Podcast",
+ url: "https://example.com/feed.xml",
+ artworkUrl: "https://example.com/feed-artwork.jpg",
+ };
+
+ mockRequestWithTimeout.mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ // When constructed with a feed, it skips calling getFeed
+ const parser = new FeedParser(mockFeed);
+ const episodes = await parser.getEpisodes("https://example.com/feed.xml");
+
+ expect(episodes[1].artworkUrl).toBe("https://example.com/feed-artwork.jpg");
+ });
+
+ test("uses episode artwork from itunes:image when available", async () => {
+ mockRequestWithTimeout
+ .mockResolvedValueOnce({
+ text: sampleRssFeedWithItunesImage,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ })
+ .mockResolvedValueOnce({
+ text: sampleRssFeedWithItunesImage,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+ const episodes = await parser.getEpisodes("https://example.com/feed.xml");
+
+ expect(episodes[0].artworkUrl).toBe("https://example.com/episode1-artwork.jpg");
+ });
+ });
+
+ describe("findItemByTitle", () => {
+ test("finds episode by exact title match", async () => {
+ mockRequestWithTimeout
+ .mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ })
+ .mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+ const episode = await parser.findItemByTitle(
+ "Episode 1",
+ "https://example.com/feed.xml",
+ );
+
+ expect(episode.title).toBe("Episode 1");
+ expect(episode.streamUrl).toBe("https://example.com/episode1.mp3");
+ });
+
+ test("throws error when episode not found", async () => {
+ mockRequestWithTimeout
+ .mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ })
+ .mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+
+ await expect(
+ parser.findItemByTitle("Non-existent Episode", "https://example.com/feed.xml"),
+ ).rejects.toThrow("Could not find episode");
+ });
+
+ test("finds episode with case-insensitive matching", async () => {
+ mockRequestWithTimeout
+ .mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ })
+ .mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+ const episode = await parser.findItemByTitle(
+ "EPISODE 1",
+ "https://example.com/feed.xml",
+ );
+
+ expect(episode.title).toBe("Episode 1");
+ });
+
+ test("finds episode with whitespace trimming", async () => {
+ mockRequestWithTimeout
+ .mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ })
+ .mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+ const episode = await parser.findItemByTitle(
+ " Episode 1 ",
+ "https://example.com/feed.xml",
+ );
+
+ expect(episode.title).toBe("Episode 1");
+ });
+
+ test("fills in missing episode data from feed", async () => {
+ const feedWithMissingEpisodeData = `
+
+
+ Feed Title
+ https://example.com
+
+ https://example.com/feed-artwork.jpg
+
+ -
+ Episode Without Artwork
+
+ Mon, 01 Jan 2024 00:00:00 GMT
+
+
+`;
+
+ mockRequestWithTimeout
+ .mockResolvedValueOnce({
+ text: feedWithMissingEpisodeData,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ })
+ .mockResolvedValueOnce({
+ text: feedWithMissingEpisodeData,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+ const episode = await parser.findItemByTitle(
+ "Episode Without Artwork",
+ "https://example.com/feed.xml",
+ );
+
+ expect(episode.artworkUrl).toBe("https://example.com/feed-artwork.jpg");
+ expect(episode.podcastName).toBe("Feed Title");
+ expect(episode.feedUrl).toBe("https://example.com/feed.xml");
+ });
+ });
+
+ describe("constructor", () => {
+ test("accepts optional feed parameter", () => {
+ const mockFeed: PodcastFeed = {
+ title: "Test Podcast",
+ url: "https://example.com/feed.xml",
+ artworkUrl: "https://example.com/artwork.jpg",
+ };
+
+ const parser = new FeedParser(mockFeed);
+ expect(parser).toBeInstanceOf(FeedParser);
+ });
+
+ test("works without feed parameter", () => {
+ const parser = new FeedParser();
+ expect(parser).toBeInstanceOf(FeedParser);
+ });
+ });
+
+ describe("edge cases", () => {
+ test("handles empty feed with no items", async () => {
+ const emptyFeed = `
+
+
+ Empty Podcast
+ https://example.com
+
+`;
+
+ mockRequestWithTimeout
+ .mockResolvedValueOnce({
+ text: emptyFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ })
+ .mockResolvedValueOnce({
+ text: emptyFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+ const episodes = await parser.getEpisodes("https://example.com/feed.xml");
+
+ expect(episodes).toHaveLength(0);
+ });
+
+ test("handles missing optional fields gracefully", async () => {
+ const minimalFeed = `
+
+
+ Minimal Podcast
+ https://example.com
+ -
+ Minimal Episode
+
+ Mon, 01 Jan 2024 00:00:00 GMT
+
+
+`;
+
+ mockRequestWithTimeout
+ .mockResolvedValueOnce({
+ text: minimalFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ })
+ .mockResolvedValueOnce({
+ text: minimalFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+ const episodes = await parser.getEpisodes("https://example.com/feed.xml");
+
+ expect(episodes).toHaveLength(1);
+ expect(episodes[0].description).toBe("");
+ expect(episodes[0].content).toBe("");
+ // url falls back to feed.url when episode link is missing
+ expect(episodes[0].url).toBe("https://example.com/feed.xml");
+ });
+
+ test("handles CDATA content in description", async () => {
+ const cdataFeed = `
+
+
+ CDATA Podcast
+ https://example.com
+ -
+ CDATA Episode
+
+ HTML description
]]>
+ Mon, 01 Jan 2024 00:00:00 GMT
+
+
+`;
+
+ mockRequestWithTimeout
+ .mockResolvedValueOnce({
+ text: cdataFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ })
+ .mockResolvedValueOnce({
+ text: cdataFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+ const episodes = await parser.getEpisodes("https://example.com/feed.xml");
+
+ expect(episodes[0].description).toBe("HTML description
");
+ });
+
+ test("getFeed sets internal feed state for subsequent calls", async () => {
+ mockRequestWithTimeout
+ .mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ })
+ .mockResolvedValueOnce({
+ text: sampleRssFeed,
+ status: 200,
+ headers: {},
+ arrayBuffer: new ArrayBuffer(0),
+ json: {},
+ });
+
+ const parser = new FeedParser();
+ await parser.getFeed("https://example.com/feed.xml");
+ const episodes = await parser.getEpisodes("https://example.com/feed.xml");
+
+ // Episodes should have feed metadata populated
+ expect(episodes[0].podcastName).toBe("Test Podcast");
+ expect(episodes[0].feedUrl).toBe("https://example.com/feed.xml");
+ });
+ });
+});
diff --git a/src/parser/feedParser.ts b/src/parser/feedParser.ts
index a71d200..56be610 100644
--- a/src/parser/feedParser.ts
+++ b/src/parser/feedParser.ts
@@ -1,6 +1,6 @@
import type { PodcastFeed } from "src/types/PodcastFeed";
-import { requestUrl } from "obsidian";
import type { Episode } from "src/types/Episode";
+import { requestWithTimeout } from "src/utility/networkRequest";
export default class FeedParser {
private feed: PodcastFeed | undefined;
@@ -10,43 +10,50 @@ export default class FeedParser {
}
public async findItemByTitle(title: string, url: string): Promise {
+ // Ensure feed metadata is loaded first
+ if (!this.feed || this.feed.url !== url) {
+ await this.getFeed(url);
+ }
+
const body = await this.parseFeed(url);
const items = body.querySelectorAll("item");
+ const target = title.trim().toLowerCase();
- const item = Array.from(items).find((item) => {
- const parsed = this.parseItem(item);
- const isMatch = parsed && parsed.title === title;
-
- return isMatch;
- });
-
- if (!item) {
- throw new Error("Could not find episode");
- }
+ // Parse all items once and find by case-insensitive match
+ const episodes = Array.from(items)
+ .map((item) => this.parseItem(item))
+ .filter((ep): ep is Episode => !!ep);
- const episode = this.parseItem(item);
- const feed = await this.getFeed(url);
+ const episode = episodes.find(
+ (ep) => ep.title.trim().toLowerCase() === target,
+ );
if (!episode) {
- throw new Error("Episode is invalid.");
+ throw new Error("Could not find episode");
}
- if (!episode.artworkUrl) {
- episode.artworkUrl = feed.artworkUrl;
+ // Fill in any missing fields from feed metadata
+ if (!episode.artworkUrl && this.feed) {
+ episode.artworkUrl = this.feed.artworkUrl;
}
- if (!episode.podcastName) {
- episode.podcastName = feed.title;
+ if (!episode.podcastName && this.feed) {
+ episode.podcastName = this.feed.title;
}
- if (!episode.feedUrl) {
- episode.feedUrl = feed.url;
+ if (!episode.feedUrl && this.feed) {
+ episode.feedUrl = this.feed.url;
}
return episode;
}
public async getEpisodes(url: string): Promise {
+ // Ensure feed metadata is loaded and cached
+ if (!this.feed || this.feed.url !== url) {
+ await this.getFeed(url);
+ }
+
const body = await this.parseFeed(url);
return this.parsePage(body);
@@ -57,7 +64,7 @@ export default class FeedParser {
const titleEl = body.querySelector("title");
const linkEl = body.querySelector("link");
- const itunesImageEl = body.querySelector("image");
+ const itunesImageEl = this.findImageElement(body);
if (!titleEl || !linkEl) {
throw new Error("Invalid RSS feed");
@@ -69,11 +76,23 @@ export default class FeedParser {
itunesImageEl?.querySelector("url")?.textContent ||
"";
- return {
+ const feed: PodcastFeed = {
title,
url,
artworkUrl,
};
+
+ this.feed = feed;
+ return feed;
+ }
+
+ private findImageElement(doc: Document | Element): Element | null {
+ // Try iTunes-specific first (handles )
+ const itunesImage = doc.getElementsByTagName("itunes:image")[0];
+ if (itunesImage) return itunesImage;
+
+ // Fallback to generic element
+ return doc.querySelector("image");
}
protected parsePage(page: Document): Episode[] {
@@ -93,8 +112,9 @@ export default class FeedParser {
const descriptionEl = item.querySelector("description");
const contentEl = item.querySelector("*|encoded");
const pubDateEl = item.querySelector("pubDate");
- const itunesImageEl = item.querySelector("image");
+ const itunesImageEl = this.findImageElement(item);
const itunesTitleEl = item.getElementsByTagName("itunes:title")[0];
+ const chaptersEl = item.getElementsByTagName("podcast:chapters")[0];
if (!titleEl || !streamUrlEl || !pubDateEl) {
return null;
@@ -109,6 +129,7 @@ export default class FeedParser {
const artworkUrl =
itunesImageEl?.getAttribute("href") || this.feed?.artworkUrl;
const itunesTitle = itunesTitleEl?.textContent;
+ const chaptersUrl = chaptersEl?.getAttribute("url") || undefined;
return {
title,
@@ -121,11 +142,12 @@ export default class FeedParser {
episodeDate: pubDate,
feedUrl: this.feed?.url || "",
itunesTitle: itunesTitle || "",
+ chaptersUrl,
};
}
private async parseFeed(feedUrl: string): Promise {
- const req = await requestUrl({ url: feedUrl });
+ const req = await requestWithTimeout(feedUrl, { timeoutMs: 30000 });
const dp = new DOMParser();
const body = dp.parseFromString(req.text, "text/xml");
diff --git a/src/services/FeedCacheService.ts b/src/services/FeedCacheService.ts
index c7a998a..f8bc9ba 100644
--- a/src/services/FeedCacheService.ts
+++ b/src/services/FeedCacheService.ts
@@ -15,6 +15,7 @@ type FeedCache = Record;
const STORAGE_KEY = "podnotes:feed-cache:v1";
const DEFAULT_TTL_MS = 1000 * 60 * 60 * 6; // 6 hours.
const MAX_EPISODES_PER_FEED = 75;
+const MAX_CACHE_SIZE_BYTES = 4 * 1024 * 1024; // 4MB to leave room for other localStorage usage
let cache: FeedCache | null = null;
@@ -55,16 +56,67 @@ function loadCache(): FeedCache {
}
}
+function evictOldestEntries(cacheData: FeedCache, targetSizeBytes: number): FeedCache {
+ const entries = Object.entries(cacheData);
+
+ // Sort by updatedAt ascending (oldest first)
+ entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
+
+ const result: FeedCache = {};
+ let currentSize = 0;
+
+ // Add entries from newest to oldest until we exceed target size
+ for (let i = entries.length - 1; i >= 0; i--) {
+ const [key, value] = entries[i];
+ const entrySize = JSON.stringify({ [key]: value }).length;
+
+ if (currentSize + entrySize <= targetSizeBytes) {
+ result[key] = value;
+ currentSize += entrySize;
+ }
+ }
+
+ return result;
+}
+
function persistCache(): void {
const storage = getStorage();
- if (!storage) {
+ if (!storage || !cache) {
return;
}
try {
- storage.setItem(STORAGE_KEY, JSON.stringify(cache ?? {}));
+ let serialized = JSON.stringify(cache);
+
+ // If cache is too large, evict oldest entries
+ if (serialized.length > MAX_CACHE_SIZE_BYTES) {
+ console.warn(
+ `Feed cache size (${serialized.length} bytes) exceeds limit, evicting old entries`,
+ );
+ cache = evictOldestEntries(cache, MAX_CACHE_SIZE_BYTES * 0.8); // Target 80% of max
+ serialized = JSON.stringify(cache);
+ }
+
+ storage.setItem(STORAGE_KEY, serialized);
} catch (error) {
- console.error("Failed to persist feed cache:", error);
+ // Handle quota exceeded error specifically
+ if (
+ error instanceof DOMException &&
+ (error.name === "QuotaExceededError" ||
+ error.name === "NS_ERROR_DOM_QUOTA_REACHED")
+ ) {
+ console.warn("localStorage quota exceeded, clearing feed cache");
+ try {
+ // Clear cache and try again with empty cache
+ cache = {};
+ storage.setItem(STORAGE_KEY, "{}");
+ } catch {
+ // If we still can't write, just clear the item
+ storage.removeItem(STORAGE_KEY);
+ }
+ } else {
+ console.error("Failed to persist feed cache:", error);
+ }
}
}
diff --git a/src/services/TranscriptionService.test.ts b/src/services/TranscriptionService.test.ts
new file mode 100644
index 0000000..01b1032
--- /dev/null
+++ b/src/services/TranscriptionService.test.ts
@@ -0,0 +1,303 @@
+import { describe, expect, test, vi, beforeEach } from "vitest";
+import { TranscriptionService } from "./TranscriptionService";
+import type { Episode } from "src/types/Episode";
+import type PodNotes from "src/main";
+
+const mockEpisode: Episode = {
+ title: "Test Episode",
+ streamUrl: "https://example.com/episode.mp3",
+ url: "https://example.com/episode",
+ description: "Test description",
+ content: "Test content",
+ podcastName: "Test Podcast",
+ feedUrl: "https://example.com/feed.xml",
+ artworkUrl: "https://example.com/artwork.jpg",
+ episodeDate: new Date("2024-01-01"),
+};
+
+function createMockPlugin(overrides: {
+ openAIApiKey?: string;
+ podcast?: Episode | null;
+ existingTranscriptPath?: string | null;
+} = {}): PodNotes {
+ const {
+ openAIApiKey = "test-api-key",
+ podcast = mockEpisode,
+ existingTranscriptPath = null,
+ } = overrides;
+
+ return {
+ settings: {
+ openAIApiKey,
+ transcript: {
+ path: "Transcripts/{{podcast}}/{{title}}.md",
+ template: "# {{title}}\n\n{{transcript}}",
+ },
+ download: {
+ path: "Downloads",
+ },
+ },
+ api: {
+ podcast,
+ },
+ app: {
+ vault: {
+ getAbstractFileByPath: vi.fn((path: string) => {
+ if (existingTranscriptPath && path === existingTranscriptPath) {
+ return { path };
+ }
+ return null;
+ }),
+ readBinary: vi.fn(),
+ create: vi.fn(),
+ createFolder: vi.fn(),
+ },
+ workspace: {
+ getLeaf: vi.fn(() => ({
+ openFile: vi.fn(),
+ })),
+ },
+ },
+ } as unknown as PodNotes;
+}
+
+describe("TranscriptionService", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("formatTime", () => {
+ test("formats time correctly for seconds", () => {
+ const formatTime = (ms: number): string => {
+ const seconds = Math.floor(ms / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ return `${hours.toString().padStart(2, "0")}:${(minutes % 60).toString().padStart(2, "0")}:${(seconds % 60).toString().padStart(2, "0")}`;
+ };
+
+ expect(formatTime(0)).toBe("00:00:00");
+ expect(formatTime(1000)).toBe("00:00:01");
+ expect(formatTime(60000)).toBe("00:01:00");
+ expect(formatTime(3600000)).toBe("01:00:00");
+ expect(formatTime(3661000)).toBe("01:01:01");
+ });
+ });
+
+ describe("getMimeType", () => {
+ test("returns correct mime types for audio formats", () => {
+ const getMimeType = (fileExtension: string): string => {
+ switch (fileExtension.toLowerCase()) {
+ case "mp3":
+ return "audio/mp3";
+ case "m4a":
+ return "audio/mp4";
+ case "ogg":
+ return "audio/ogg";
+ case "wav":
+ return "audio/wav";
+ case "flac":
+ return "audio/flac";
+ default:
+ return "audio/mpeg";
+ }
+ };
+
+ expect(getMimeType("mp3")).toBe("audio/mp3");
+ expect(getMimeType("MP3")).toBe("audio/mp3");
+ expect(getMimeType("m4a")).toBe("audio/mp4");
+ expect(getMimeType("ogg")).toBe("audio/ogg");
+ expect(getMimeType("wav")).toBe("audio/wav");
+ expect(getMimeType("flac")).toBe("audio/flac");
+ expect(getMimeType("unknown")).toBe("audio/mpeg");
+ });
+ });
+
+ describe("shouldConvertToWav", () => {
+ test("returns true for m4a files", () => {
+ const shouldConvertToWav = (extension: string, mimeType: string): boolean => {
+ const normalizedExtension = extension.toLowerCase();
+ return normalizedExtension === "m4a" || mimeType === "audio/mp4";
+ };
+
+ expect(shouldConvertToWav("m4a", "audio/mp4")).toBe(true);
+ expect(shouldConvertToWav("M4A", "audio/mp4")).toBe(true);
+ expect(shouldConvertToWav("mp3", "audio/mp4")).toBe(true);
+ expect(shouldConvertToWav("mp3", "audio/mpeg")).toBe(false);
+ });
+ });
+
+ describe("getEpisodeKey", () => {
+ test("generates unique key from podcast name and title", () => {
+ const getEpisodeKey = (episode: Episode): string => {
+ return `${episode.podcastName}:${episode.title}`;
+ };
+
+ expect(getEpisodeKey(mockEpisode)).toBe("Test Podcast:Test Episode");
+ });
+ });
+
+ describe("createBinaryChunkFiles", () => {
+ const CHUNK_SIZE_BYTES = 20 * 1024 * 1024;
+
+ function createBinaryChunkFiles(
+ buffer: ArrayBuffer,
+ basename: string,
+ extension: string,
+ mimeType: string,
+ ): File[] {
+ if (buffer.byteLength <= CHUNK_SIZE_BYTES) {
+ return [
+ new File([buffer], `${basename}.${extension}`, {
+ type: mimeType,
+ }),
+ ];
+ }
+
+ const files: File[] = [];
+ for (
+ let offset = 0, index = 0;
+ offset < buffer.byteLength;
+ offset += CHUNK_SIZE_BYTES, index++
+ ) {
+ const chunk = buffer.slice(offset, offset + CHUNK_SIZE_BYTES);
+ files.push(
+ new File([chunk], `${basename}.part${index}.${extension}`, {
+ type: mimeType,
+ }),
+ );
+ }
+
+ return files;
+ }
+
+ test("returns single file when buffer is smaller than chunk size", () => {
+ const smallBuffer = new ArrayBuffer(1024);
+ const files = createBinaryChunkFiles(smallBuffer, "test", "mp3", "audio/mpeg");
+
+ expect(files).toHaveLength(1);
+ expect(files[0].name).toBe("test.mp3");
+ expect(files[0].type).toBe("audio/mpeg");
+ expect(files[0].size).toBe(1024);
+ });
+
+ test("returns multiple files when buffer exceeds chunk size", () => {
+ const largeBuffer = new ArrayBuffer(CHUNK_SIZE_BYTES * 2 + 1024);
+ const files = createBinaryChunkFiles(largeBuffer, "test", "mp3", "audio/mpeg");
+
+ expect(files).toHaveLength(3);
+ expect(files[0].name).toBe("test.part0.mp3");
+ expect(files[1].name).toBe("test.part1.mp3");
+ expect(files[2].name).toBe("test.part2.mp3");
+ });
+ });
+
+ describe("writeWavHeader", () => {
+ const WAV_HEADER_SIZE = 44;
+ const PCM_BYTES_PER_SAMPLE = 2;
+
+ function writeString(view: DataView, offset: number, str: string): void {
+ for (let i = 0; i < str.length; i++) {
+ view.setUint8(offset + i, str.charCodeAt(i));
+ }
+ }
+
+ function writeWavHeader(
+ view: DataView,
+ sampleRate: number,
+ numChannels: number,
+ sampleCount: number,
+ ): void {
+ const blockAlign = numChannels * PCM_BYTES_PER_SAMPLE;
+ const byteRate = sampleRate * blockAlign;
+ const dataSize = sampleCount * blockAlign;
+ writeString(view, 0, "RIFF");
+ view.setUint32(4, 36 + dataSize, true);
+ writeString(view, 8, "WAVE");
+ writeString(view, 12, "fmt ");
+ view.setUint32(16, 16, true);
+ view.setUint16(20, 1, true);
+ view.setUint16(22, numChannels, true);
+ view.setUint32(24, sampleRate, true);
+ view.setUint32(28, byteRate, true);
+ view.setUint16(32, blockAlign, true);
+ view.setUint16(34, PCM_BYTES_PER_SAMPLE * 8, true);
+ writeString(view, 36, "data");
+ view.setUint32(40, dataSize, true);
+ }
+
+ test("writes correct RIFF header", () => {
+ const buffer = new ArrayBuffer(WAV_HEADER_SIZE);
+ const view = new DataView(buffer);
+
+ writeWavHeader(view, 44100, 2, 44100);
+
+ const riff = String.fromCharCode(
+ view.getUint8(0),
+ view.getUint8(1),
+ view.getUint8(2),
+ view.getUint8(3),
+ );
+ expect(riff).toBe("RIFF");
+
+ const wave = String.fromCharCode(
+ view.getUint8(8),
+ view.getUint8(9),
+ view.getUint8(10),
+ view.getUint8(11),
+ );
+ expect(wave).toBe("WAVE");
+
+ const fmt = String.fromCharCode(
+ view.getUint8(12),
+ view.getUint8(13),
+ view.getUint8(14),
+ view.getUint8(15),
+ );
+ expect(fmt).toBe("fmt ");
+
+ const data = String.fromCharCode(
+ view.getUint8(36),
+ view.getUint8(37),
+ view.getUint8(38),
+ view.getUint8(39),
+ );
+ expect(data).toBe("data");
+ });
+
+ test("writes correct sample rate and channels", () => {
+ const buffer = new ArrayBuffer(WAV_HEADER_SIZE);
+ const view = new DataView(buffer);
+
+ writeWavHeader(view, 44100, 2, 1000);
+
+ expect(view.getUint16(22, true)).toBe(2);
+ expect(view.getUint32(24, true)).toBe(44100);
+ expect(view.getUint16(34, true)).toBe(16);
+ });
+ });
+
+ describe("TranscriptionService instantiation", () => {
+ test("creates instance with plugin reference", () => {
+ const mockPlugin = createMockPlugin();
+ const service = new TranscriptionService(mockPlugin);
+
+ expect(service).toBeInstanceOf(TranscriptionService);
+ });
+ });
+
+ describe("transcribeCurrentEpisode validation", () => {
+ test("shows notice when no API key is configured", async () => {
+ const mockPlugin = createMockPlugin({ openAIApiKey: "" });
+ const service = new TranscriptionService(mockPlugin);
+
+ await service.transcribeCurrentEpisode();
+ });
+
+ test("shows notice when no episode is playing", async () => {
+ const mockPlugin = createMockPlugin({ podcast: null });
+ const service = new TranscriptionService(mockPlugin);
+
+ await service.transcribeCurrentEpisode();
+ });
+ });
+});
diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts
index 2679324..b1e8c9c 100644
--- a/src/services/TranscriptionService.ts
+++ b/src/services/TranscriptionService.ts
@@ -57,6 +57,7 @@ export class TranscriptionService {
private readonly WAV_HEADER_SIZE = 44;
private readonly PCM_BYTES_PER_SAMPLE = 2;
private readonly MAX_CONCURRENT_TRANSCRIPTIONS = 2;
+ private readonly MAX_CONCURRENT_CHUNK_TRANSCRIPTIONS = 3;
private pendingEpisodes: Episode[] = [];
private activeTranscriptions = new Set();
@@ -418,6 +419,7 @@ export class TranscriptionService {
const client = await this.getClient();
const transcriptions: string[] = new Array(files.length);
let completedChunks = 0;
+ let nextIndex = 0;
const updateProgress = () => {
const progress = ((completedChunks / files.length) * 100).toFixed(1);
@@ -428,8 +430,12 @@ export class TranscriptionService {
updateProgress();
- await Promise.all(
- files.map(async (file, index) => {
+ const worker = async () => {
+ while (true) {
+ const index = nextIndex++;
+ if (index >= files.length) return;
+ const file = files[index];
+
let retries = 0;
while (retries < this.MAX_RETRIES) {
try {
@@ -454,12 +460,20 @@ export class TranscriptionService {
} else {
await new Promise((resolve) =>
setTimeout(resolve, 1000 * retries),
- ); // Exponential backoff
+ );
}
}
}
- }),
+ }
+ };
+
+ const workerCount = Math.min(
+ this.MAX_CONCURRENT_CHUNK_TRANSCRIPTIONS,
+ files.length,
);
+ const workers = Array.from({ length: workerCount }, () => worker());
+
+ await Promise.all(workers);
return transcriptions.join(" ");
}
@@ -481,13 +495,20 @@ export class TranscriptionService {
const vault = this.plugin.app.vault;
- // Ensure the directory exists
+ // Ensure the directory exists (create nested folders recursively)
const directory = transcriptPath.substring(
0,
transcriptPath.lastIndexOf("/"),
);
- if (directory && !vault.getAbstractFileByPath(directory)) {
- await vault.createFolder(directory);
+ if (directory) {
+ const parts = directory.split("/");
+ let current = "";
+ for (const part of parts) {
+ current = current ? `${current}/${part}` : part;
+ if (!vault.getAbstractFileByPath(current)) {
+ await vault.createFolder(current);
+ }
+ }
}
const file = vault.getAbstractFileByPath(transcriptPath);
@@ -495,8 +516,11 @@ export class TranscriptionService {
if (!file) {
const newFile = await vault.create(transcriptPath, transcriptContent);
await this.plugin.app.workspace.getLeaf().openFile(newFile);
+ } else if (file instanceof TFile) {
+ // File already exists - open it without overwriting
+ await this.plugin.app.workspace.getLeaf().openFile(file);
} else {
- throw new Error("Expected a file but got a folder");
+ throw new Error("Expected a file but found a folder at transcript path.");
}
}
diff --git a/src/store/index.ts b/src/store/index.ts
index d42dcd2..1e21001 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -8,6 +8,7 @@ import { ViewState } from "src/types/ViewState";
import type DownloadedEpisode from "src/types/DownloadedEpisode";
import { TFile } from "obsidian";
import type { LocalEpisode } from "src/types/LocalEpisode";
+import { getEpisodeKey } from "src/utility/episodeKey";
export const plugin = writable();
export const currentTime = writable(0);
@@ -46,18 +47,51 @@ export const playedEpisodes = (() => {
const store = writable<{ [key: string]: PlayedEpisode }>({});
const { subscribe, update, set } = store;
+ /**
+ * Gets played episode data, checking both composite key and legacy title-only key
+ * for backwards compatibility.
+ */
+ function getPlayedEpisode(
+ playedEps: { [key: string]: PlayedEpisode },
+ episode: Episode | null | undefined,
+ ): PlayedEpisode | undefined {
+ if (!episode) return undefined;
+
+ const key = getEpisodeKey(episode);
+ // First try composite key
+ if (key && playedEps[key]) {
+ return playedEps[key];
+ }
+ // Fall back to title-only for backwards compatibility
+ if (episode.title && playedEps[episode.title]) {
+ return playedEps[episode.title];
+ }
+ return undefined;
+ }
+
return {
subscribe,
set,
update,
+ /**
+ * Gets played episode data with backwards compatibility.
+ */
+ get: (episode: Episode): PlayedEpisode | undefined => {
+ return getPlayedEpisode(get(store), episode);
+ },
setEpisodeTime: (
- episode: Episode,
+ episode: Episode | null | undefined,
time: number,
duration: number,
finished: boolean,
) => {
+ if (!episode) return;
+
update((playedEpisodes) => {
- playedEpisodes[episode.title] = {
+ const key = getEpisodeKey(episode);
+ if (!key) return playedEpisodes;
+
+ playedEpisodes[key] = {
title: episode.title,
podcastName: episode.podcastName,
time,
@@ -68,29 +102,47 @@ export const playedEpisodes = (() => {
return playedEpisodes;
});
},
- markAsPlayed: (episode: Episode) => {
+ markAsPlayed: (episode: Episode | null | undefined) => {
+ if (!episode) return;
+
update((playedEpisodes) => {
- const playedEpisode = playedEpisodes[episode.title] || episode;
+ const key = getEpisodeKey(episode);
+ if (!key) return playedEpisodes;
- if (playedEpisode) {
- playedEpisode.time = playedEpisode.duration;
- playedEpisode.finished = true;
- }
+ const playedEpisode = getPlayedEpisode(playedEpisodes, episode) || {
+ title: episode.title,
+ podcastName: episode.podcastName,
+ time: 0,
+ duration: 0,
+ finished: false,
+ };
- playedEpisodes[episode.title] = playedEpisode;
+ playedEpisode.time = playedEpisode.duration;
+ playedEpisode.finished = true;
+
+ playedEpisodes[key] = playedEpisode;
return playedEpisodes;
});
},
- markAsUnplayed: (episode: Episode) => {
+ markAsUnplayed: (episode: Episode | null | undefined) => {
+ if (!episode) return;
+
update((playedEpisodes) => {
- const playedEpisode = playedEpisodes[episode.title] || episode;
+ const key = getEpisodeKey(episode);
+ if (!key) return playedEpisodes;
- if (playedEpisode) {
- playedEpisode.time = 0;
- playedEpisode.finished = false;
- }
+ const playedEpisode = getPlayedEpisode(playedEpisodes, episode) || {
+ title: episode.title,
+ podcastName: episode.podcastName,
+ time: 0,
+ duration: 0,
+ finished: false,
+ };
- playedEpisodes[episode.title] = playedEpisode;
+ playedEpisode.time = 0;
+ playedEpisode.finished = false;
+
+ playedEpisodes[key] = playedEpisode;
return playedEpisodes;
});
},
@@ -313,11 +365,16 @@ export const downloadedEpisodes = (() => {
const index = podcastEpisodes.findIndex(
(e) => e.title === episode.title,
);
- const filePath = podcastEpisodes[index].filePath;
+ // Guard against episode not found
+ if (index === -1) {
+ return downloadedEpisodes;
+ }
+
+ const filePath = podcastEpisodes[index].filePath;
podcastEpisodes.splice(index, 1);
- if (removeFile) {
+ if (removeFile && filePath) {
try {
// @ts-ignore: app is not defined in the global scope anymore, but is still
// available. Need to fix this later
diff --git a/src/types/Chapter.ts b/src/types/Chapter.ts
new file mode 100644
index 0000000..4d6455b
--- /dev/null
+++ b/src/types/Chapter.ts
@@ -0,0 +1,24 @@
+/**
+ * Represents a chapter in a podcast episode.
+ * Based on the Podcasting 2.0 JSON Chapters format.
+ * @see https://github.com/Podcastindex-org/podcast-namespace/blob/main/chapters/jsonChapters.md
+ */
+export interface Chapter {
+ /** Start time in seconds */
+ startTime: number;
+ /** Optional end time in seconds */
+ endTime?: number;
+ /** Chapter title */
+ title: string;
+ /** Optional chapter artwork URL */
+ img?: string;
+ /** Optional link URL */
+ url?: string;
+ /** Whether this chapter should be hidden (ad, etc.) */
+ toc?: boolean;
+}
+
+export interface ChaptersData {
+ version: string;
+ chapters: Chapter[];
+}
diff --git a/src/types/Episode.ts b/src/types/Episode.ts
index 3e8d9ae..2299ace 100644
--- a/src/types/Episode.ts
+++ b/src/types/Episode.ts
@@ -9,4 +9,6 @@ export interface Episode {
artworkUrl?: string;
episodeDate?: Date;
itunesTitle?: string;
+ /** URL to the podcast:chapters JSON file */
+ chaptersUrl?: string;
}
diff --git a/src/ui/PodcastView/ChapterList.svelte b/src/ui/PodcastView/ChapterList.svelte
new file mode 100644
index 0000000..de7cec6
--- /dev/null
+++ b/src/ui/PodcastView/ChapterList.svelte
@@ -0,0 +1,144 @@
+
+
+{#if chapters.length > 0}
+
+
+
+ {#if isExpanded}
+
+ {#each chapters as chapter, index}
+ -
+
+
+ {/each}
+
+ {/if}
+
+{/if}
+
+
diff --git a/src/ui/PodcastView/EpisodeList.svelte b/src/ui/PodcastView/EpisodeList.svelte
index 16a418c..5db185c 100644
--- a/src/ui/PodcastView/EpisodeList.svelte
+++ b/src/ui/PodcastView/EpisodeList.svelte
@@ -6,6 +6,7 @@
import Icon from "../obsidian/Icon.svelte";
import Text from "../obsidian/Text.svelte";
import Loading from "./Loading.svelte";
+ import { getEpisodeKey } from "src/utility/episodeKey";
export let episodes: Episode[] = [];
export let showThumbnails: boolean = false;
@@ -13,6 +14,13 @@
export let isLoading: boolean = false;
let searchInputQuery: string = "";
+ function isEpisodeFinished(episode: Episode | null | undefined, playedEps: typeof $playedEpisodes): boolean {
+ if (!episode) return false;
+ const key = getEpisodeKey(episode);
+ // Check composite key first, then fall back to title-only for backwards compat
+ return (key && playedEps[key]?.finished) || playedEps[episode.title]?.finished || false;
+ }
+
const dispatch = createEventDispatcher();
function forwardClickEpisode(event: CustomEvent<{ episode: Episode }>) {
@@ -74,8 +82,8 @@
{#if episodes.length === 0 && !isLoading}
No episodes found.
{/if}
- {#each episodes as episode (episode.url || episode.streamUrl || `${episode.title}-${episode.episodeDate ?? ""}`)}
- {@const episodePlayed = $playedEpisodes[episode.title]?.finished}
+ {#each episodes as episode, index (getEpisodeKey(episode) ?? `${episode.title}-${episode.episodeDate ?? ""}-${index}`)}
+ {@const episodePlayed = isEpisodeFinished(episode, $playedEpisodes)}
{#if !$hidePlayedEpisodes || !episodePlayed}
diff --git a/src/ui/PodcastView/EpisodeListHeader.svelte b/src/ui/PodcastView/EpisodeListHeader.svelte
index f76e810..5475e68 100644
--- a/src/ui/PodcastView/EpisodeListHeader.svelte
+++ b/src/ui/PodcastView/EpisodeListHeader.svelte
@@ -14,12 +14,25 @@
.podcast-header {
display: flex;
flex-direction: column;
- justify-content: space-around;
+ justify-content: center;
align-items: center;
- padding: 0.5rem;
+ gap: 0.75rem;
+ padding: 1rem;
+ }
+
+ #podcast-artwork {
+ width: 5rem;
+ height: 5rem;
+ border-radius: 0.5rem;
+ object-fit: cover;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.podcast-heading {
+ margin: 0;
+ font-size: 1.125rem;
+ font-weight: 600;
text-align: center;
+ color: var(--text-normal);
}
\ No newline at end of file
diff --git a/src/ui/PodcastView/EpisodeListItem.svelte b/src/ui/PodcastView/EpisodeListItem.svelte
index efb5664..e6a5a04 100644
--- a/src/ui/PodcastView/EpisodeListItem.svelte
+++ b/src/ui/PodcastView/EpisodeListItem.svelte
@@ -64,8 +64,8 @@
src={episode.artworkUrl}
alt={episode.title}
fadeIn={true}
- width="5rem"
- height="5rem"
+ width="100%"
+ height="100%"
class="podcast-episode-thumbnail"
/>
@@ -83,61 +83,95 @@
display: flex;
flex-direction: row;
justify-content: flex-start;
- align-items: flex-start;
- padding: 0.5rem;
- min-height: 5rem;
+ align-items: center;
+ padding: 0.625rem 0.75rem;
+ min-height: 4.5rem;
width: 100%;
- border: solid 1px var(--background-divider);
+ border: none;
+ border-bottom: 1px solid var(--background-modifier-border);
gap: 0.75rem;
background: transparent;
text-align: left;
+ cursor: pointer;
+ transition: background-color 120ms ease;
+ }
+
+ .podcast-episode-item:last-child {
+ border-bottom: none;
}
.podcast-episode-item:focus-visible {
outline: 2px solid var(--interactive-accent);
- outline-offset: 2px;
+ outline-offset: -2px;
+ border-radius: 0.25rem;
}
.podcast-episode-item:hover {
- background-color: var(--background-divider);
+ background-color: var(--background-secondary-alt);
+ }
+
+ .podcast-episode-item:active {
+ background-color: var(--background-modifier-border);
}
.strikeout {
text-decoration: line-through;
+ opacity: 0.6;
}
.podcast-episode-information {
display: flex;
flex-direction: column;
- justify-content: space-between;
+ justify-content: center;
align-items: flex-start;
+ gap: 0.25rem;
flex: 1 1 auto;
min-width: 0;
}
.episode-item-date {
- color: gray;
+ font-size: 0.75rem;
+ font-weight: 500;
+ letter-spacing: 0.025em;
+ color: var(--text-muted);
+ }
+
+ .episode-item-title {
+ font-size: 0.9rem;
+ line-height: 1.4;
+ color: var(--text-normal);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ -webkit-box-orient: vertical;
}
.podcast-episode-thumbnail-container {
- flex: 0 0 5rem;
- width: 5rem;
- height: 5rem;
- max-width: 5rem;
- max-height: 5rem;
+ flex: 0 0 3.5rem;
+ width: 3.5rem;
+ height: 3.5rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--background-secondary);
- border-radius: 15%;
+ border-radius: 0.375rem;
overflow: hidden;
}
+ @media (min-width: 400px) {
+ .podcast-episode-thumbnail-container {
+ flex: 0 0 4rem;
+ width: 4rem;
+ height: 4rem;
+ }
+ }
+
:global(.podcast-episode-thumbnail) {
width: 100%;
height: 100%;
object-fit: cover;
- border-radius: 15%;
- cursor: pointer !important;
+ border-radius: 0.375rem;
}
diff --git a/src/ui/PodcastView/EpisodePlayer.svelte b/src/ui/PodcastView/EpisodePlayer.svelte
index 162b7da..6709e1b 100644
--- a/src/ui/PodcastView/EpisodePlayer.svelte
+++ b/src/ui/PodcastView/EpisodePlayer.svelte
@@ -13,18 +13,22 @@
downloadedEpisodes,
} from "src/store";
import { formatSeconds } from "src/utility/formatSeconds";
+ import { fetchChapters } from "src/utility/fetchChapters";
import { onDestroy, onMount } from "svelte";
import Icon from "../obsidian/Icon.svelte";
import Button from "../obsidian/Button.svelte";
import Slider from "../obsidian/Slider.svelte";
import Loading from "./Loading.svelte";
import EpisodeList from "./EpisodeList.svelte";
+ import ChapterList from "./ChapterList.svelte";
import Progressbar from "../common/Progressbar.svelte";
import spawnEpisodeContextMenu from "./spawnEpisodeContextMenu";
import type { Episode } from "src/types/Episode";
+ import type { Chapter } from "src/types/Chapter";
import { ViewState } from "src/types/ViewState";
import { createMediaUrlObjectFromFilePath } from "src/utility/createMediaUrlObjectFromFilePath";
import Image from "../common/Image.svelte";
+ import { getEpisodeKey } from "src/utility/episodeKey";
// #region Circumventing the forced two-way binding of the playback rate.
class CircumentForcedTwoWayBinding {
@@ -33,6 +37,10 @@
public get _playbackRate() {
return this.playbackRate;
}
+
+ public set _playbackRate(_: number) {
+ // No-op: prevent two-way binding from overwriting our value
+ }
}
const offBinding = new CircumentForcedTwoWayBinding();
@@ -42,6 +50,8 @@
let isHoveringArtwork: boolean = false;
let isLoading: boolean = true;
let playerVolume: number = 1;
+ let chapters: Chapter[] = [];
+ let lastChaptersUrl: string | undefined = undefined;
function togglePlayback() {
isPaused.update((value) => !value);
@@ -93,6 +103,10 @@
volume.set(newVolume);
}
+ function onChapterSeek(event: CustomEvent<{ time: number }>) {
+ currentTime.set(event.detail.time);
+ }
+
function onMetadataLoaded() {
isLoading = false;
@@ -103,8 +117,19 @@
const playedEps = $playedEpisodes;
const currentEp = $currentEpisode;
- if (playedEps[currentEp.title]) {
- currentTime.set(playedEps[currentEp.title].time);
+ if (!currentEp) {
+ currentTime.set(0);
+ isPaused.set(false);
+ return;
+ }
+
+ const key = getEpisodeKey(currentEp);
+
+ // Check composite key first, then fallback to title-only for backwards compat
+ const playedData = (key && playedEps[key]) || playedEps[currentEp.title];
+
+ if (playedData?.time) {
+ currentTime.set(playedData.time);
} else {
currentTime.set(0);
}
@@ -121,8 +146,20 @@
srcPromise = getSrc($currentEpisode);
});
- const unsubCurrentEpisode = currentEpisode.subscribe(_ => {
+ const unsubCurrentEpisode = currentEpisode.subscribe((episode) => {
srcPromise = getSrc($currentEpisode);
+
+ // Fetch chapters when episode changes
+ const chaptersUrl = episode?.chaptersUrl;
+ if (chaptersUrl && chaptersUrl !== lastChaptersUrl) {
+ lastChaptersUrl = chaptersUrl;
+ fetchChapters(chaptersUrl).then((c) => {
+ chapters = c;
+ });
+ } else if (!chaptersUrl) {
+ lastChaptersUrl = undefined;
+ chapters = [];
+ }
});
const unsubVolume = volume.subscribe((value) => {
@@ -273,6 +310,12 @@
+
+
diff --git a/src/ui/PodcastView/PlaylistCard.svelte b/src/ui/PodcastView/PlaylistCard.svelte
index 917ce7d..5872607 100644
--- a/src/ui/PodcastView/PlaylistCard.svelte
+++ b/src/ui/PodcastView/PlaylistCard.svelte
@@ -31,16 +31,31 @@
flex-direction: column;
align-items: center;
justify-content: center;
+ gap: 0.25rem;
width: 100%;
- height: 100%;
+ min-height: 5rem;
+ padding: 0.75rem 0.5rem;
border: 1px solid var(--background-modifier-border);
+ border-radius: 0.5rem;
text-align: center;
- overflow: hidden;
- background: transparent;
- padding: 0;
+ background: var(--background-secondary);
+ cursor: pointer;
+ transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease, background-color 150ms ease;
}
.playlist-card:hover {
- background-color: var(--background-modifier-border);
+ transform: scale(1.02);
+ border-color: var(--interactive-accent);
+ background-color: var(--background-secondary-alt);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ }
+
+ .playlist-card:active {
+ transform: scale(0.98);
+ }
+
+ .playlist-card span {
+ font-size: 0.8rem;
+ color: var(--text-muted);
}
diff --git a/src/ui/PodcastView/PodcastGrid.svelte b/src/ui/PodcastView/PodcastGrid.svelte
index 1ddad97..5d3160e 100644
--- a/src/ui/PodcastView/PodcastGrid.svelte
+++ b/src/ui/PodcastView/PodcastGrid.svelte
@@ -39,9 +39,26 @@
diff --git a/src/ui/PodcastView/PodcastGridCard.svelte b/src/ui/PodcastView/PodcastGridCard.svelte
index ae32a7e..02e098a 100644
--- a/src/ui/PodcastView/PodcastGridCard.svelte
+++ b/src/ui/PodcastView/PodcastGridCard.svelte
@@ -24,11 +24,24 @@
:global(.podcast-image) {
width: 100%;
height: 100%;
+ aspect-ratio: 1;
cursor: pointer !important;
object-fit: cover;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border: 1px solid var(--background-modifier-border);
+ border-radius: 0.5rem;
+ transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease;
+ }
+
+ :global(.podcast-image:hover) {
+ transform: scale(1.02);
+ border-color: var(--interactive-accent);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ }
+
+ :global(.podcast-image:active) {
+ transform: scale(0.98);
}
diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte
index b243ff7..fe71325 100644
--- a/src/ui/PodcastView/PodcastView.svelte
+++ b/src/ui/PodcastView/PodcastView.svelte
@@ -21,7 +21,7 @@
import FeedParser from "src/parser/feedParser";
import TopBar from "./TopBar.svelte";
import { ViewState } from "src/types/ViewState";
- import { onMount } from "svelte";
+ import { onMount, onDestroy } from "svelte";
import EpisodeListHeader from "./EpisodeListHeader.svelte";
import Icon from "../obsidian/Icon.svelte";
import { debounce } from "obsidian";
@@ -45,6 +45,11 @@
let currentSearchQuery: string = "";
let loadingFeedNames: string[] = [];
let loadingFeedSummary: string = "";
+ let isMounted: boolean = true;
+
+ onDestroy(() => {
+ isMounted = false;
+ });
$: loadingFeedNames = Array.from(loadingFeeds);
$: loadingFeedSummary =
@@ -55,7 +60,7 @@
onMount(() => {
const unsubscribePlaylists = playlists.subscribe((pl) => {
- displayedPlaylists = [$queue, $favorites, $localFiles, ...Object.values(pl)];
+ displayedPlaylists = [get(queue), get(favorites), get(localFiles), ...Object.values(pl)];
});
const unsubscribeSavedFeeds = savedFeeds.subscribe((storeValue) => {
@@ -73,11 +78,20 @@
}
});
+ let currentViewState = get(viewState);
+ const unsubscribeViewState = viewState.subscribe((vs) => {
+ currentViewState = vs;
+ });
+
const unsubscribeLatestEpisodes = latestEpisodesStore.subscribe(
(episodes) => {
latestEpisodes = episodes;
- if (!selectedFeed && !selectedPlaylist) {
+ if (
+ currentViewState === ViewState.EpisodeList &&
+ !selectedFeed &&
+ !selectedPlaylist
+ ) {
displayedEpisodes = currentSearchQuery
? searchEpisodes(currentSearchQuery, episodes)
: episodes;
@@ -87,6 +101,7 @@
return () => {
unsubscribeLatestEpisodes();
+ unsubscribeViewState();
unsubscribeSavedFeeds();
unsubscribePlaylists();
};
@@ -103,7 +118,8 @@
const cacheTtlMs =
Math.max(1, feedCacheSettings?.ttlHours ?? 6) * 60 * 60 * 1000;
- const cachedEpisodesInFeed = $episodeCache[feed.title];
+ const currentCache = get(episodeCache);
+ const cachedEpisodesInFeed = currentCache[feed.title];
if (
useCache &&
@@ -141,11 +157,15 @@
`Failed to fetch episodes for ${feed.title}:`,
error,
);
- return $downloadedEpisodes[feed.title] || [];
+ const downloaded = get(downloadedEpisodes);
+ return downloaded[feed.title] || [];
}
}
function setFeedLoading(feedTitle: string, isLoading: boolean) {
+ // Don't update state if component is unmounted
+ if (!isMounted) return;
+
const updatedLoadingFeeds = new Set(loadingFeeds);
if (isLoading) {
@@ -228,7 +248,8 @@
currentSearchQuery = query;
if (selectedFeed) {
- const episodesInFeed = $episodeCache[selectedFeed.title] ?? [];
+ const cache = get(episodeCache);
+ const episodesInFeed = cache[selectedFeed.title] ?? [];
displayedEpisodes = searchEpisodes(query, episodesInFeed);
return;
}
@@ -286,7 +307,7 @@
diff --git a/src/ui/PodcastView/TopBar.svelte b/src/ui/PodcastView/TopBar.svelte
index feef28c..4bf505f 100644
--- a/src/ui/PodcastView/TopBar.svelte
+++ b/src/ui/PodcastView/TopBar.svelte
@@ -91,12 +91,12 @@
display: flex;
flex-direction: row;
align-items: center;
- justify-content: space-between;
- gap: 0.5rem;
- padding: 0.25rem 0.5rem;
- height: 50px;
- min-height: 50px;
- border-bottom: 1px solid var(--background-divider);
+ justify-content: stretch;
+ gap: 0.375rem;
+ padding: 0.5rem;
+ min-height: 3rem;
+ border-bottom: 1px solid var(--background-modifier-border);
+ background: var(--background-secondary);
box-sizing: border-box;
}
@@ -104,52 +104,47 @@
display: flex;
align-items: center;
justify-content: center;
- width: 100%;
- padding: 0.4rem 0.25rem;
+ height: 2rem;
+ padding: 0 0.75rem;
flex: 1 1 0;
- border: 1px solid var(--background-modifier-border, #3a3a3a);
- border-radius: 8px;
- background: var(--background-secondary, transparent);
- color: var(--text-muted, #8a8a8a);
+ border: 1px solid transparent;
+ border-radius: 0.375rem;
+ background: transparent;
+ color: var(--text-muted);
transition:
background-color 120ms ease,
border-color 120ms ease,
- color 120ms ease,
- box-shadow 120ms ease,
- opacity 120ms ease;
+ color 120ms ease;
}
.topbar-menu-button:focus-visible {
- outline: 2px solid var(--interactive-accent, #5c6bf7);
- outline-offset: 2px;
+ outline: 2px solid var(--interactive-accent);
+ outline-offset: 1px;
}
.topbar-selectable {
cursor: pointer;
- color: var(--text-normal, #e6e6e6);
- background: var(--background-secondary-alt, rgba(255, 255, 255, 0.02));
+ color: var(--text-normal);
}
- .topbar-menu-button:hover.topbar-selectable:not(.topbar-selected) {
- background: var(--background-modifier-hover, rgba(255, 255, 255, 0.06));
- border-color: var(--interactive-accent, #5c6bf7);
- color: var(--text-normal, #e6e6e6);
+ .topbar-selectable:hover:not(.topbar-selected) {
+ background: var(--background-modifier-hover);
+ }
+
+ .topbar-selectable:active:not(.topbar-selected) {
+ background: var(--background-modifier-border);
}
.topbar-selected,
.topbar-selected:hover {
- color: var(--text-on-accent, #ffffff);
- background: var(--interactive-accent, #5c6bf7);
- border-color: var(--interactive-accent, #5c6bf7);
- box-shadow: 0 0 0 1px var(--interactive-accent, #5c6bf7);
+ color: var(--text-on-accent);
+ background: var(--interactive-accent);
}
.topbar-disabled,
.topbar-menu-button:disabled {
cursor: not-allowed;
- color: var(--text-faint, #a0a0a0);
- background: var(--background-modifier-border, #3a3a3a);
- border-style: dashed;
- opacity: 1;
+ color: var(--text-faint);
+ opacity: 0.5;
}
diff --git a/src/ui/common/Image.svelte b/src/ui/common/Image.svelte
index 7b76f54..52fa25e 100644
--- a/src/ui/common/Image.svelte
+++ b/src/ui/common/Image.svelte
@@ -68,15 +68,21 @@
overflow: hidden;
border: none;
padding: 0;
- background: transparent;
+ background: var(--background-secondary);
}
.pn_image_container--static {
- border: none;
- padding: 0;
+ cursor: default;
+ }
+
+ .pn_image_container:not(.pn_image_container--static) {
+ cursor: pointer;
}
- .pn_image_container:not(.pn_image_container--static) img:hover {
- cursor: pointer !important;
+ .pn_image_container img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
}
diff --git a/src/ui/common/Progressbar.svelte b/src/ui/common/Progressbar.svelte
index 09a522d..0051030 100644
--- a/src/ui/common/Progressbar.svelte
+++ b/src/ui/common/Progressbar.svelte
@@ -97,11 +97,22 @@ function handleKeyDown(event: KeyboardEvent) {
.progress {
position: relative;
width: 100%;
- height: 1rem;
- background: var(--background-modifier-border, #ccc);
+ height: 0.5rem;
+ background: var(--background-modifier-border);
border-radius: 9999px;
overflow: hidden;
cursor: pointer;
+ transition: height 120ms ease;
+ }
+
+ .progress:hover,
+ .progress:focus-visible {
+ height: 0.625rem;
+ }
+
+ .progress:focus-visible {
+ outline: 2px solid var(--interactive-accent);
+ outline-offset: 2px;
}
.progress__bar {
@@ -109,6 +120,8 @@ function handleKeyDown(event: KeyboardEvent) {
top: 0;
left: 0;
height: 100%;
- background: var(--interactive-accent, #5c6bf7);
+ background: var(--interactive-accent);
+ border-radius: 9999px;
+ transition: width 50ms linear;
}
diff --git a/src/ui/settings/PlaylistItem.svelte b/src/ui/settings/PlaylistItem.svelte
index ffd0668..f358a82 100644
--- a/src/ui/settings/PlaylistItem.svelte
+++ b/src/ui/settings/PlaylistItem.svelte
@@ -9,7 +9,7 @@
let clickedDelete: boolean = false;
const dispatch = createEventDispatcher();
- function onClickedDelete(event: CustomEvent) {
+ function onClickedDelete() {
if (clickedDelete) {
dispatch("delete", { value: playlist });
return;
@@ -22,7 +22,7 @@
}, 2000);
}
- function onClickedRepeat(event: CustomEvent) {
+ function onClickedRepeat() {
dispatch("toggleRepeat", { value: playlist });
}
@@ -31,33 +31,28 @@
- {playlist.name}
- ({playlist.episodes.length})
+ {playlist.name}
+ ({playlist.episodes.length})
-
{#if showDeleteButton}
-
+ aria-label={clickedDelete ? "Confirm deletion" : "Delete playlist"}
+ >
+
+
{/if}
@@ -67,14 +62,37 @@
display: flex;
align-items: center;
justify-content: space-between;
- padding: 0.5rem;
- border-bottom: 1px solid var(--background-modifier-border);
+ gap: 0.75rem;
+ padding: 0.625rem 0.75rem;
width: 100%;
+ background: var(--background-secondary);
+ transition: background-color 120ms ease;
+ }
+
+ .playlist-item:not(:last-child) {
+ border-bottom: 1px solid var(--background-modifier-border);
+ }
+
+ .playlist-item:hover {
+ background: var(--background-secondary-alt);
}
.playlist-item-left {
display: flex;
align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+ }
+
+ .playlist-name {
+ font-weight: 500;
+ font-size: 0.9rem;
+ color: var(--text-normal);
+ }
+
+ .playlist-count {
+ font-size: 0.8rem;
+ color: var(--text-muted);
}
.playlist-item-controls {
@@ -82,4 +100,30 @@
align-items: center;
gap: 0.25rem;
}
+
+ .delete-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.375rem;
+ border: none;
+ border-radius: 0.25rem;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: background-color 120ms ease, color 120ms ease;
+ }
+
+ .delete-button:hover {
+ background: var(--background-modifier-hover);
+ color: var(--text-error);
+ }
+
+ .delete-button.confirm {
+ color: var(--text-success);
+ }
+
+ .delete-button.confirm:hover {
+ color: var(--text-success);
+ }
diff --git a/src/ui/settings/PlaylistManager.svelte b/src/ui/settings/PlaylistManager.svelte
index 518a926..81de1ba 100644
--- a/src/ui/settings/PlaylistManager.svelte
+++ b/src/ui/settings/PlaylistManager.svelte
@@ -96,24 +96,23 @@
.playlist-manager-container {
display: flex;
flex-direction: column;
- align-items: center;
- justify-content: center;
width: 100%;
- height: 100%;
- margin-bottom: 2rem;
+ margin-bottom: 1.5rem;
}
.playlist-list {
display: flex;
flex-direction: column;
- align-items: center;
- justify-content: center;
width: 100%;
- height: 100%;
- overflow-y: auto;
+ border: 1px solid var(--background-modifier-border);
+ border-radius: 0.5rem;
+ overflow: hidden;
}
.add-playlist-container {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
margin-top: 1rem;
}
diff --git a/src/ui/settings/PodcastQueryGrid.svelte b/src/ui/settings/PodcastQueryGrid.svelte
index b64c8a4..11d98cd 100644
--- a/src/ui/settings/PodcastQueryGrid.svelte
+++ b/src/ui/settings/PodcastQueryGrid.svelte
@@ -111,18 +111,18 @@
diff --git a/src/ui/settings/PodcastResultCard.svelte b/src/ui/settings/PodcastResultCard.svelte
index e00d1ea..666b016 100644
--- a/src/ui/settings/PodcastResultCard.svelte
+++ b/src/ui/settings/PodcastResultCard.svelte
@@ -42,23 +42,29 @@
.podcast-result-card {
display: flex;
align-items: center;
- padding: 16px;
+ gap: 0.875rem;
+ padding: 0.875rem;
border: 1px solid var(--background-modifier-border);
- border-radius: 8px;
+ border-radius: 0.5rem;
background-color: var(--background-secondary);
max-width: 100%;
- transition: all 0.3s ease;
- position: relative;
+ transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease;
+ }
+
+ .podcast-result-card:hover {
+ border-color: var(--interactive-accent);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ transform: translateY(-1px);
}
.podcast-artwork-container {
- width: 70px;
- height: 70px;
+ width: 4rem;
+ height: 4rem;
flex-shrink: 0;
- margin-right: 20px;
overflow: hidden;
- border-radius: 4px;
+ border-radius: 0.375rem;
position: relative;
+ background: var(--background-modifier-border);
}
.podcast-artwork {
@@ -70,23 +76,23 @@
left: 0;
}
- .podcast-result-card:hover {
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- transform: translateY(-2px);
- }
-
.podcast-info {
- flex-grow: 1;
+ flex: 1 1 auto;
min-width: 0;
- padding-right: 12px;
}
.podcast-title {
- margin: 0 0 6px 0;
- font-size: 16px;
- font-weight: bold;
- line-height: 1.3;
- word-break: break-word;
+ margin: 0;
+ font-size: 0.9rem;
+ font-weight: 600;
+ line-height: 1.4;
+ color: var(--text-normal);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ -webkit-box-orient: vertical;
}
.podcast-actions {
@@ -96,13 +102,12 @@
}
:global(.podcast-actions button) {
- padding: 4px;
- width: 24px;
- height: 24px;
+ padding: 0.375rem;
+ border-radius: 0.25rem;
+ transition: background-color 120ms ease;
}
- :global(.podcast-actions button svg) {
- width: 16px;
- height: 16px;
+ :global(.podcast-actions button:hover) {
+ background-color: var(--background-modifier-hover);
}
diff --git a/src/utility/createMediaUrlObjectFromFilePath.ts b/src/utility/createMediaUrlObjectFromFilePath.ts
index b251a85..7ce1ab7 100644
--- a/src/utility/createMediaUrlObjectFromFilePath.ts
+++ b/src/utility/createMediaUrlObjectFromFilePath.ts
@@ -1,10 +1,65 @@
import { TFile } from "obsidian";
-export async function createMediaUrlObjectFromFilePath(filePath: string) {
- const file = app.vault.getAbstractFileByPath(filePath);
- if (!file || !(file instanceof TFile)) return "";
+/**
+ * Manages blob URLs to prevent memory leaks.
+ * Tracks created URLs and provides cleanup mechanism.
+ */
+class BlobUrlManager {
+ private activeUrls: Map = new Map();
- const binary = await app.vault.readBinary(file);
+ /**
+ * Creates a blob URL for a file path, cleaning up any previous URL for the same path.
+ */
+ async createUrl(filePath: string): Promise {
+ // Revoke existing URL for this file path to prevent memory leak
+ this.revokeUrl(filePath);
- return URL.createObjectURL(new Blob([binary], { type: "audio/mpeg" }));
+ const file = app.vault.getAbstractFileByPath(filePath);
+ if (!file || !(file instanceof TFile)) return "";
+
+ const binary = await app.vault.readBinary(file);
+ const url = URL.createObjectURL(new Blob([binary], { type: "audio/mpeg" }));
+
+ this.activeUrls.set(filePath, url);
+ return url;
+ }
+
+ /**
+ * Revokes a blob URL for a specific file path.
+ */
+ revokeUrl(filePath: string): void {
+ const existingUrl = this.activeUrls.get(filePath);
+ if (existingUrl) {
+ URL.revokeObjectURL(existingUrl);
+ this.activeUrls.delete(filePath);
+ }
+ }
+
+ /**
+ * Revokes all active blob URLs. Call this on plugin unload.
+ */
+ revokeAll(): void {
+ for (const url of this.activeUrls.values()) {
+ URL.revokeObjectURL(url);
+ }
+ this.activeUrls.clear();
+ }
+
+ /**
+ * Returns the number of active blob URLs (for debugging).
+ */
+ get activeCount(): number {
+ return this.activeUrls.size;
+ }
+}
+
+// Singleton instance
+export const blobUrlManager = new BlobUrlManager();
+
+/**
+ * Creates a blob URL from a file path in the vault.
+ * Automatically cleans up previous URL for the same file.
+ */
+export async function createMediaUrlObjectFromFilePath(filePath: string): Promise {
+ return blobUrlManager.createUrl(filePath);
}
diff --git a/src/utility/episodeKey.ts b/src/utility/episodeKey.ts
new file mode 100644
index 0000000..3cfa59c
--- /dev/null
+++ b/src/utility/episodeKey.ts
@@ -0,0 +1,35 @@
+import type { Episode } from "src/types/Episode";
+
+/**
+ * Generates a unique key for an episode.
+ * Uses podcastName + title to avoid collisions between episodes with the same title
+ * from different podcasts.
+ *
+ * Falls back to title-only for backwards compatibility with episodes that don't have podcastName.
+ */
+export function getEpisodeKey(episode: Episode | null | undefined): string {
+ if (!episode || !episode.title) {
+ return "";
+ }
+ if (episode.podcastName) {
+ return `${episode.podcastName}::${episode.title}`;
+ }
+ // Fallback for legacy episodes without podcastName
+ return episode.title;
+}
+
+/**
+ * Checks if an episode matches a given key.
+ * Handles both new composite keys and legacy title-only keys.
+ */
+export function episodeMatchesKey(episode: Episode | null | undefined, key: string): boolean {
+ if (!episode || !key) {
+ return false;
+ }
+ const compositeKey = getEpisodeKey(episode);
+ if (compositeKey === key) {
+ return true;
+ }
+ // Also check title-only for backwards compatibility
+ return episode.title === key;
+}
diff --git a/src/utility/fetchChapters.ts b/src/utility/fetchChapters.ts
new file mode 100644
index 0000000..51f4ab0
--- /dev/null
+++ b/src/utility/fetchChapters.ts
@@ -0,0 +1,29 @@
+import type { Chapter, ChaptersData } from "src/types/Chapter";
+import { requestWithTimeout } from "./networkRequest";
+
+/**
+ * Fetches and parses podcast chapters from a chapters URL.
+ * Returns an empty array if the URL is invalid or the request fails.
+ */
+export async function fetchChapters(chaptersUrl: string): Promise {
+ if (!chaptersUrl) {
+ return [];
+ }
+
+ try {
+ const response = await requestWithTimeout(chaptersUrl, { timeoutMs: 10000 });
+ const data: ChaptersData = JSON.parse(response.text);
+
+ if (!data.chapters || !Array.isArray(data.chapters)) {
+ return [];
+ }
+
+ // Filter out hidden chapters (toc === false) and sort by start time
+ return data.chapters
+ .filter((chapter) => chapter.toc !== false)
+ .sort((a, b) => a.startTime - b.startTime);
+ } catch (error) {
+ console.warn("Failed to fetch chapters:", error);
+ return [];
+ }
+}
diff --git a/src/utility/formatDate.ts b/src/utility/formatDate.ts
new file mode 100644
index 0000000..8ec83c1
--- /dev/null
+++ b/src/utility/formatDate.ts
@@ -0,0 +1,69 @@
+/**
+ * Formats a date using Moment.js-style format tokens for backward compatibility.
+ * Common tokens supported:
+ * - YYYY: 4-digit year, YY: 2-digit year
+ * - MMMM: full month, MMM: abbreviated month, MM: 2-digit month, M: month
+ * - DD: 2-digit day, D: day, Do: day with ordinal
+ * - dddd: full weekday, ddd: abbreviated weekday
+ * - HH: 24h hours, H: 24h hour, hh: 12h hours, h: 12h hour
+ * - mm: minutes, m: minute
+ * - ss: seconds, s: second
+ * - A: AM/PM, a: am/pm
+ */
+export function formatDate(date: Date, format: string): string {
+ const year = date.getFullYear();
+ const month = date.getMonth();
+ const day = date.getDate();
+ const weekday = date.getDay();
+ const hours = date.getHours();
+ const minutes = date.getMinutes();
+ const seconds = date.getSeconds();
+
+ const pad = (n: number): string => n.toString().padStart(2, '0');
+
+ const monthNames = [
+ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December'
+ ];
+ const monthNamesShort = [
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
+ ];
+ const weekdayNames = [
+ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'
+ ];
+ const weekdayNamesShort = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+
+ const ordinal = (n: number): string => {
+ const s = ['th', 'st', 'nd', 'rd'];
+ const v = n % 100;
+ return n + (s[(v - 20) % 10] || s[v] || s[0]);
+ };
+
+ const hours12 = hours % 12 || 12;
+ const isPM = hours >= 12;
+
+ // Order matters: longer tokens must be replaced before shorter ones
+ return format
+ .replace(/YYYY/g, year.toString())
+ .replace(/YY/g, year.toString().slice(-2))
+ .replace(/MMMM/g, monthNames[month])
+ .replace(/MMM/g, monthNamesShort[month])
+ .replace(/MM/g, pad(month + 1))
+ .replace(/M/g, (month + 1).toString())
+ .replace(/dddd/g, weekdayNames[weekday])
+ .replace(/ddd/g, weekdayNamesShort[weekday])
+ .replace(/Do/g, ordinal(day))
+ .replace(/DD/g, pad(day))
+ .replace(/D/g, day.toString())
+ .replace(/HH/g, pad(hours))
+ .replace(/H/g, hours.toString())
+ .replace(/hh/g, pad(hours12))
+ .replace(/h/g, hours12.toString())
+ .replace(/mm/g, pad(minutes))
+ .replace(/m/g, minutes.toString())
+ .replace(/ss/g, pad(seconds))
+ .replace(/s/g, seconds.toString())
+ .replace(/A/g, isPM ? 'PM' : 'AM')
+ .replace(/a/g, isPM ? 'pm' : 'am');
+}
diff --git a/src/utility/formatSeconds.ts b/src/utility/formatSeconds.ts
index 78587df..92fc0a4 100644
--- a/src/utility/formatSeconds.ts
+++ b/src/utility/formatSeconds.ts
@@ -1,3 +1,31 @@
-export function formatSeconds(seconds: number, format: string) {
- return window.moment().startOf('day').seconds(seconds).format(format);
+/**
+ * Formats a duration in seconds to a time string.
+ * Supports common Moment.js-style format tokens for backward compatibility:
+ * - H, HH: hours (0-23, 00-23)
+ * - h, hh: hours (1-12, 01-12)
+ * - m, mm: minutes (0-59, 00-59)
+ * - s, ss: seconds (0-59, 00-59)
+ * - A: AM/PM, a: am/pm
+ */
+export function formatSeconds(totalSeconds: number, format: string): string {
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const secs = Math.floor(totalSeconds % 60);
+
+ const hours12 = hours % 12 || 12;
+ const isPM = hours >= 12;
+
+ const pad = (n: number): string => n.toString().padStart(2, '0');
+
+ return format
+ .replace(/HH/g, pad(hours))
+ .replace(/H/g, hours.toString())
+ .replace(/hh/g, pad(hours12))
+ .replace(/h/g, hours12.toString())
+ .replace(/mm/g, pad(minutes))
+ .replace(/m/g, minutes.toString())
+ .replace(/ss/g, pad(secs))
+ .replace(/s/g, secs.toString())
+ .replace(/A/g, isPM ? 'PM' : 'AM')
+ .replace(/a/g, isPM ? 'pm' : 'am');
}
\ No newline at end of file
diff --git a/src/utility/networkRequest.ts b/src/utility/networkRequest.ts
new file mode 100644
index 0000000..5d5420e
--- /dev/null
+++ b/src/utility/networkRequest.ts
@@ -0,0 +1,104 @@
+import { requestUrl, type RequestUrlResponse } from "obsidian";
+
+const DEFAULT_TIMEOUT_MS = 30000; // 30 seconds
+
+export class NetworkError extends Error {
+ constructor(
+ message: string,
+ public readonly url: string,
+ public readonly cause?: unknown,
+ ) {
+ super(message);
+ this.name = "NetworkError";
+ }
+}
+
+export class TimeoutError extends NetworkError {
+ constructor(url: string, timeoutMs: number) {
+ super(`Request timed out after ${timeoutMs}ms`, url);
+ this.name = "TimeoutError";
+ }
+}
+
+/**
+ * Makes a network request with timeout protection.
+ * Throws TimeoutError if the request takes longer than the specified timeout.
+ * Throws NetworkError for other network-related failures.
+ */
+export async function requestWithTimeout(
+ url: string,
+ options: {
+ timeoutMs?: number;
+ method?: string;
+ headers?: Record;
+ body?: string;
+ } = {},
+): Promise {
+ const { timeoutMs = DEFAULT_TIMEOUT_MS, method, headers, body } = options;
+
+ let timeoutId: ReturnType | undefined;
+
+ const timeoutPromise = new Promise((_, reject) => {
+ timeoutId = setTimeout(() => {
+ reject(new TimeoutError(url, timeoutMs));
+ }, timeoutMs);
+ });
+
+ try {
+ const response = await Promise.race([
+ requestUrl({
+ url,
+ method,
+ headers,
+ body,
+ throw: false, // Don't throw on non-2xx status
+ }),
+ timeoutPromise,
+ ]);
+
+ // Check for HTTP errors
+ if (response.status >= 400) {
+ throw new NetworkError(
+ `HTTP ${response.status}: ${response.text?.slice(0, 100) || "Unknown error"}`,
+ url,
+ );
+ }
+
+ return response;
+ } catch (error) {
+ if (error instanceof NetworkError) {
+ throw error;
+ }
+ throw new NetworkError(
+ error instanceof Error ? error.message : String(error),
+ url,
+ error,
+ );
+ } finally {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ }
+}
+
+/**
+ * Fetches JSON from a URL with timeout protection.
+ */
+export async function fetchJsonWithTimeout(
+ url: string,
+ options: { timeoutMs?: number } = {},
+): Promise {
+ const response = await requestWithTimeout(url, options);
+ return response.json as T;
+}
+
+/**
+ * Fetches text from a URL with timeout protection.
+ */
+export async function fetchTextWithTimeout(
+ url: string,
+ options: { timeoutMs?: number } = {},
+): Promise {
+ const response = await requestWithTimeout(url, options);
+ return response.text;
+}