From 4427d7355a8e16e0d68c8c306b3e95e016d0513d Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 24 Oct 2026 16:22:16 +0200 Subject: [PATCH 1/4] feat(chapters): read embedded ID3 chapters from MP3 episodes --- src/TemplateEngine.ts | 4 +- src/createPodcastNote.test.ts | 2 +- src/createPodcastNote.ts | 7 +- src/main.ts | 4 +- src/player/mediaSession.test.ts | 14 +- src/player/mediaSession.ts | 13 +- src/types/Episode.ts | 2 +- src/ui/PodcastView/Player.svelte | 21 ++- src/ui/PodcastView/Player.test.ts | 15 ++- src/ui/PodcastView/chapters.test.ts | 34 +++-- src/ui/PodcastView/chapters.ts | 16 +-- src/utility/fetchChapters.test.ts | 30 ++++- src/utility/fetchChapters.ts | 26 +++- src/utility/id3Chapters.test.ts | 193 ++++++++++++++++++++++++++++ src/utility/id3Chapters.ts | 115 +++++++++++++++++ 15 files changed, 432 insertions(+), 64 deletions(-) create mode 100644 src/utility/id3Chapters.test.ts create mode 100644 src/utility/id3Chapters.ts diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index 5d522c86..43e7e5b3 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -347,8 +347,8 @@ export function NoteTemplateEngine( addTag("url", episodeUrl); addEpisodeDateTags(addTag, episode); addTag("duration", durationTag(episode)); - // Podcasting 2.0 chapters, fetched before note creation when the template - // asks for them. Empty when the feed has no chapters URL or fetching fails. + // The chapters, fetched before note creation when the template + // asks for them. Empty when the episode has none or fetching fails. addTag("chapters", (prependToLines?: string) => formatTemplateChapters(chapters, prependToLines), ); diff --git a/src/createPodcastNote.test.ts b/src/createPodcastNote.test.ts index 5d6bb07c..8b6a4e11 100644 --- a/src/createPodcastNote.test.ts +++ b/src/createPodcastNote.test.ts @@ -84,7 +84,7 @@ describe("createPodcastNote chapters template support (#47)", () => { await createPodcastNote(episode); - expect(mockFetchChapters).toHaveBeenCalledWith("https://example.com/chapters.json"); + expect(mockFetchChapters).toHaveBeenCalledWith(episode); expect(createdFiles[0]).toMatchObject({ path: "PodNotes/Chaptered Episode.md", data: "# Chaptered Episode\n\n- 0:00 Intro\n- 1:05 Deep Dive", diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index 4a56e862..371a2658 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -118,11 +118,8 @@ async function getTemplateChapters( template: string, episode: Episode, ): Promise { - if (!episode.chaptersUrl || !templateHasTag(template, "chapters")) { - return undefined; - } - - return await fetchChapters(episode.chaptersUrl); + if (!templateHasTag(template, "chapters")) return undefined; + return (await fetchChapters(episode)) ?? []; } export function getPodcastNote(episode: Episode): TFile | null { diff --git a/src/main.ts b/src/main.ts index 30e88773..b8179770 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import type { IAPI } from "src/API/IAPI"; import { VIEW_TYPE } from "src/constants"; import { PodNotesSettingsTab } from "src/ui/settings/PodNotesSettingsTab"; import { MainView } from "src/ui/PodcastView"; +import { loadChapters } from "src/ui/PodcastView/chapters"; import { Navigation } from "src/ui/PodcastView/Navigation"; import { dismissUndoNotices } from "src/ui/undoNotice"; import { removePreviousStyles } from "src/ui/removePreviousStyles"; @@ -57,7 +58,6 @@ import { FeedUrlRepository } from "./services/FeedUrlRepository"; import { privateFeedAddresses, protectPrivateFeeds, resolveFeedUrl } from "./services/privateFeeds"; import { redactPrivateFeedAddresses } from "./utility/privateFeedUrl"; import { FeedRepository, loadFeed } from "./services/FeedRepository"; -import { fetchChapters } from "./utility/fetchChapters"; import { onlineStatus } from "./services/online"; type Closing = "window-unloading" | "obsidian-closing"; @@ -235,7 +235,7 @@ export default class PodNotes extends Plugin implements IPodNotes { captureTimestamp: () => void this.capture.timestamp(), skipBackward: () => this.api.skipBackward(), skipForward: () => this.api.skipForward(), - chapters: fetchChapters, + chapters: loadChapters, }), ); this.isReady = true; diff --git a/src/player/mediaSession.test.ts b/src/player/mediaSession.test.ts index 106d8b7f..9bf4aa71 100644 --- a/src/player/mediaSession.test.ts +++ b/src/player/mediaSession.test.ts @@ -85,7 +85,7 @@ class FakeMediaSession { function setup({ mediaSession = new FakeMediaSession() as FakeMediaSession | null, Metadata = FakeMetadata as typeof FakeMetadata | null, - chapters = (_url: string): Promise => Promise.resolve([]), + chapters = (_episode: Episode): Promise => Promise.resolve([]), } = {}) { const doc = new FakeDocument(); const host = { @@ -445,7 +445,7 @@ describe("headset mapping", () => { }); test("next and previous chapter seek to the episode's chapter starts", async () => { - const chapters = vi.fn(() => Promise.resolve(CHAPTERS)); + const chapters = vi.fn((_episode: Episode) => Promise.resolve(CHAPTERS)); const context = setup({ chapters }); context.host.settings.headsetPreviousTrack = "previousChapter"; context.host.settings.headsetNextTrack = "nextChapter"; @@ -469,7 +469,9 @@ describe("headset mapping", () => { context.session.act("nexttrack"); await flush(); expect(context.media().currentTime).toBe(130); - expect(chapters.mock.calls).toEqual([[WITH_CHAPTERS.chaptersUrl]]); + expect(chapters.mock.calls.map(([episode]) => episode.chaptersUrl)).toEqual([ + WITH_CHAPTERS.chaptersUrl, + ]); }); test("chapters that arrive after the episode changed move nothing", async () => { @@ -493,7 +495,7 @@ describe("headset mapping", () => { "%s with no chapters is a no-op and retries", async (action) => { const chapters = vi - .fn<(url: string) => Promise>() + .fn<(episode: Episode) => Promise>() .mockRejectedValueOnce(new Error("offline")) .mockResolvedValueOnce([]) .mockResolvedValue(CHAPTERS); @@ -511,8 +513,8 @@ describe("headset mapping", () => { playing(context, 30, 3600, B); context.session.act(action); await flush(); - expect(chapters).toHaveBeenCalledTimes(3); - expect(context.media().currentTime).toBe(30); + expect(chapters).toHaveBeenCalledTimes(4); + expect(chapters).toHaveBeenLastCalledWith(expect.objectContaining({ title: "B" })); }, ); diff --git a/src/player/mediaSession.ts b/src/player/mediaSession.ts index 0427c2e6..4409ff9d 100644 --- a/src/player/mediaSession.ts +++ b/src/player/mediaSession.ts @@ -4,6 +4,7 @@ import type { Episode } from "src/types/Episode"; import type { NextTrackAction, PreviousTrackAction } from "src/types/Headset"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import { getEpisodeKey } from "src/utility/episodeKey"; +import { chaptersKey } from "src/utility/fetchChapters"; import { isPlaying, type PlayerController, @@ -18,7 +19,7 @@ interface MediaSessionHost { captureTimestamp(): void; skipBackward(): void; skipForward(): void; - chapters(url: string): Promise; + chapters(episode: Episode): Promise; } const CHAPTER_RESTART_S = 2; @@ -81,15 +82,15 @@ export function bindMediaSession( const { mediaSession, MediaMetadata } = host; let episodeKey = ""; let activation = 0; - let chapters: { url: string; loading: Promise } | undefined; + let chapters: { key: string; loading: Promise } | undefined; async function seekChapter(direction: 1 | -1): Promise { const { episode } = get(player.state); - const url = episode?.chaptersUrl; - if (!url) return; + if (!episode) return; const requestedActivation = activation; - if (chapters?.url !== url) { - chapters = { url, loading: host.chapters(url).catch(() => []) }; + const key = chaptersKey(episode); + if (chapters?.key !== key) { + chapters = { key, loading: host.chapters(episode).catch(() => []) }; } const asked = chapters; const loaded = await asked.loading; diff --git a/src/types/Episode.ts b/src/types/Episode.ts index bcc6a1f0..e3e1f7e8 100644 --- a/src/types/Episode.ts +++ b/src/types/Episode.ts @@ -22,7 +22,7 @@ export interface Episode { episodeNumber?: number; /** Episode duration in whole seconds, from `` where present. */ duration?: number; - /** URL to the podcast:chapters JSON file */ + /** URL to the podcast:chapters JSON file. Without one, chapters come from the media's ID3 tag. */ chaptersUrl?: string; /** Media element type used to play the enclosure/local file. */ mediaType?: EpisodeMediaType; diff --git a/src/ui/PodcastView/Player.svelte b/src/ui/PodcastView/Player.svelte index 80c5f78d..343dcb9f 100644 --- a/src/ui/PodcastView/Player.svelte +++ b/src/ui/PodcastView/Player.svelte @@ -1,5 +1,5 @@