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; +}