Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/TemplateEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down
2 changes: 1 addition & 1 deletion src/createPodcastNote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 2 additions & 5 deletions src/createPodcastNote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,8 @@ async function getTemplateChapters(
template: string,
episode: Episode,
): Promise<Chapter[] | undefined> {
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 {
Expand Down
4 changes: 2 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 8 additions & 6 deletions src/player/mediaSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class FakeMediaSession {
function setup({
mediaSession = new FakeMediaSession() as FakeMediaSession | null,
Metadata = FakeMetadata as typeof FakeMetadata | null,
chapters = (_url: string): Promise<Chapter[]> => Promise.resolve([]),
chapters = (_episode: Episode): Promise<Chapter[]> => Promise.resolve([]),
} = {}) {
const doc = new FakeDocument();
const host = {
Expand Down Expand Up @@ -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";
Expand All @@ -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 () => {
Expand All @@ -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<Chapter[]>>()
.fn<(episode: Episode) => Promise<Chapter[]>>()
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce([])
.mockResolvedValue(CHAPTERS);
Expand All @@ -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" }));
},
);

Expand Down
13 changes: 7 additions & 6 deletions src/player/mediaSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,7 +19,7 @@ interface MediaSessionHost {
captureTimestamp(): void;
skipBackward(): void;
skipForward(): void;
chapters(url: string): Promise<Chapter[]>;
chapters(episode: Episode): Promise<Chapter[]>;
}

const CHAPTER_RESTART_S = 2;
Expand Down Expand Up @@ -81,15 +82,15 @@ export function bindMediaSession(
const { mediaSession, MediaMetadata } = host;
let episodeKey = "";
let activation = 0;
let chapters: { url: string; loading: Promise<Chapter[]> } | undefined;
let chapters: { key: string; loading: Promise<Chapter[]> } | undefined;

async function seekChapter(direction: 1 | -1): Promise<void> {
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;
Expand Down
2 changes: 1 addition & 1 deletion src/types/Episode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface Episode {
episodeNumber?: number;
/** Episode duration in whole seconds, from `<itunes:duration>` where present. */
duration?: number;
/** URL to the podcast:chapters JSON file */
/** URL to the podcast:chapters JSON file. Without one, a streamed episode's chapters come from its ID3 tag. */
chaptersUrl?: string;
/** Media element type used to play the enclosure/local file. */
mediaType?: EpisodeMediaType;
Expand Down
21 changes: 14 additions & 7 deletions src/ui/PodcastView/Player.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { tick } from "svelte";
import { tick, untrack } from "svelte";
import type { Chapter } from "src/types/Chapter";
import type { Episode } from "src/types/Episode";
import { icon } from "../obsidian/icon";
Expand Down Expand Up @@ -63,16 +63,23 @@
return wide ? panels.find((kind) => kind !== "transcript") : undefined;
});
const chaptersUrl = $derived(episode.chaptersUrl);
const streamUrl = $derived(episode.streamUrl);
// Chapters without a chapters file come from the media, which a restored player has not loaded.
const chaptersReady = $derived(!!chaptersUrl || $playerState.status !== "restored");

$effect.pre(() => {
const url = chaptersUrl;
void [chaptersUrl, streamUrl];
// A choice of Chapters belongs to the episode it was made on.
panel = undefined;
const cached = url && cachedChapters(url);
chapters = cached || [];
if (!url || cached) return;
if (untrack(() => panel) === "chapters") panel = undefined;
});

$effect.pre(() => {
const source = { chaptersUrl, streamUrl };
const cached = cachedChapters(source);
chapters = cached ?? [];
if (cached || !chaptersReady) return;
let latest = true;
void loadChapters(url).then((loaded) => latest && (chapters = loaded));
void loadChapters(source).then((loaded) => latest && (chapters = loaded));
return () => (latest = false);
});

Expand Down
15 changes: 9 additions & 6 deletions src/ui/PodcastView/Player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@ import {
} from "../../../tests/mocks/services";
import PodcastView from "./PodcastView.svelte";

vi.mock("src/utility/fetchChapters", () => ({
fetchChapters: async () =>
["Intro", "Memory palaces", "Questions"].map((title, index) => ({
title,
startTime: index * 600,
})),
vi.mock("src/utility/fetchChapters", async (importOriginal) => ({
...(await importOriginal<typeof import("src/utility/fetchChapters")>()),
fetchChapters: async ({ chaptersUrl }: Episode) =>
chaptersUrl
? ["Intro", "Memory palaces", "Questions"].map((title, index) => ({
title,
startTime: index * 600,
}))
: [],
}));

const episode: Episode = {
Expand Down
35 changes: 25 additions & 10 deletions src/ui/PodcastView/chapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { describe, expect, test, vi } from "vitest";
import { fetchChapters } from "src/utility/fetchChapters";
import { cachedChapters, chapterAt, loadChapters, segmentsOf } from "./chapters";

vi.mock("src/utility/fetchChapters", () => ({ fetchChapters: vi.fn() }));
vi.mock("src/utility/fetchChapters", async (importOriginal) => ({
...(await importOriginal<typeof import("src/utility/fetchChapters")>()),
fetchChapters: vi.fn(),
}));

const chapters = [0, 60, 120].map((startTime) => ({ startTime, title: `At ${startTime}` }));

Expand Down Expand Up @@ -37,21 +40,33 @@ describe("chapters", () => {
});

test("a load that failed, as offline, fetches again the next time", async () => {
const url = "https://pod.test/chapters.json";
vi.mocked(fetchChapters).mockResolvedValueOnce([]).mockResolvedValueOnce(chapters);
const episode = { chaptersUrl: "https://pod.test/chapters.json", streamUrl: "" };
vi.mocked(fetchChapters).mockResolvedValueOnce(undefined).mockResolvedValueOnce(chapters);

expect(await loadChapters(url)).toEqual([]);
expect(cachedChapters(url)).toBeUndefined();
expect(await loadChapters(url)).toEqual(chapters);
expect(await loadChapters(episode)).toEqual([]);
expect(cachedChapters(episode)).toBeUndefined();
expect(await loadChapters(episode)).toEqual(chapters);

expect(fetchChapters).toHaveBeenCalledTimes(2);
expect(cachedChapters(url)).toEqual(chapters);
expect(cachedChapters("https://pod.test/other.json")).toBeUndefined();
expect(cachedChapters(episode)).toEqual(chapters);
expect(
cachedChapters({ ...episode, chaptersUrl: "https://pod.test/other.json" }),
).toBeUndefined();
});

test("media found to have no chapters is not asked again", async () => {
const episode = { streamUrl: "https://pod.test/plain.mp3" };
vi.mocked(fetchChapters).mockClear().mockResolvedValueOnce([]);

expect(await loadChapters(episode)).toEqual([]);
expect(await loadChapters(episode)).toEqual([]);
expect(cachedChapters(episode)).toEqual([]);
expect(fetchChapters).toHaveBeenCalledOnce();
});

test("each episode keeps the chapters it loaded, whichever load finishes first", async () => {
const earlier = "https://pod.test/earlier.json";
const latest = "https://pod.test/latest.json";
const earlier = { chaptersUrl: "https://pod.test/earlier.json", streamUrl: "" };
const latest = { streamUrl: "https://pod.test/latest.mp3" };
let finishEarlier: (loaded: typeof chapters) => void = () => {};
vi.mocked(fetchChapters)
.mockReturnValueOnce(new Promise((resolve) => (finishEarlier = resolve)))
Expand Down
18 changes: 10 additions & 8 deletions src/ui/PodcastView/chapters.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Chapter } from "src/types/Chapter";
import { fetchChapters } from "src/utility/fetchChapters";
import { chaptersKey, fetchChapters, type ChaptersSource } from "src/utility/fetchChapters";

/** The chapter under way at `time`: the last to start at or before it. -1 before the first. */
export function chapterAt(chapters: readonly Chapter[], time: number): number {
Expand All @@ -22,13 +22,15 @@ export function segmentsOf(chapters: readonly Chapter[], duration: number): [num

const loaded = new Map<string, Chapter[]>();

export function cachedChapters(url: string): Chapter[] | undefined {
return loaded.get(url);
export function cachedChapters(episode: ChaptersSource): Chapter[] | undefined {
return loaded.get(chaptersKey(episode));
}

/** The chapters at `url`. A failed load, as offline, is not kept, so the next one fetches again. */
export async function loadChapters(url: string): Promise<Chapter[]> {
const chapters = await fetchChapters(url);
if (chapters.length > 0) loaded.set(url, chapters);
return chapters;
/** The episode's chapters. A failed load, as offline, is not kept, so the next one fetches again. */
export async function loadChapters(episode: ChaptersSource): Promise<Chapter[]> {
const cached = cachedChapters(episode);
if (cached) return cached;
const chapters = await fetchChapters(episode);
if (chapters) loaded.set(chaptersKey(episode), chapters);
return chapters ?? [];
}
30 changes: 27 additions & 3 deletions src/utility/fetchChapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchChapters } from "./fetchChapters";

const mockFetchTextWithTimeout = vi.hoisted(() => vi.fn());
const mockFetchId3Chapters = vi.hoisted(() => vi.fn());

vi.mock("./networkRequest", () => ({
fetchTextWithTimeout: mockFetchTextWithTimeout,
}));
vi.mock("./id3Chapters", () => ({ fetchId3Chapters: mockFetchId3Chapters }));

const json = (chaptersUrl: string) => ({
chaptersUrl,
streamUrl: "https://example.com/episode.mp3",
});

describe("fetchChapters", () => {
beforeEach(() => {
mockFetchTextWithTimeout.mockReset();
mockFetchId3Chapters.mockReset();
});

it("normalizes malformed chapter entries instead of returning raw JSON", async () => {
Expand All @@ -27,7 +35,7 @@ describe("fetchChapters", () => {
}),
);

await expect(fetchChapters("https://example.com/chapters.json")).resolves.toEqual([
await expect(fetchChapters(json("https://example.com/chapters.json"))).resolves.toEqual([
{ startTime: 0, title: "Intro" },
{ startTime: 35, title: "" },
{ startTime: 65, title: "Deep Dive" },
Expand All @@ -44,13 +52,29 @@ describe("fetchChapters", () => {
new Error("Network request target is not allowed."),
);

await expect(fetchChapters("file:///tmp/chapters.json")).resolves.toEqual([]);
await expect(fetchChapters(json("file:///tmp/chapters.json"))).resolves.toBeUndefined();
expect(mockFetchTextWithTimeout).toHaveBeenCalledOnce();
});

it("ignores oversized chapter payloads", async () => {
mockFetchTextWithTimeout.mockResolvedValue(" ".repeat(1_000_001));

await expect(fetchChapters("https://example.com/chapters.json")).resolves.toEqual([]);
await expect(fetchChapters(json("https://example.com/chapters.json"))).resolves.toEqual([]);
});

it("reads the media's ID3 chapters when the feed names no chapters file", async () => {
const id3 = [{ startTime: 0, title: "Intro" }];
mockFetchId3Chapters.mockResolvedValue(id3);

await expect(
fetchChapters({ streamUrl: "https://example.com/episode.mp3" }),
).resolves.toEqual(id3);
await expect(
fetchChapters({ streamUrl: "app://local/vault/Episode.mp3" }),
).resolves.toEqual([]);
expect(mockFetchId3Chapters).toHaveBeenCalledExactlyOnceWith(
"https://example.com/episode.mp3",
);
expect(mockFetchTextWithTimeout).not.toHaveBeenCalled();
});
});
Loading
Loading