Skip to content
Merged
67 changes: 66 additions & 1 deletion src/main.persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import * as obsidian from "obsidian";
import { DEFAULT_SETTINGS } from "./constants";
import PodNotes from "./main";
import * as privateFeedUrls from "./utility/privateFeedUrl";
import { getProgressKey } from "./utility/episodeKey";
import { getPlayedEpisode } from "./utility/episodeStatus";
import type { Episode } from "./types/Episode";
import * as persistenceMerge from "./persistence/merge";
import { encodePodNotesData } from "./persistence/podNotesData";
import * as podNotesData from "./persistence/podNotesData";
import { PlayerController } from "./player/PlayerController";
import { downloadedEpisodes, localFiles, plugin as pluginStore } from "./store";
import { downloadedEpisodes, localFiles, playedEpisodes, plugin as pluginStore } from "./store";
import { bindStoresToSettings } from "./store/persistence";
import { CredentialRepository } from "./services/CredentialRepository";
import {
Expand Down Expand Up @@ -340,6 +343,8 @@ describe("private addresses adopted from sync", () => {
await settled();

expect(plugin.settings.playedEpisodes["PRIV2 Both::PRIV2 Episode"].time).toBe(30);
const fromFeed = { ...plugin.settings.currentEpisode!, guid: "priv2-episode" };
expect(getPlayedEpisode(plugin.settings.playedEpisodes, fromFeed)?.time).toBe(30);
expect(await plugin.loadData()).toEqual(released2211);
expect(save).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -938,6 +943,66 @@ describe("PodNotes persistence integration", () => {
);
});

it("lends a 2.21.1 library's progress to its episode but not to two that share a title, and a write adds one key", async () => {
const fromFeed = (title: string, guid: string, sharedTitle?: true): Episode => ({
title,
podcastName: "PRIV2 Both",
guid,
sharedTitle,
streamUrl: "",
url: "",
description: "",
content: "",
});
const released = {
...structuredClone(released2211),
playedEpisodes: {
...released2211.playedEpisodes,
"PRIV2 Both::Rerun": {
title: "Rerun",
podcastName: "PRIV2 Both",
time: 180,
duration: 180,
finished: true,
},
},
};
const saveData = vi.fn().mockResolvedValue(undefined);
const plugin = makePlugin(saveData, { dataFileExists: true });
Object.assign(plugin, { loadData: vi.fn().mockResolvedValue(structuredClone(released)) });
const episode = fromFeed("PRIV2 Episode", "priv2-episode");

await plugin.loadSettings();
attachDataFileWriter(plugin);
(plugin as unknown as { setCollectionStores(): void }).setCollectionStores();
onTestFinished(bindStoresToSettings(plugin));

expect(playedEpisodes.get(episode)).toEqual(
released.playedEpisodes["PRIV2 Both::PRIV2 Episode"],
);
expect(playedEpisodes.get(fromFeed("Rerun", "rerun-1", true))).toBeUndefined();
expect(playedEpisodes.get(fromFeed("Rerun", "rerun-2", true))).toBeUndefined();

playedEpisodes.setEpisodeTime(episode, 40, 180, false);
await plugin.flushSaves();

expect(saveData).toHaveBeenCalledTimes(1);
expect(saveData.mock.calls[0][0]).toEqual({
...savedByThisVersion(released),
libraryId: "",
playedEpisodes: {
...released.playedEpisodes,
[getProgressKey(episode)]: {
title: "PRIV2 Episode",
podcastName: "PRIV2 Both",
time: 40,
duration: 180,
finished: false,
},
},
});
});

it("moves a 2.21.1 library's path-secret feed address into SecretStorage once, for each device to set", async () => {
const rawUrl = "https://priv3.supercast.com/feeds/RELEASED_SENTINEL";
const released = JSON.parse(
Expand Down
2 changes: 2 additions & 0 deletions src/parser/feedDocumentParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface ParsedEpisodeDocument {
duration?: number;
chaptersUrl?: string;
guid?: string;
/** In the feed cache: another item of the full feed has this title, though the cut may have dropped it. */
sharedTitle?: true;
transcripts?: TranscriptLink[];
mediaType: EpisodeMediaType;
}
Expand Down
4 changes: 4 additions & 0 deletions src/persistence/episodeCodec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ export function decodeEpisode(

setOptionalString(decoded, value, "feedUrl", warnings, path);
setOptionalString(decoded, value, "guid", warnings, path);
if (value.sharedTitle !== undefined && value.sharedTitle !== true) {
delete decoded.sharedTitle;
warn(warnings, `${path}.sharedTitle`, "expected true");
}
setOptionalString(decoded, value, "artworkUrl", warnings, path);
setOptionalString(decoded, value, "itunesTitle", warnings, path);
setOptionalString(decoded, value, "chaptersUrl", warnings, path);
Expand Down
2 changes: 1 addition & 1 deletion src/persistence/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function isEpisodeEntry(value: unknown): value is EpisodeEntry {
);
}

/** The identity played state and downloads use, so same-titled episodes of two podcasts stay apart. */
/** The identity playlists and downloads use, so same-titled episodes of two podcasts stay apart. */
function episodeEntryKey(entry: EpisodeEntry): string {
return getEpisodeKey(entry as unknown as Episode);
}
Expand Down
11 changes: 11 additions & 0 deletions src/persistence/podNotesData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,17 @@ describe("PodNotes data schema", () => {
).toEqual(stored);
});

it("keeps an episode's shared title mark only when it is true", () => {
const decoded = decodePodNotesData({
currentEpisode: { ...episode("Restored"), sharedTitle: "no" },
queue: { episodes: [{ ...episode("Queued"), sharedTitle: true }] },
});

expect(decoded.settings.currentEpisode).not.toHaveProperty("sharedTitle");
expect(decoded.settings.queue.episodes[0].sharedTitle).toBe(true);
expect(decoded.warnings).toEqual(["currentEpisode.sharedTitle: expected true"]);
});

it("drops a guid that is not a string", () => {
const decoded = decodePodNotesData({
currentEpisode: { ...episode("Restored"), guid: 12345 },
Expand Down
26 changes: 26 additions & 0 deletions src/player/PlayerController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,32 @@ describe("switching episodes (#353)", () => {
expect(media().currentTime).toBe(1234);
});

test.each([
["another item of its feed", episode("Rerun", { guid: "rerun-1", sharedTitle: true })],
["a copy saved before guids", episode("Rerun")],
])(
"starts an episode at its own position after %s with its title paused (#448)",
(_, first) => {
const second = episode("Rerun", {
guid: "rerun-2",
sharedTitle: true,
streamUrl: "https://cdn.example.com/Rerun-2.mp3",
});
playedEpisodes.setEpisodeTime(second, 12, 3600, false);
const { player, media } = setup();
player.play(first);
metadata(media());
reach(media(), 30);
player.pause();

player.play(second);
metadata(media());

expect(media().currentTime).toBe(12);
expect(playedEpisodes.get(first)?.time).toBe(30);
},
);

test("does not queue the episode switched away from when the queue is not automatic", () => {
const { player } = setup({ autoQueue: false });
player.restore(A);
Expand Down
8 changes: 4 additions & 4 deletions src/player/PlayerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { Episode, EpisodeMediaType } from "src/types/Episode";
import type { IPodNotesSettings } from "src/types/IPodNotesSettings";
import type { PlayedEpisode } from "src/types/PlayedEpisode";
import { createMediaUrlObjectFromFilePath } from "src/utility/createMediaUrlObjectFromFilePath";
import { getEpisodeKey, isSameEpisode, isSameStoredEpisode } from "src/utility/episodeKey";
import { getEpisodeKey, isSameFeedItem, isSameStoredEpisode } from "src/utility/episodeKey";
import { getPlayedEpisode } from "src/utility/episodeStatus";
import { getEpisodeMediaType } from "src/utility/mediaType";
import { normalizePlaybackRate } from "src/utility/playbackRate";
Expand Down Expand Up @@ -150,7 +150,7 @@ export class PlayerController {
return;
}
if (!episode || (status !== "empty" && status !== "restored")) return;
this.moved &&= !!shown && isSameEpisode(shown, episode);
this.moved &&= !!shown && isSameFeedItem(shown, episode);
this.setState({
status: "restored",
episode,
Expand Down Expand Up @@ -197,7 +197,7 @@ export class PlayerController {
const source = selectSource(episode, get(downloadedEpisodes), this.plugin.app.vault);
this.noteStoppedMove();
const { status, episode: outgoing, mediaType } = this.snapshot;
const sameEpisode = !!outgoing && isSameEpisode(outgoing, episode);
const sameEpisode = !!outgoing && isSameFeedItem(outgoing, episode);
this.select(episode);

const session = this.session;
Expand Down Expand Up @@ -370,7 +370,7 @@ export class PlayerController {
const shown = this.snapshot.episode;
// Reloading the shown episode, as on its first play after a restart, keeps the user's speed.
const playbackRate =
shown && isSameEpisode(shown, episode)
shown && isSameFeedItem(shown, episode)
? this.snapshot.playbackRate
: normalizePlaybackRate(this.plugin.settings.defaultPlaybackRate);
if (!source.src) {
Expand Down
39 changes: 39 additions & 0 deletions src/services/FeedRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { ParsedEpisodeDocument, ParsedFeedEpisodes } from "src/parser/feedDocumentParser";
import { encodeEpisode } from "src/persistence/episodeCodec";
import type { PodcastFeed } from "src/types/PodcastFeed";
import { getPlayedEpisode } from "src/utility/episodeStatus";
import { privateFeedPlaceholder } from "src/utility/privateFeedUrl";
import { deferred, MemoryStorage, stubLegacyStorage } from "../../tests/mocks/feeds";
import { FeedRepository } from "./FeedRepository";
Expand Down Expand Up @@ -562,6 +563,20 @@ describe("FeedRepository", () => {
]);
});

test("marks each item whose title another item of the feed has", async () => {
const t = setup();
const items = [item(1, { title: "Rerun" }), item(2), item(3, { title: "Rerun" })];
await loaded(t, { Alpha: alpha }, doc("Alpha", items));

expect(
t.feed("Alpha")!.episodes.map(({ title, sharedTitle }) => [title, sharedTitle]),
).toEqual([
["Rerun", true],
["Episode 2", undefined],
["Rerun", true],
]);
});

test("a guid whose twin the cache cut identifies no episode after a restart", async () => {
const first = setup();
const items = Array.from({ length: 76 }, (_, index) =>
Expand All @@ -582,6 +597,30 @@ describe("FeedRepository", () => {
expect(guids.get("Episode 75")).toBe("own-75");
});

test("a title whose twin the cache cut still lends neither its old record after a failed refresh (#448)", async () => {
const first = setup();
const items = Array.from({ length: 76 }, (_, index) => item(76 - index));
items[0] = item(76, { title: "Rerun", guid: "rerun" });
items[75] = item(1, { title: "Rerun", guid: "original" });
await loaded(first, { Alpha: alpha }, doc("Alpha", items));

const t = setup({ storage: first.storage });
t.repo.setSubscriptions({ Alpha: alpha });
const refresh = t.repo.load(["Alpha"], "network");
t.requests[0].respond.reject(new Error("503"));
await refresh;

const rerun = t.feed("Alpha")!.episodes.find(({ guid }) => guid === "rerun")!;
const saved = {
title: "Rerun",
podcastName: "Alpha",
time: 40,
duration: 40,
finished: true,
};
expect(getPlayedEpisode({ "Alpha::Rerun": saved }, rerun)).toBeUndefined();
});

test("reads an episode's transcripts from its own subscription's feed, by guid first", async () => {
const t = setup();
const transcripts = [{ url: "https://media.test/b.vtt", type: "text/vtt" }];
Expand Down
27 changes: 18 additions & 9 deletions src/services/FeedRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,8 @@ function projectEpisodes(
items: readonly ParsedEpisodeDocument[],
feed: Pick<PodcastFeed, "title" | "url" | "artworkUrl">,
): Episode[] {
const repeated = repeatedGuids(items);
const repeated = repeatedValues(items, "guid");
const sharedTitles = repeatedValues(items, "title");
return items.map((item) => ({
title: item.title,
streamUrl: item.streamUrl,
Expand All @@ -361,6 +362,7 @@ function projectEpisodes(
feedUrl: feed.url,
// A guid that several items repeat identifies none of them, so notes and links never get it.
guid: item.guid && !repeated.has(item.guid) ? item.guid : undefined,
sharedTitle: item.sharedTitle === true || sharedTitles.has(item.title) || undefined,
itunesTitle: item.itunesTitle || "",
episodeNumber: item.episodeNumber,
duration: item.duration,
Expand Down Expand Up @@ -393,21 +395,28 @@ const MAX_CACHE_BYTES = 4 * 1024 * 1024;
function keepNewestItems(doc: FeedDocument): FeedDocument {
if (doc.items.length <= MAX_ITEMS_PER_FEED) return doc;
const kept = new Set([...doc.items].sort(newestFirst).slice(0, MAX_ITEMS_PER_FEED));
// The cut can drop one of two items that share a guid, which would make the other look unique.
const repeated = repeatedGuids(doc.items);
// The cut can drop one of two items that share a guid or a title, which would make the other look unique.
const repeated = repeatedValues(doc.items, "guid");
const sharedTitles = repeatedValues(doc.items, "title");
const items = doc.items
.filter((item) => kept.has(item))
.map((item) =>
item.guid && repeated.has(item.guid) ? { ...item, guid: undefined } : item,
);
.map((item) => ({
...item,
guid: item.guid && !repeated.has(item.guid) ? item.guid : undefined,
sharedTitle: sharedTitles.has(item.title) || undefined,
}));
return { ...doc, items, complete: false };
}

/** Guids that more than one item carries. */
Comment thread
cursor[bot] marked this conversation as resolved.
function repeatedGuids(items: readonly ParsedEpisodeDocument[]): Set<string> {
/** Values of `field` that more than one item carries. */
function repeatedValues(
items: readonly ParsedEpisodeDocument[],
field: "guid" | "title",
): Set<string> {
const seen = new Set<string>();
const repeated = new Set<string>();
for (const { guid } of items) if (guid) (seen.has(guid) ? repeated : seen).add(guid);
for (const { [field]: value } of items)
if (value) (seen.has(value) ? repeated : seen).add(value);
return repeated;
}

Expand Down
Loading
Loading