diff --git a/src/API/API.test.ts b/src/API/API.test.ts index 4c05c916..fe7edef0 100644 --- a/src/API/API.test.ts +++ b/src/API/API.test.ts @@ -138,6 +138,19 @@ describe("API.getPodcastSegmentFormatted", () => { ); }); + test("formats the numeric strings that JavaScript workflows pass as times", () => { + const { api, player } = setup(); + player.restore(feedEpisode); + const seconds = (value: string) => value as unknown as number; + + expect( + api.getPodcastSegmentFormatted("HH:mm:ss", seconds("115"), seconds("125"), true), + ).toBe("00:01:55-00:02:05"); + expect(api.getPodcastSegmentFormatted("HH:mm:ss", 115, seconds("125"))).toBe( + "00:01:55-00:02:05", + ); + }); + test("seeking through the public API ends a segment link's stop", () => { const { api, player, media } = setup(); player.play(feedEpisode, { time: 115, endTime: 125 }); diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index ecc6363a..5d522c86 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -154,15 +154,28 @@ function addEpisodeFileNameTags( ): void { addTag("title", legalizedNameTag(episode.title, sanitizeFileName)); addTag("podcast", legalizedNameTag(episode.podcastName, sanitizeFileName)); - addTag("date", (format?: string) => - episode.episodeDate ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") : "", + addEpisodeDateTags(addTag, episode); +} + +function addEpisodeDateTags(addTag: AddTagFn, episode: Episode): void { + addTag("date", (format = "YYYY-MM-DD") => + episode.episodeDate ? formatDate(episode.episodeDate, format) : "", ); - addTag("currentdate", (format?: string) => formatDate(new Date(), format ?? "YYYY-MM-DD")); + // The current date the note is created on, distinct from {{date}} (the episode + // publish date). Supports the same Moment.js format arg. See issue #75. + addTag("currentdate", (format = "YYYY-MM-DD") => formatDate(new Date(), format)); + // Episode number from , else best-effort from the title. See #34. addTag("episodenumber", (pad?: string) => formatEpisodeNumber(resolveEpisodeNumber(episode), pad), ); } +// Episode duration from . See issue #88. +function durationTag(episode: Episode): TagValue { + return (format?: string) => + episode.duration !== undefined ? formatDuration(episode.duration, format) : ""; +} + function formatChapterTitle(title: string): string { return title.replace(/\s+/g, " ").trim(); } @@ -270,6 +283,10 @@ function feedHtmlToMarkdown(html: string, source: object): string { ); } +function joinLines(lines: string[], prependToLines?: string): string { + return (prependToLines ? lines.map((line) => `${prependToLines}${line}`) : lines).join("\n"); +} + function formatTemplateChapters(chapters: Chapter[] | undefined, prependToLines?: string): string { const lines = normalizeChapters(chapters ?? []).map((chapter) => { const title = formatChapterTitle(chapter.title); @@ -278,11 +295,7 @@ function formatTemplateChapters(chapters: Chapter[] | undefined, prependToLines? return `- ${formatDuration(chapter.startTime)}${escapedTitle}`; }); - if (!prependToLines) { - return lines.join("\n"); - } - - return lines.map((line) => `${prependToLines}${line}`).join("\n"); + return joinLines(lines, prependToLines); } /** @@ -317,48 +330,23 @@ export function NoteTemplateEngine( const episodeUrl = resolveEpisodeUrl(episode); addTag("title", escapeMarkdownBodyText(episode.title)); - addTag("description", (prependToLines?: string) => { - // reduce multiple new lines - const sanitizeDescription = feedHtmlToMarkdown(episode.description, liveEpisode).replace( - /\n{3,}/g, - "\n\n", - ); - if (prependToLines) { - return sanitizeDescription - .split("\n") - .map((str) => `${prependToLines}${str}`) - .join("\n"); - } - - return sanitizeDescription; - }); - addTag("content", (prependToLines?: string) => { - if (prependToLines) { - return feedHtmlToMarkdown(episode.content, liveEpisode) - .split("\n") - .map((str) => `${prependToLines}${str}`) - .join("\n"); - } - - return feedHtmlToMarkdown(episode.content, liveEpisode); - }); + addTag("description", (prependToLines?: string) => + joinLines( + // reduce multiple new lines + feedHtmlToMarkdown(episode.description, liveEpisode) + .replace(/\n{3,}/g, "\n\n") + .split("\n"), + prependToLines, + ), + ); + addTag("content", (prependToLines?: string) => + joinLines(feedHtmlToMarkdown(episode.content, liveEpisode).split("\n"), prependToLines), + ); addTag("safetitle", replaceIllegalFileNameCharactersInString(episode.title)); addTag("stream", sanitizePortableResourceUrl(episode.streamUrl)); addTag("url", episodeUrl); - addTag("date", (format?: string) => - episode.episodeDate ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") : "", - ); - // The current date the note is created on, distinct from {{date}} (the episode - // publish date). Supports the same Moment.js format arg. See issue #75. - addTag("currentdate", (format?: string) => formatDate(new Date(), format ?? "YYYY-MM-DD")); - // Episode number from , else best-effort from the title. See #34. - addTag("episodenumber", (pad?: string) => - formatEpisodeNumber(resolveEpisodeNumber(episode), pad), - ); - // Episode duration from . See issue #88. - addTag("duration", (format?: string) => - episode.duration !== undefined ? formatDuration(episode.duration, format) : "", - ); + 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. addTag("chapters", (prependToLines?: string) => @@ -401,36 +389,23 @@ export function TimestampTemplateEngine( const { api, settings } = get(plugin); const timestampOffset = settings.timestamp.offset ?? 0; - addTag("time", (format?: string) => - api.getPodcastTimeFormatted(format ?? "HH:mm:ss", false, timestampOffset), - ); - addTag("linktime", (format?: string) => - api.getPodcastTimeFormatted(format ?? "HH:mm:ss", true, timestampOffset), - ); - addTag("segment", (format?: string) => { - if (!options.segment) { - return api.getPodcastTimeFormatted(format ?? "HH:mm:ss", false, timestampOffset); - } - - return api.getPodcastSegmentFormatted( - format ?? "HH:mm:ss", - options.segment.startTime, - options.segment.endTime, - false, - ); - }); - addTag("linksegment", (format?: string) => { - if (!options.segment) { - return api.getPodcastTimeFormatted(format ?? "HH:mm:ss", true, timestampOffset); - } - - return api.getPodcastSegmentFormatted( - format ?? "HH:mm:ss", - options.segment.startTime, - options.segment.endTime, - true, - ); - }); + // Without a segment, {{segment}} renders the current time as {{time}} does. + const timeTag = + (linkify: boolean, segment?: PodcastSegmentTimes): TagValue => + (format = "HH:mm:ss") => + segment + ? api.getPodcastSegmentFormatted( + format, + segment.startTime, + segment.endTime, + linkify, + ) + : api.getPodcastTimeFormatted(format, linkify, timestampOffset); + + addTag("time", timeTag(false)); + addTag("linktime", timeTag(true)); + addTag("segment", timeTag(false, options.segment)); + addTag("linksegment", timeTag(true, options.segment)); return replacer(template); } @@ -453,12 +428,10 @@ export function DownloadPathTemplateEngine(template: string, episode: Episode) { // FIRST '.ext' followed by '?'/'#'/end, which need not be the trailing one, so // a positional `.replace(ext, "")` could corrupt a folder name that happens to // contain the same string earlier in the path (#DL-04). + // getUrlExtension returns letters and digits only, so the extension needs no escaping. const templateExtension = getUrlExtension(template); const templateWithoutExtension = templateExtension - ? template.replace( - new RegExp(`\\.${templateExtension.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i"), - "", - ) + ? template.replace(new RegExp(`\\.${templateExtension}$`, "i"), "") : template; const [replacer, addTag] = useTemplateEngine(); @@ -480,20 +453,11 @@ export function TranscriptTemplateEngine( ] as const); addEpisodeFileNameTags(addTag, episode); - addTag("duration", (format?: string) => - episode.duration !== undefined ? formatDuration(episode.duration, format) : "", - ); + addTag("duration", durationTag(episode)); addTag("transcript", transcription); - addTag("description", (prependToLines?: string) => { - if (prependToLines) { - return feedHtmlToMarkdown(episode.description, liveEpisode) - .split("\n") - .map((str) => `${prependToLines}${str}`) - .join("\n"); - } - - return feedHtmlToMarkdown(episode.description, liveEpisode); - }); + addTag("description", (prependToLines?: string) => + joinLines(feedHtmlToMarkdown(episode.description, liveEpisode).split("\n"), prependToLines), + ); addTag("url", resolveEpisodeUrl(episode)); addTag("artwork", sanitizePortableResourceUrl(episode.artworkUrl ?? "")); @@ -522,21 +486,15 @@ export function FeedNoteTemplateEngine(template: string, liveFeed: PodcastFeed) addTag("artwork", sanitizePortableResourceUrl(feed.artworkUrl ?? "")); addTag("feedartwork", sanitizePortableResourceUrl(feed.artworkUrl ?? "")); addTag("author", escapeMarkdownBodyText(feed.author ?? "")); - addTag("description", (prependToLines?: string) => { - const sanitizeDescription = feedHtmlToMarkdown(feed.description ?? "", context).replace( - /\n{3,}/g, - "\n\n", - ); - if (prependToLines) { - return sanitizeDescription - .split("\n") - .map((str) => `${prependToLines}${str}`) - .join("\n"); - } - - return sanitizeDescription; - }); - addTag("date", (format?: string) => formatDate(new Date(), format ?? "YYYY-MM-DD")); + addTag("description", (prependToLines?: string) => + joinLines( + feedHtmlToMarkdown(feed.description ?? "", context) + .replace(/\n{3,}/g, "\n\n") + .split("\n"), + prependToLines, + ), + ); + addTag("date", (format = "YYYY-MM-DD") => formatDate(new Date(), format)); return replacer(template); } @@ -547,7 +505,7 @@ export function FeedFilePathTemplateEngine(template: string, feed: PodcastFeed) const nameTag = legalizedNameTag(withoutFeedAddresses(feed).title); addTag("title", nameTag); addTag("podcast", nameTag); - addTag("date", (format?: string) => formatDate(new Date(), format ?? "YYYY-MM-DD")); + addTag("date", (format = "YYYY-MM-DD") => formatDate(new Date(), format)); return replacer(template); } diff --git a/src/URIHandler.ts b/src/URIHandler.ts index 24ff344a..a0e0ecff 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -110,7 +110,7 @@ export default async function podNotesURIHandler( return; } // One guard ahead of both the segment and plain paths: a negative time is - // never valid (the segment path's own requestedTime < 0 check is now redundant). + // never valid. if (requestedTime < 0) { new Notice("Timestamp must be zero or greater"); return; @@ -125,16 +125,16 @@ export default async function podNotesURIHandler( if (requestedEndTime === null) return; const nameCandidates = candidateValues(episodeName); - const player = get(plugin).player; + const { app, feedUrls, feeds, player } = get(plugin); const playOptions = hasExplicitTime ? { time: requestedTime, endTime: requestedEndTime } : {}; const loaded = get(player.state).episode; - const { app, feedUrls } = get(plugin); // A link carrying a saved private feed's secret URL opens as the feed's placeholder // link, so the secret stays out of the episode. const url = Object.values(get(savedFeeds)).find((saved) => isSavedFeed(saved, linkUrl, feedUrls)) ?.url ?? linkUrl; - const localFile = candidateValues(url) + const urlCandidates = candidateValues(url); + const localFile = urlCandidates .map((path) => app.vault.getAbstractFileByPath(path)) .find((file) => file instanceof TFile); // A link to the loaded episode names its feed, or its file for a local file, so a @@ -155,7 +155,7 @@ export default async function podNotesURIHandler( !!loaded && !!loadedLinkTarget && (!isLocalFile(loaded) || localFile?.path === loadedLinkTarget) && - candidateValues(url).includes(loadedLinkTarget) && + urlCandidates.includes(loadedLinkTarget) && (!guid || loaded.guid === guid) && (nameCandidates.includes(loaded.title) || nameCandidates.some((name) => titleTokenSimilarity(name, loaded.title) === 1)); @@ -188,8 +188,8 @@ export default async function podNotesURIHandler( getUnambiguousMediaTypeFromPath(localFile.path) ?? (named ? getEpisodeMediaType(named) : "audio"), ); - } else if (!isPlaceholderLink && !candidateValues(url).some((u) => /^https?:\/\//i.test(u))) { - const { paths, name, files } = filesNamedLike(app.vault.getFiles(), candidateValues(url)); + } else if (!isPlaceholderLink && !urlCandidates.some((u) => /^https?:\/\//i.test(u))) { + const { paths, name, files } = filesNamedLike(app.vault.getFiles(), urlCandidates); if (files.length !== 1) { new Notice( files.length === 0 @@ -219,7 +219,6 @@ export default async function podNotesURIHandler( // SecretStorage. An attacker-crafted placeholder link can therefore only // make the user's own device fetch the user's own subscribed feed - and // the resolved URL still passes the network fetchability gate. - const feeds = get(plugin).feeds; const placeholderKey = parsePrivateFeedPlaceholder(url); if (placeholderKey !== null) { // Only a feed whose URL is on this device is fetched, and a newer link or play @@ -229,7 +228,7 @@ export default async function podNotesURIHandler( const saved = get(savedFeeds); const fetches = Object.prototype.hasOwnProperty.call(saved, placeholderKey) && - resolveFeedUrl(saved[placeholderKey], get(plugin).feedUrls) !== null; + resolveFeedUrl(saved[placeholderKey], feedUrls) !== null; const request = fetches ? ++latestRequest : latestRequest; if (fetches) play = player.deferPlay(); // Fetch even a fresh feed so the link finds an episode published since. @@ -302,11 +301,6 @@ function parseSegmentEndTime( return null; } - if (requestedTime < 0) { - new Notice("Segment start time must be zero or greater"); - return null; - } - const parsed = parseSeconds(rawEndTime); if (!Number.isFinite(parsed)) { new Notice("Segment end time must be a valid number"); diff --git a/src/constants.ts b/src/constants.ts index cdd1cdb8..f2925198 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -23,7 +23,7 @@ export const DEFAULT_EPISODE_LIST_LIMIT = 10; */ export const MAX_EPISODE_LIST_LIMIT = 75; -type PlaylistSettings = Pick< +export type PlaylistSettings = Pick< Playlist, "icon" | "name" | "shouldEpisodeRemoveAfterPlay" | "shouldRepeat" >; diff --git a/src/createFeedNote.ts b/src/createFeedNote.ts index 2c8864e9..b3493ad9 100644 --- a/src/createFeedNote.ts +++ b/src/createFeedNote.ts @@ -5,7 +5,7 @@ import { get } from "svelte/store"; import { plugin } from "./store"; import addExtension from "./utility/addExtension"; import { enforceMaxPathLength } from "./utility/enforceMaxPathLength"; -import { ensureFolderExists } from "./utility/ensureFolderExists"; +import { createFileIfNotExists } from "./utility/createFileIfNotExists"; import { openNote } from "./ui/openNote"; /** @@ -25,14 +25,8 @@ function getFeedNotePath(feed: PodcastFeed): string { } export function getFeedNote(feed: PodcastFeed): TFile | null { - const filePathDotMd = getFeedNotePath(feed); - const file = get(plugin).app.vault.getAbstractFileByPath(filePathDotMd); - - if (!file || !(file instanceof TFile)) { - return null; - } - - return file; + const file = get(plugin).app.vault.getAbstractFileByPath(getFeedNotePath(feed)); + return file instanceof TFile ? file : null; } export function openFeedNote(feed: PodcastFeed): void { @@ -71,7 +65,12 @@ export default async function createFeedNote(feed: PodcastFeed): Promise { const content = FeedNoteTemplateEngine(template, enrichedFeed); try { - const file = await createFileIfNotExists(filePathDotMd, content, feed); + // Re-check immediately before creating: enrichment above awaits the network, + // during which a second invocation (command + context menu) could have + // created the note already. + const existing = getFeedNote(feed); + if (existing) new Notice(`Note for "${feed.title}" already exists`); + const file = existing ?? (await createFileIfNotExists(filePathDotMd, content)).file; void openNote(get(plugin).app, file); } catch (error) { console.error(error); @@ -103,34 +102,3 @@ async function enrichFeed(feed: PodcastFeed): Promise { return feed; } } - -async function createFileIfNotExists( - path: string, - content: string, - feed: PodcastFeed, -): Promise { - // Re-check immediately before creating: enrichment above awaits the network, - // during which a second invocation (command + context menu) could have - // created the note already. - const existing = getFeedNote(feed); - if (existing) { - new Notice(`Note for "${feed.title}" already exists`); - return existing; - } - - const folderPath = path.split("/").slice(0, -1).join("/"); - await ensureFolderExists(folderPath); - - const { app } = get(plugin); - try { - return await app.vault.create(path, content); - } catch (error) { - // A racing create may have won between the check and here; treat an - // already-existing file as success rather than surfacing an error. - const raced = app.vault.getAbstractFileByPath(path); - if (raced instanceof TFile) { - return raced; - } - throw error; - } -} diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index 0b75cd1e..4a56e862 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -11,7 +11,7 @@ import { get } from "svelte/store"; import { plugin } from "./store"; import addExtension from "./utility/addExtension"; import { enforceMaxPathLength } from "./utility/enforceMaxPathLength"; -import { ensureFolderExists } from "./utility/ensureFolderExists"; +import { createFileIfNotExists } from "./utility/createFileIfNotExists"; import { fetchChapters } from "./utility/fetchChapters"; import { findUniqueTitleMatch } from "./utility/episodeTitleMatch"; import { findByGuid } from "./utility/findByGuid"; @@ -209,24 +209,3 @@ export function openPodcastNote(episode: Episode): void { void openNote(get(plugin).app, file); } - -async function createFileIfNotExists( - path: string, - content: string, -): Promise<{ file: TFile; created: boolean }> { - const folderPath = path.split("/").slice(0, -1).join("/"); - await ensureFolderExists(folderPath); - - const { app } = get(plugin); - try { - return { file: await app.vault.create(path, content), created: true }; - } catch (error) { - // A racing create may have won between the check and here; treat an - // already-existing file as success rather than surfacing an error. - const raced = app.vault.getAbstractFileByPath(path); - if (raced instanceof TFile) { - return { file: raced, created: false }; - } - throw error; - } -} diff --git a/src/download/mediaSignatures.ts b/src/download/mediaSignatures.ts index 6082e992..95a12a43 100644 --- a/src/download/mediaSignatures.ts +++ b/src/download/mediaSignatures.ts @@ -76,29 +76,13 @@ export function detectAudioFileExtension(data: ArrayBuffer): string | null { return isoBmffExtension; } - for (const { signature, mask, fileExtension } of audioSignatures) { - if (signature.length > arr.length) { - continue; - } - - let matches = true; - for (let i = 0; i < signature.length; i++) { - if (mask) { - if ((arr[i] & mask[i]) !== (signature[i] & mask[i])) { - matches = false; - break; - } - } else { - if (arr[i] !== signature[i]) { - matches = false; - break; - } - } - } - if (matches) { - return fileExtension; - } - } - - return null; + const match = audioSignatures.find( + ({ signature, mask }) => + signature.length <= arr.length && + signature.every((byte, i) => { + const bits = mask?.[i] ?? 0xff; + return (arr[i] & bits) === (byte & bits); + }), + ); + return match?.fileExtension ?? null; } diff --git a/src/downloadEpisode.ts b/src/downloadEpisode.ts index 00752341..eb396dbb 100644 --- a/src/downloadEpisode.ts +++ b/src/downloadEpisode.ts @@ -50,7 +50,6 @@ interface DownloadedFile { */ data: ArrayBuffer; contentType: string; - byteLength: number; } function downloadRequestError(error: unknown): Error { @@ -74,10 +73,9 @@ async function downloadFile(url: string): Promise { throw downloadRequestError(error); } - const data = response.arrayBuffer; const contentType = response.headers["content-type"] ?? response.headers["Content-Type"] ?? ""; - return { data, contentType, byteLength: data.byteLength }; + return { data: response.arrayBuffer, contentType }; } function parentFolderPath(filePath: string): string { @@ -291,8 +289,8 @@ function createNoticeDoc(title: string) { return { doc, - update: (updateFn: (bodyEl: HTMLDivElement) => void, empty = true) => { - if (empty) bodyEl.empty(); + update: (updateFn: (bodyEl: HTMLDivElement) => void) => { + bodyEl.empty(); updateFn(bodyEl); }, }; @@ -594,7 +592,7 @@ export async function getEpisodeAudioBuffer( if (episodeMediaType !== "audio") { throw new Error("Transcription supports audio episodes only."); } - const audioContainerHint = getAudioContainerHint(episode, episodeMediaType); + const audioContainerHint = getAudioContainerHint(episode); if (isLocalFile(episode)) { const localFilePath = resolveLocalEpisodeFilePath(episode); @@ -604,7 +602,7 @@ export async function getEpisodeAudioBuffer( ); } - return readVaultAudio(localFilePath, episodeMediaType); + return readVaultAudio(localFilePath); } // Reuse a previously downloaded file only when the registry entry is the SAME @@ -626,7 +624,7 @@ export async function getEpisodeAudioBuffer( const existingFile = app.vault.getAbstractFileByPath(registered.filePath); if (existingFile instanceof TFile) { - return readVaultAudio(registered.filePath, registeredMediaType); + return readVaultAudio(registered.filePath); } } @@ -649,11 +647,7 @@ export async function getEpisodeAudioBuffer( } } -function getAudioContainerHint( - episode: Episode, - episodeMediaType: EpisodeMediaType, -): EpisodeMediaType | undefined { - if (episodeMediaType !== "audio") return undefined; +function getAudioContainerHint(episode: Episode): EpisodeMediaType | undefined { if (episode.mediaType === "audio") return "audio"; return isAudioContainerExtension(getUrlExtension(episode.streamUrl)) ? "audio" : undefined; @@ -661,7 +655,6 @@ function getAudioContainerHint( async function readVaultAudio( filePath: string, - mediaTypeHint?: EpisodeMediaType, ): Promise<{ buffer: ArrayBuffer; extension: string; basename: string }> { const { app } = get(plugin); const file = app.vault.getAbstractFileByPath(filePath); @@ -670,10 +663,7 @@ async function readVaultAudio( } const fileExtension = file.extension || getUrlExtension(file.path || filePath) || ""; - const explicitAudioContainer = - mediaTypeHint === "audio" && - (fileExtension.toLowerCase() === "mp4" || fileExtension.toLowerCase() === "webm"); - const mediaType = explicitAudioContainer + const mediaType = isAudioContainerExtension(fileExtension) ? "audio" : (getMediaTypeFromExtension(fileExtension) ?? getMediaTypeFromPath(file.path || filePath)); if (mediaType !== "audio") { @@ -683,10 +673,7 @@ async function readVaultAudio( const buffer = await app.vault.readBinary(file); return { buffer, - extension: - mediaTypeHint === "audio" && fileExtension.toLowerCase() === "mp4" - ? "m4a" - : fileExtension, + extension: fileExtension.toLowerCase() === "mp4" ? "m4a" : fileExtension, basename: file.basename || getFileBasename(file.path || filePath), }; } diff --git a/src/getContextMenuHandler.ts b/src/getContextMenuHandler.ts index 4f01f2f4..3bf0df72 100644 --- a/src/getContextMenuHandler.ts +++ b/src/getContextMenuHandler.ts @@ -7,18 +7,15 @@ import { getMediaTypeFromPath, isAudioContainerExtension } from "./utility/media export default function getContextMenuHandler(app: App): EventRef { return app.workspace.on("file-menu", (menu: Menu, file: TAbstractFile) => { if (!(file instanceof TFile)) return; - const mediaType = getMediaTypeFromPath(file.path); - const isAmbiguousContainer = isAudioContainerExtension(file.extension); - if (!mediaType && !isAmbiguousContainer) return; - if (isAmbiguousContainer) { - addPlayLocalFileItem(menu, app, file, "audio"); - addPlayLocalFileItem(menu, app, file, "video"); + if (isAudioContainerExtension(file.extension)) { + addPlayLocalFileItem(menu, app, file, "audio", "Play as audio with PodNotes"); + addPlayLocalFileItem(menu, app, file, "video", "Play as video with PodNotes"); return; } - if (!mediaType) return; - addPlayLocalFileItem(menu, app, file, mediaType); + const mediaType = getMediaTypeFromPath(file.path); + if (mediaType) addPlayLocalFileItem(menu, app, file, mediaType, "Play with PodNotes"); }); } @@ -27,12 +24,8 @@ function addPlayLocalFileItem( app: App, file: TFile, mediaType: EpisodeMediaType, + title: string, ): void { - const isAmbiguousContainer = isAudioContainerExtension(file.extension); - const title = isAmbiguousContainer - ? `Play as ${mediaType} with PodNotes` - : "Play with PodNotes"; - menu.addItem((item) => item .setIcon("play") diff --git a/src/getVaultRenameHandler.ts b/src/getVaultRenameHandler.ts index 899e0d0c..84480def 100644 --- a/src/getVaultRenameHandler.ts +++ b/src/getVaultRenameHandler.ts @@ -2,6 +2,7 @@ import { TFile, type EventRef, type TAbstractFile } from "obsidian"; import { get } from "svelte/store"; import type PodNotes from "./main"; import { downloadedEpisodes, favorites, playlists, queue } from "./store"; +import { mapRecord } from "./persistence/codecUtils"; import { writePlayerSettings } from "./store/persistence"; import type DownloadedEpisode from "./types/DownloadedEpisode"; import type { Episode } from "./types/Episode"; @@ -97,14 +98,10 @@ function followRenames(plugin: PodNotes, renamed: Map): void { ? { ...playlist, episodes: playlist.episodes.map(moved) } : playlist; - if (inDownloads) downloadedEpisodes.set(mapValues(downloads, movedList)); + if (inDownloads) downloadedEpisodes.set(mapRecord(downloads, movedList)); if (inQueue) queue.set(movedPlaylist(queued)); if (inFavorites) favorites.set(movedPlaylist(favorited)); - if (inPlaylists) playlists.set(mapValues(lists, movedPlaylist)); + if (inPlaylists) playlists.set(mapRecord(lists, movedPlaylist)); if (isCurrent) writePlayerSettings(plugin, { currentEpisode: moved(current) }); for (const [from, file] of renamed) plugin.player.relocate(from, file.path, moved); } - -function mapValues(record: Record, map: (value: T) => T): Record { - return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, map(value)])); -} diff --git a/src/iTunesAPIConsumer.ts b/src/iTunesAPIConsumer.ts index c62b9c2e..aa738473 100644 --- a/src/iTunesAPIConsumer.ts +++ b/src/iTunesAPIConsumer.ts @@ -25,15 +25,15 @@ interface iTunesResponse { } export async function queryiTunesPodcasts(query: string): Promise { - const url = new URL("https://itunes.apple.com/search"); - url.searchParams.append("term", query); - url.searchParams.append("media", "podcast"); - url.searchParams.append("limit", "20"); - url.searchParams.append("kind", "podcast"); - + const found = await fetchShows("search", { + term: query, + media: "podcast", + limit: "20", + kind: "podcast", + }); // Search can list one feed under several shows; keep the best-ranked one. const shows = new Map(); - for (const show of await fetchShows(url)) { + for (const show of found) { if (!shows.has(show.feed.url)) shows.set(show.feed.url, show); } return [...shows.values()]; @@ -41,11 +41,8 @@ export async function queryiTunesPodcasts(query: string): Promise { - const url = new URL("https://itunes.apple.com/lookup"); - url.searchParams.append("id", id); - url.searchParams.append("entity", "podcast"); // An artist id returns the artist's shows, none of which is the linked show. - const shows = await fetchShows(url); + const shows = await fetchShows("lookup", { id, entity: "podcast" }); return shows.find(({ feed }) => feed.collectionId === id) ?? null; } @@ -75,9 +72,13 @@ export function parseApplePodcastsId(input: string): string | null { return id && /^\d+$/.test(id) ? id : null; } -async function fetchShows(url: URL): Promise { +async function fetchShows( + endpoint: "search" | "lookup", + params: Record, +): Promise { + const url = `https://itunes.apple.com/${endpoint}?${new URLSearchParams(params)}`; try { - const data = await fetchJsonWithTimeout(url.href, { + const data = await fetchJsonWithTimeout(url, { timeoutMs: ITUNES_REQUEST_TIMEOUT_MS, maxResponseBytes: MAX_ITUNES_RESPONSE_BYTES, acceptedStatuses: [200], diff --git a/src/main.ts b/src/main.ts index 181aa9a2..789c08bb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -123,7 +123,8 @@ export default class PodNotes extends Plugin implements IPodNotes { // Before registerView, where the views Obsidian restores mount and append their sheets. removePreviousStyles(this.app.workspace); this.isUnloaded = false; - this.podcastViewMountEnabled = !this.isMobileRuntime(); + // The mobile app keeps a restored view dormant until PodNotes is opened. + this.podcastViewMountEnabled = !Platform.isMobileApp && this.app.isMobile !== true; this.registerDomEvent(window, "pagehide", () => { this.closing = "window-unloading"; }); @@ -264,7 +265,10 @@ export default class PodNotes extends Plugin implements IPodNotes { // makes "Show PodNotes" and the ribbon icon reliably surface the view even // when it is already open but hidden in a collapsed/overflowing sidebar (#55). async activateView(): Promise { - this.enablePodcastViewMount(); + this.podcastViewMountEnabled = true; + for (const view of this.views) { + view.mountPodcastView(); + } const { workspace } = this.app; @@ -308,13 +312,6 @@ export default class PodNotes extends Plugin implements IPodNotes { return this.podcastViewMountEnabled; } - enablePodcastViewMount(): void { - this.podcastViewMountEnabled = true; - for (const view of this.views) { - view.mountPodcastView(); - } - } - unregisterPodcastView(view: MainView): void { this.views.delete(view); } @@ -330,10 +327,6 @@ export default class PodNotes extends Plugin implements IPodNotes { } } - private isMobileRuntime(): boolean { - return Platform.isMobileApp || this.app.isMobile === true; - } - // Public so the command registrations in src/commands.ts can reach it. getTranscriptionService(): TranscriptionService { if (!this.transcriptionService) { diff --git a/src/parser/feedDocumentParser.ts b/src/parser/feedDocumentParser.ts index ec1ea53c..2ac603a0 100644 --- a/src/parser/feedDocumentParser.ts +++ b/src/parser/feedDocumentParser.ts @@ -70,163 +70,158 @@ function isParserErrorDocument(document: Document): boolean { /** Pure XML parsing boundary with no network or Obsidian dependency. */ export default class FeedDocumentParser { parseEpisodes(xml: string): ParsedFeedEpisodes { - const document = this.parseXml(xml); + const document = parseXml(xml); return { - feed: this.extractFeed(document), - episodes: this.parsePage(document), + feed: extractFeed(document), + episodes: parsePage(document), }; } +} - private parseXml(xml: string): Document { - const document = new DOMParser().parseFromString(xml, "text/xml"); - if (isParserErrorDocument(document)) throw new Error("Invalid RSS feed"); - return document; - } +function parseXml(xml: string): Document { + const document = new DOMParser().parseFromString(xml, "text/xml"); + if (isParserErrorDocument(document)) throw new Error("Invalid RSS feed"); + return document; +} - private extractFeed(body: Document): ParsedFeedDocument { - const titleEl = body.querySelector("title"); - - // A feed must have a title. is intentionally not required: it is the - // human website, but many valid feeds omit it or only include an Atom self link. - if (!titleEl) throw new Error("Invalid RSS feed"); - - const channel = body.querySelector("channel") ?? body; - const imageEl = this.findChannelImageElement(channel); - const artworkUrl = - imageEl?.getAttribute("href") || imageEl?.querySelector("url")?.textContent || ""; - const siteUrl = this.findFeedLink(channel); - const description = this.findDirectChildText(channel, ["description", "itunes:summary"]); - const author = this.findDirectChildText(channel, [ - "itunes:author", - "author", - "managingEditor", - ]); - const guid = this.findDirectChildText(channel, ["podcast:guid", "id"]); +function extractFeed(body: Document): ParsedFeedDocument { + const titleEl = body.querySelector("title"); + + // A feed must have a title. is intentionally not required: it is the + // human website, but many valid feeds omit it or only include an Atom self link. + if (!titleEl) throw new Error("Invalid RSS feed"); + + const channel = body.querySelector("channel") ?? body; + const imageEl = findChannelImageElement(channel); + const artworkUrl = + imageEl?.getAttribute("href") || imageEl?.querySelector("url")?.textContent || ""; + const siteUrl = findFeedLink(channel); + const description = findDirectChildText(channel, ["description", "itunes:summary"]); + const author = findDirectChildText(channel, ["itunes:author", "author", "managingEditor"]); + const guid = findDirectChildText(channel, ["podcast:guid", "id"]); + + return { + title: titleEl.textContent || "", + ...(artworkUrl ? { artworkUrl } : {}), + ...(siteUrl ? { siteUrl } : {}), + ...(description ? { description } : {}), + ...(author ? { author } : {}), + ...(guid ? { guid } : {}), + }; +} - return { - title: titleEl.textContent || "", - ...(artworkUrl ? { artworkUrl } : {}), - ...(siteUrl ? { siteUrl } : {}), - ...(description ? { description } : {}), - ...(author ? { author } : {}), - ...(guid ? { guid } : {}), - }; - } +function findImageElement(document: Document | Element): Element | null { + const itunesImage = document.getElementsByTagName("itunes:image")[0]; + return itunesImage ?? document.querySelector("image"); +} - private findImageElement(document: Document | Element): Element | null { - const itunesImage = document.getElementsByTagName("itunes:image")[0]; - return itunesImage ?? document.querySelector("image"); - } +/** A nested item image must never become channel artwork. */ +function findChannelImageElement(scope: Element | Document): Element | null { + const directChildren = Array.from(scope.children); + const itunesImage = directChildren.find( + (element) => element.tagName.toLowerCase() === "itunes:image", + ); + if (itunesImage) return itunesImage; + return directChildren.find((element) => element.tagName.toLowerCase() === "image") ?? null; +} + +/** Resolve only the human website link, never an Atom self or hub endpoint. */ +function findFeedLink(scope: Document | Element): string { + const directLinks = Array.from(scope.children).filter((element) => { + const tag = element.tagName.toLowerCase(); + return tag === "link" || tag === "atom:link"; + }); - /** A nested item image must never become channel artwork. */ - private findChannelImageElement(scope: Element | Document): Element | null { - const directChildren = Array.from(scope.children); - const itunesImage = directChildren.find( - (element) => element.tagName.toLowerCase() === "itunes:image", - ); - if (itunesImage) return itunesImage; - return directChildren.find((element) => element.tagName.toLowerCase() === "image") ?? null; + for (const link of directLinks) { + const text = link.textContent?.trim(); + if (text && /^https?:\/\//i.test(text)) return text; } - /** Resolve only the human website link, never an Atom self or hub endpoint. */ - private findFeedLink(scope: Document | Element): string { - const directLinks = Array.from(scope.children).filter((element) => { - const tag = element.tagName.toLowerCase(); - return tag === "link" || tag === "atom:link"; - }); + for (const link of directLinks) { + const relation = link.getAttribute("rel"); + if (relation && relation !== "alternate") continue; + const href = link.getAttribute("href")?.trim(); + if (href && /^https?:\/\//i.test(href)) return href; + } - for (const link of directLinks) { - const text = link.textContent?.trim(); - if (text && /^https?:\/\//i.test(text)) return text; - } + return ""; +} - for (const link of directLinks) { - const relation = link.getAttribute("rel"); - if (relation && relation !== "alternate") continue; - const href = link.getAttribute("href")?.trim(); - if (href && /^https?:\/\//i.test(href)) return href; +/** Read direct-child metadata in explicit tag-priority order. */ +function findDirectChildText(scope: Document | Element, tagNames: string[]): string { + const children = Array.from(scope.children); + for (const tag of tagNames) { + const wanted = tag.toLowerCase(); + for (const child of children) { + if (child.tagName.toLowerCase() !== wanted) continue; + const text = child.textContent?.trim() ?? ""; + if (text) return text; } - - return ""; } + return ""; +} - /** Read direct-child metadata in explicit tag-priority order. */ - private findDirectChildText(scope: Document | Element, tagNames: string[]): string { - const children = Array.from(scope.children); - for (const tag of tagNames) { - const wanted = tag.toLowerCase(); - for (const child of children) { - if (child.tagName.toLowerCase() !== wanted) continue; - const text = child.textContent?.trim() ?? ""; - if (text) return text; - } +function findTranscripts(item: Element): TranscriptLink[] { + return Array.from(item.children).flatMap((child) => { + const url = child.getAttribute("url"); + if (child.tagName.toLowerCase() !== "podcast:transcript" || !url) return []; + const transcript: TranscriptLink = { url }; + for (const name of ["type", "language", "rel"] as const) { + const value = child.getAttribute(name); + if (value) transcript[name] = value; } - return ""; - } - - private findTranscripts(item: Element): TranscriptLink[] { - return Array.from(item.children).flatMap((child) => { - const url = child.getAttribute("url"); - if (child.tagName.toLowerCase() !== "podcast:transcript" || !url) return []; - const transcript: TranscriptLink = { url }; - for (const name of ["type", "language", "rel"] as const) { - const value = child.getAttribute(name); - if (value) transcript[name] = value; - } - return [transcript]; - }); - } - - private parsePage(page: Document): ParsedEpisodeDocument[] { - return Array.from(page.querySelectorAll("item")) - .map((item) => this.parseItem(item)) - .filter((episode): episode is ParsedEpisodeDocument => episode !== null); - } + return [transcript]; + }); +} - private parseItem(item: Element): ParsedEpisodeDocument | null { - const titleEl = item.querySelector("title"); - const streamUrlEl = item.querySelector("enclosure"); - if (!titleEl || !streamUrlEl) return null; - - const title = titleEl.textContent || ""; - const streamUrl = streamUrlEl.getAttribute("url") || ""; - const itemLink = item.querySelector("link")?.textContent || ""; - const description = item.querySelector("description")?.textContent || ""; - const content = item.querySelector("*|encoded")?.textContent || ""; - const episodeDate = decodeDate(item.querySelector("pubDate")?.textContent); - const artworkUrl = this.findImageElement(item)?.getAttribute("href") || ""; - const itunesTitle = item.getElementsByTagName("itunes:title")[0]?.textContent || ""; - const episodeNumber = parseEpisodeNumber( - item.getElementsByTagName("itunes:episode")[0]?.textContent, - title, - ); - const duration = parseDurationToSeconds( - item.getElementsByTagName("itunes:duration")[0]?.textContent, - ); - const chaptersUrl = - item.getElementsByTagName("podcast:chapters")[0]?.getAttribute("url") || ""; - const guid = this.findDirectChildText(item, ["guid"]); - const transcripts = this.findTranscripts(item); - const enclosureType = streamUrlEl.getAttribute("type"); +function parsePage(page: Document): ParsedEpisodeDocument[] { + return Array.from(page.querySelectorAll("item")) + .map((item) => parseItem(item)) + .filter((episode): episode is ParsedEpisodeDocument => episode !== null); +} - return { - title, - streamUrl, - ...(itemLink ? { itemLink } : {}), - description, - content, - ...(artworkUrl ? { artworkUrl } : {}), - ...(episodeDate ? { episodeDate } : {}), - ...(itunesTitle ? { itunesTitle } : {}), - ...(episodeNumber === undefined ? {} : { episodeNumber }), - ...(duration === undefined ? {} : { duration }), - ...(chaptersUrl ? { chaptersUrl } : {}), - ...(guid ? { guid } : {}), - ...(transcripts.length ? { transcripts } : {}), - mediaType: - getMediaTypeFromContentType(enclosureType) ?? - getUnambiguousMediaTypeFromPath(streamUrl) ?? - "audio", - }; - } +function parseItem(item: Element): ParsedEpisodeDocument | null { + const titleEl = item.querySelector("title"); + const streamUrlEl = item.querySelector("enclosure"); + if (!titleEl || !streamUrlEl) return null; + + const title = titleEl.textContent || ""; + const streamUrl = streamUrlEl.getAttribute("url") || ""; + const itemLink = item.querySelector("link")?.textContent || ""; + const description = item.querySelector("description")?.textContent || ""; + const content = item.querySelector("*|encoded")?.textContent || ""; + const episodeDate = decodeDate(item.querySelector("pubDate")?.textContent); + const artworkUrl = findImageElement(item)?.getAttribute("href") || ""; + const itunesTitle = item.getElementsByTagName("itunes:title")[0]?.textContent || ""; + const episodeNumber = parseEpisodeNumber( + item.getElementsByTagName("itunes:episode")[0]?.textContent, + title, + ); + const duration = parseDurationToSeconds( + item.getElementsByTagName("itunes:duration")[0]?.textContent, + ); + const chaptersUrl = item.getElementsByTagName("podcast:chapters")[0]?.getAttribute("url") || ""; + const guid = findDirectChildText(item, ["guid"]); + const transcripts = findTranscripts(item); + const enclosureType = streamUrlEl.getAttribute("type"); + + return { + title, + streamUrl, + ...(itemLink ? { itemLink } : {}), + description, + content, + ...(artworkUrl ? { artworkUrl } : {}), + ...(episodeDate ? { episodeDate } : {}), + ...(itunesTitle ? { itunesTitle } : {}), + ...(episodeNumber === undefined ? {} : { episodeNumber }), + ...(duration === undefined ? {} : { duration }), + ...(chaptersUrl ? { chaptersUrl } : {}), + ...(guid ? { guid } : {}), + ...(transcripts.length ? { transcripts } : {}), + mediaType: + getMediaTypeFromContentType(enclosureType) ?? + getUnambiguousMediaTypeFromPath(streamUrl) ?? + "audio", + }; } diff --git a/src/persistence/collectionCodecs.ts b/src/persistence/collectionCodecs.ts index 04ed8ade..f4645d3b 100644 --- a/src/persistence/collectionCodecs.ts +++ b/src/persistence/collectionCodecs.ts @@ -15,178 +15,153 @@ import { } from "./codecUtils"; import { decodeEpisode, decodePlaylist } from "./episodeCodec"; -export function decodeSavedFeeds( +/** A map whose entries `isEntry` accepts; any other entry is skipped with `skipped`. */ +function decodeRecord( value: unknown, warnings: Set, -): Record { + name: string, + isEntry: (candidate: unknown) => candidate is E, + skipped: string, + decode: (candidate: E, path: string, key: string) => T, +): Record { if (!isPlainObject(value)) { - if (value !== undefined) warn(warnings, "savedFeeds", "expected an object"); + if (value !== undefined) warn(warnings, name, "expected an object"); return ownRecord(); } - const feeds = ownRecord(); + const decoded = ownRecord(); for (const [key, candidate] of Object.entries(value)) { - if (!isPlainObject(candidate)) { - warn(warnings, `savedFeeds.${key}`, "expected an object; feed was skipped"); - continue; - } - - const feed = copySafeObject(candidate); - feed.title = readString(candidate, "title", key, warnings, `savedFeeds.${key}`); - feed.url = readString(candidate, "url", "", warnings, `savedFeeds.${key}`); - feed.artworkUrl = readString(candidate, "artworkUrl", "", warnings, `savedFeeds.${key}`); - setOptionalString(feed, candidate, "urlSecretId", warnings, `savedFeeds.${key}`); - setOptionalString(feed, candidate, "description", warnings, `savedFeeds.${key}`); - setOptionalString(feed, candidate, "link", warnings, `savedFeeds.${key}`); - setOptionalString(feed, candidate, "author", warnings, `savedFeeds.${key}`); - if (typeof candidate.collectionId === "number" && Number.isFinite(candidate.collectionId)) { - feed.collectionId = String(candidate.collectionId); - warn(warnings, `savedFeeds.${key}.collectionId`, "number was converted to text"); - } else { - setOptionalString(feed, candidate, "collectionId", warnings, `savedFeeds.${key}`); - } - feeds[key] = feed as unknown as PodcastFeed; + const path = `${name}.${key}`; + if (isEntry(candidate)) decoded[key] = decode(candidate, path, key); + else warn(warnings, path, skipped); } - return feeds; + return decoded; } -export function decodePodNotes(value: unknown, warnings: Set): Record { - if (!isPlainObject(value)) { - if (value !== undefined) warn(warnings, "podNotes", "expected an object"); - return ownRecord(); - } +export function decodeSavedFeeds( + value: unknown, + warnings: Set, +): Record { + return decodeRecord( + value, + warnings, + "savedFeeds", + isPlainObject, + "expected an object; feed was skipped", + (candidate, path, key) => { + const feed = copySafeObject(candidate); + feed.title = readString(candidate, "title", key, warnings, path); + feed.url = readString(candidate, "url", "", warnings, path); + feed.artworkUrl = readString(candidate, "artworkUrl", "", warnings, path); + setOptionalString(feed, candidate, "urlSecretId", warnings, path); + setOptionalString(feed, candidate, "description", warnings, path); + setOptionalString(feed, candidate, "link", warnings, path); + setOptionalString(feed, candidate, "author", warnings, path); + if ( + typeof candidate.collectionId === "number" && + Number.isFinite(candidate.collectionId) + ) { + feed.collectionId = String(candidate.collectionId); + warn(warnings, `${path}.collectionId`, "number was converted to text"); + } else { + setOptionalString(feed, candidate, "collectionId", warnings, path); + } + return feed as unknown as PodcastFeed; + }, + ); +} - const notes = ownRecord(); - for (const [key, candidate] of Object.entries(value)) { - if (!isPlainObject(candidate)) { - warn(warnings, `podNotes.${key}`, "expected an object; note mapping was skipped"); - continue; - } - notes[key] = { +export function decodePodNotes(value: unknown, warnings: Set): Record { + return decodeRecord( + value, + warnings, + "podNotes", + isPlainObject, + "expected an object; note mapping was skipped", + (candidate, path, key) => ({ ...copySafeObject(candidate), - episodeName: readString(candidate, "episodeName", key, warnings, `podNotes.${key}`), - filePath: readString(candidate, "filePath", "", warnings, `podNotes.${key}`), - podcastFeedKey: readString( - candidate, - "podcastFeedKey", - "", - warnings, - `podNotes.${key}`, - ), - }; - } - return notes; + episodeName: readString(candidate, "episodeName", key, warnings, path), + filePath: readString(candidate, "filePath", "", warnings, path), + podcastFeedKey: readString(candidate, "podcastFeedKey", "", warnings, path), + }), + ); } export function decodePlayedEpisodes( value: unknown, warnings: Set, ): Record { - if (!isPlainObject(value)) { - if (value !== undefined) warn(warnings, "playedEpisodes", "expected an object"); - return ownRecord(); - } - - const played = ownRecord(); - for (const [key, candidate] of Object.entries(value)) { - if (!isPlainObject(candidate)) { - warn(warnings, `playedEpisodes.${key}`, "expected an object; progress was skipped"); - continue; - } - played[key] = { + return decodeRecord( + value, + warnings, + "playedEpisodes", + isPlainObject, + "expected an object; progress was skipped", + (candidate, path, key) => ({ ...copySafeObject(candidate), - title: readString(candidate, "title", key, warnings, `playedEpisodes.${key}`), - podcastName: readString( - candidate, - "podcastName", - "", - warnings, - `playedEpisodes.${key}`, - ), - time: readNonNegativeNumber(candidate, "time", 0, warnings, `playedEpisodes.${key}`), - duration: readNonNegativeNumber( - candidate, - "duration", - 0, - warnings, - `playedEpisodes.${key}`, - ), - finished: readBoolean(candidate, "finished", false, warnings, `playedEpisodes.${key}`), - }; - } - return played; + title: readString(candidate, "title", key, warnings, path), + podcastName: readString(candidate, "podcastName", "", warnings, path), + time: readNonNegativeNumber(candidate, "time", 0, warnings, path), + duration: readNonNegativeNumber(candidate, "duration", 0, warnings, path), + finished: readBoolean(candidate, "finished", false, warnings, path), + }), + ); } export function decodePlaylists(value: unknown, warnings: Set): Record { - if (!isPlainObject(value)) { - if (value !== undefined) warn(warnings, "playlists", "expected an object"); - return ownRecord(); - } - - const playlists = ownRecord(); - for (const [key, candidate] of Object.entries(value)) { - if (!isPlainObject(candidate)) { - warn(warnings, `playlists.${key}`, "expected an object; playlist was skipped"); - continue; - } - playlists[key] = decodePlaylist( - candidate, - { - name: key, - icon: "list", - episodes: [], - shouldEpisodeRemoveAfterPlay: false, - shouldRepeat: false, - }, - warnings, - `playlists.${key}`, - ); - } - return playlists; + return decodeRecord( + value, + warnings, + "playlists", + isPlainObject, + "expected an object; playlist was skipped", + (candidate, path, key) => + decodePlaylist( + candidate, + { + name: key, + icon: "list", + shouldEpisodeRemoveAfterPlay: false, + shouldRepeat: false, + }, + warnings, + path, + ), + ); } export function decodeDownloadedEpisodes( value: unknown, warnings: Set, ): Record { - if (!isPlainObject(value)) { - if (value !== undefined) warn(warnings, "downloadedEpisodes", "expected an object"); - return ownRecord(); - } + return decodeRecord( + value, + warnings, + "downloadedEpisodes", + Array.isArray, + "expected an array; download list was skipped", + (candidate, listPath) => + candidate.flatMap((entry, index) => { + const path = `${listPath}[${index}]`; + const decoded = decodeEpisode(entry, warnings, path); + if (!decoded || !isPlainObject(entry) || typeof entry.filePath !== "string") { + if (decoded) + warn(warnings, `${path}.filePath`, "missing path; download was skipped"); + return []; + } - const downloads = ownRecord(); - for (const [podcastName, candidate] of Object.entries(value)) { - if (!Array.isArray(candidate)) { - warn( - warnings, - `downloadedEpisodes.${podcastName}`, - "expected an array; download list was skipped", - ); - continue; - } - - downloads[podcastName] = candidate.flatMap((entry, index) => { - const path = `downloadedEpisodes.${podcastName}[${index}]`; - const decoded = decodeEpisode(entry, warnings, path); - if (!decoded || !isPlainObject(entry) || typeof entry.filePath !== "string") { - if (decoded) - warn(warnings, `${path}.filePath`, "missing path; download was skipped"); - return []; - } + const size = + typeof entry.size === "number" && Number.isFinite(entry.size) && entry.size >= 0 + ? entry.size + : 0; + if (size !== entry.size) warn(warnings, `${path}.size`, "value was normalized"); - const size = - typeof entry.size === "number" && Number.isFinite(entry.size) && entry.size >= 0 - ? entry.size - : 0; - if (size !== entry.size) warn(warnings, `${path}.size`, "value was normalized"); - - const download: DownloadedEpisode = { ...decoded, filePath: entry.filePath, size }; - if ("downloadedByPodNotes" in entry && entry.downloadedByPodNotes !== true) { - delete download.downloadedByPodNotes; - warn(warnings, `${path}.downloadedByPodNotes`, "value was normalized"); - } - return [download]; - }); - } - return downloads; + const download: DownloadedEpisode = { ...decoded, filePath: entry.filePath, size }; + if ("downloadedByPodNotes" in entry && entry.downloadedByPodNotes !== true) { + delete download.downloadedByPodNotes; + warn(warnings, `${path}.downloadedByPodNotes`, "value was normalized"); + } + return [download]; + }), + ); } diff --git a/src/persistence/episodeCodec.ts b/src/persistence/episodeCodec.ts index cb3caee5..30156020 100644 --- a/src/persistence/episodeCodec.ts +++ b/src/persistence/episodeCodec.ts @@ -1,3 +1,4 @@ +import type { PlaylistSettings } from "src/constants"; import type { Episode } from "src/types/Episode"; import type { Playlist } from "src/types/Playlist"; import { @@ -24,8 +25,8 @@ export type PersistedPlaylist = Omit & /** Decode a standalone episode at a persistence, import, or cache boundary. */ export function decodeEpisode( value: unknown, - warnings: Set = new Set(), - path = "episode", + warnings: Set, + path: string, ): Episode | undefined { if (!isPlainObject(value)) { warn(warnings, path, "expected an object"); @@ -98,13 +99,13 @@ export function encodeEpisode(value: Episode | undefined): PersistedEpisode | un /** Decode a playlist and every episode snapshot it owns. */ export function decodePlaylist( value: unknown, - fallback: Playlist, - warnings: Set = new Set(), - path = "playlist", + fallback: PlaylistSettings, + warnings: Set, + path: string, ): Playlist { if (!isPlainObject(value)) { if (value !== undefined) warn(warnings, path, "expected an object"); - return clonePlaylist(fallback); + return { ...fallback, episodes: [], currentEpisode: undefined }; } const decoded = copySafeObject(value); @@ -132,11 +133,7 @@ export function decodePlaylist( }); } else { if (value.episodes !== undefined) warn(warnings, `${path}.episodes`, "expected an array"); - decoded.episodes = fallback.episodes - .map((episode, index) => - decodeEpisode(episode, warnings, `${path}.fallbackEpisodes[${index}]`), - ) - .filter((episode): episode is Episode => Boolean(episode)); + decoded.episodes = []; } const currentEpisode = @@ -166,11 +163,3 @@ export function encodePlaylist(value: Playlist): PersistedPlaylist { else delete encoded.currentEpisode; return encoded as PersistedPlaylist; } - -function clonePlaylist(value: Playlist): Playlist { - return { - ...value, - episodes: value.episodes.map((episode) => ({ ...episode })), - currentEpisode: value.currentEpisode ? { ...value.currentEpisode } : undefined, - }; -} diff --git a/src/persistence/podNotesData.test.ts b/src/persistence/podNotesData.test.ts index e0ca79bd..d76ccab9 100644 --- a/src/persistence/podNotesData.test.ts +++ b/src/persistence/podNotesData.test.ts @@ -348,6 +348,31 @@ describe("PodNotes data schema", () => { }); }); + it("keeps a valid media type or virtual flag an object inherits or hides from enumeration", () => { + const inherited = Object.assign( + Object.create({ mediaType: "video" }) as Episode, + episode("Inherited"), + ); + const hidden = episode("Hidden"); + Object.defineProperty(hidden, "mediaType", { value: "video", enumerable: false }); + const virtualList = Object.assign( + Object.create({ isVirtual: true }) as Playlist, + playlist("Virtual", episode("Listed")), + ); + const settings = structuredClone(DEFAULT_SETTINGS); + + const persisted = encodePodNotesData({ + ...settings, + currentEpisode: inherited, + queue: { ...settings.queue, episodes: [hidden] }, + playlists: { Virtual: virtualList }, + }); + + expect((persisted.currentEpisode as Record).mediaType).toBe("video"); + expect((persisted.queue as Playlist).episodes[0].mediaType).toBe("video"); + expect((persisted.playlists as Record).Virtual.isVirtual).toBe(true); + }); + it("migrates valid legacy values through the existing repair rules", () => { const decoded = decodePodNotesData({ defaultPlaybackRate: 99, diff --git a/src/persistence/settingsCodec.ts b/src/persistence/settingsCodec.ts index 70d96e82..107c198c 100644 --- a/src/persistence/settingsCodec.ts +++ b/src/persistence/settingsCodec.ts @@ -1,4 +1,9 @@ -import { DEFAULT_SETTINGS } from "src/constants"; +import { + DEFAULT_SETTINGS, + FAVORITES_SETTINGS, + LOCAL_FILES_SETTINGS, + QUEUE_SETTINGS, +} from "src/constants"; import { migrateDownloadPath, migrateFeedNoteSettings, @@ -38,14 +43,12 @@ export function decodeSettings( warnings: Set, sourceVersion: PodNotesDataSchemaVersion = 2, ): IPodNotesSettings { - const defaultPlaybackRate = normalizePlaybackRate(root.defaultPlaybackRate); - if ( - root.defaultPlaybackRate !== undefined && - (typeof root.defaultPlaybackRate !== "number" || - root.defaultPlaybackRate !== defaultPlaybackRate) - ) { - warn(warnings, "defaultPlaybackRate", "value was normalized"); - } + const defaultPlaybackRate = decodeNormalized( + root, + "defaultPlaybackRate", + normalizePlaybackRate, + warnings, + ); const defaultVolume = readClampedNumber( root, @@ -55,13 +58,12 @@ export function decodeSettings( 1, warnings, ); - const episodeListLimit = sanitizeEpisodeListLimit(root.episodeListLimit); - if ( - root.episodeListLimit !== undefined && - (typeof root.episodeListLimit !== "number" || root.episodeListLimit !== episodeListLimit) - ) { - warn(warnings, "episodeListLimit", "value was normalized"); - } + const episodeListLimit = decodeNormalized( + root, + "episodeListLimit", + sanitizeEpisodeListLimit, + warnings, + ); return { savedFeeds: decodeSavedFeeds(root.savedFeeds, warnings), @@ -76,16 +78,16 @@ export function decodeSettings( ), episodeListLimit, playedEpisodes: decodePlayedEpisodes(root.playedEpisodes, warnings), - skipBackwardLength: decodeSkipLength( - root.skipBackwardLength, - DEFAULT_SETTINGS.skipBackwardLength, + skipBackwardLength: decodeNormalized( + root, "skipBackwardLength", + (value) => migrateSkipLength(value, DEFAULT_SETTINGS.skipBackwardLength), warnings, ), - skipForwardLength: decodeSkipLength( - root.skipForwardLength, - DEFAULT_SETTINGS.skipForwardLength, + skipForwardLength: decodeNormalized( + root, "skipForwardLength", + (value) => migrateSkipLength(value, DEFAULT_SETTINGS.skipForwardLength), warnings, ), headsetPreviousTrack: readOption( @@ -103,20 +105,10 @@ export function decodeSettings( warnings, ), playlists: decodePlaylists(root.playlists, warnings), - queue: decodePlaylist(root.queue, DEFAULT_SETTINGS.queue, warnings, "queue"), + queue: decodePlaylist(root.queue, QUEUE_SETTINGS, warnings, "queue"), autoQueue: readBoolean(root, "autoQueue", DEFAULT_SETTINGS.autoQueue, warnings), - favorites: decodePlaylist( - root.favorites, - DEFAULT_SETTINGS.favorites, - warnings, - "favorites", - ), - localFiles: decodePlaylist( - root.localFiles, - DEFAULT_SETTINGS.localFiles, - warnings, - "localFiles", - ), + favorites: decodePlaylist(root.favorites, FAVORITES_SETTINGS, warnings, "favorites"), + localFiles: decodePlaylist(root.localFiles, LOCAL_FILES_SETTINGS, warnings, "localFiles"), currentEpisode: root.currentEpisode === undefined || root.currentEpisode === null ? undefined @@ -245,13 +237,15 @@ function decodeTranscript(value: unknown, warnings: Set): IPodNotesSetti }; } -function decodeSkipLength( - value: unknown, - fallback: number, - path: string, +/** The normalized number, with a warning when it differs from a stored value. */ +function decodeNormalized( + root: UnknownRecord, + key: string, + normalize: (value: unknown) => number, warnings: Set, ): number { - const decoded = migrateSkipLength(value, fallback); - if (value !== undefined && value !== decoded) warn(warnings, path, "value was normalized"); + const value = root[key]; + const decoded = normalize(value); + if (value !== undefined && value !== decoded) warn(warnings, key, "value was normalized"); return decoded; } diff --git a/src/player/PlayerController.test.ts b/src/player/PlayerController.test.ts index 4d0234f1..81465ec8 100644 --- a/src/player/PlayerController.test.ts +++ b/src/player/PlayerController.test.ts @@ -2143,7 +2143,7 @@ describe("video stage", () => { const { player, doc } = setup(); const stage = new FakeParent(doc); player.play(VIDEO); - const action = player.videoStage(stage as unknown as HTMLElement); + const detach = player.videoStage(stage as unknown as HTMLElement); expect(stage.children).toEqual([doc.media]); expect(doc.media.hidden).toBe(false); @@ -2155,7 +2155,7 @@ describe("video stage", () => { stage.migrateTo(doc); expect(stage.children).toEqual([doc.media]); - action.destroy(); + detach(); expect(doc.body.children).toEqual([doc.media]); expect(doc.media.hidden).toBe(true); }); @@ -2177,11 +2177,11 @@ describe("video stage", () => { test("an older stage's teardown leaves the video in the newer stage", () => { const { player, doc } = setup(); player.play(VIDEO); - const older = player.videoStage(new FakeParent(doc) as unknown as HTMLElement); + const detachOlder = player.videoStage(new FakeParent(doc) as unknown as HTMLElement); const newer = new FakeParent(doc); player.videoStage(newer as unknown as HTMLElement); - older.destroy(); + detachOlder(); expect(newer.children).toEqual([doc.media]); }); diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 52a7e3d9..a78326b0 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -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, isSameStoredEpisode } from "src/utility/episodeKey"; +import { getEpisodeKey, isSameEpisode, isSameStoredEpisode } from "src/utility/episodeKey"; import { getPlayedEpisode } from "src/utility/episodeStatus"; import { getEpisodeMediaType } from "src/utility/mediaType"; import { normalizePlaybackRate } from "src/utility/playbackRate"; @@ -83,6 +83,23 @@ const isTime = (value: number) => Number.isFinite(value) && value >= 0; const isOptionalTime = (value: number | undefined) => value === undefined || isTime(value); const finiteOr0 = (value: number) => (Number.isFinite(value) ? value : 0); +function savedProgress(episode: Episode): PlayedEpisode | undefined { + return getPlayedEpisode(get(playedEpisodes), episode); +} + +function resumeTime(episode: Episode): number { + const saved = savedProgress(episode); + if (!saved || saved.finished || (saved.duration > 0 && saved.time >= saved.duration)) { + return 0; + } + return saved.time; +} + +/** The length shown until the metadata loads. */ +function startDuration(episode: Episode): number { + return savedProgress(episode)?.duration || episode.duration || 0; +} + /** * Owns the one media element, in the main window, from plugin load to unload. * Views render `state` and call the intents. @@ -116,7 +133,7 @@ export class PlayerController { currentTime: 0, duration: 0, playbackRate: normalizePlaybackRate(defaultPlaybackRate), - volume: Number.isFinite(defaultVolume) ? Math.min(1, Math.max(0, defaultVolume)) : 1, + volume: defaultVolume, }; this.store = writable(this.snapshot); this.state = { subscribe: this.store.subscribe }; @@ -128,18 +145,18 @@ export class PlayerController { this.noteStoppedMove(); const { status, episode: shown, currentTime } = this.snapshot; if (status === "paused" && shown && !this.moved) { - const time = this.resumeTime(shown); + const time = resumeTime(shown); if (time !== currentTime) this.moveTo(time); return; } if (!episode || (status !== "empty" && status !== "restored")) return; - this.moved &&= !!shown && getEpisodeKey(shown) === getEpisodeKey(episode); + this.moved &&= !!shown && isSameEpisode(shown, episode); this.setState({ status: "restored", episode, mediaType: getEpisodeMediaType(episode), - currentTime: this.moved ? currentTime : this.resumeTime(episode), - duration: this.startDuration(episode), + currentTime: this.moved ? currentTime : resumeTime(episode), + duration: startDuration(episode), }); } @@ -180,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 && getEpisodeKey(outgoing) === getEpisodeKey(episode); + const sameEpisode = !!outgoing && isSameEpisode(outgoing, episode); this.select(episode); const session = this.session; @@ -201,7 +218,7 @@ export class PlayerController { } session.segmentEnd = endTime ?? null; session.preview = endTime !== undefined; - const at = time ?? (status === "ended" ? this.resumeTime(episode) : undefined); + const at = time ?? (status === "ended" ? resumeTime(episode) : undefined); if (time !== undefined) this.moved = true; if (at === undefined || this.moveTo(at)) this.start(); return true; @@ -216,7 +233,7 @@ export class PlayerController { ) { queue.prepend(outgoing); } - const resumeAt = sameEpisode ? this.restartTime(episode) : this.resumeTime(episode); + const resumeAt = sameEpisode ? this.restartTime(episode) : resumeTime(episode); this.load(episode, source, time ?? resumeAt, endTime); this.moved = time !== undefined || (sameEpisode && this.moved); return true; @@ -245,12 +262,12 @@ export class PlayerController { const { status, episode } = this.snapshot; if (!episode) return; this.requests++; - if (status === "playing" || status === "loading") return; + if (isPlaying(status)) return; this.endPreview(); if (!this.session) { const source = selectSource(episode, get(downloadedEpisodes), this.plugin.app.vault); this.load(episode, source, this.restartTime(episode)); - } else if (status !== "ended" || this.moveTo(this.resumeTime(episode))) { + } else if (status !== "ended" || this.moveTo(resumeTime(episode))) { this.start(); } } @@ -259,14 +276,14 @@ export class PlayerController { if (this.disposed) return; this.requests++; const { status } = this.snapshot; - if (status !== "playing" && status !== "loading") return; + if (!isPlaying(status)) return; this.el.pause(); this.settlePaused(); } toggle(): void { const { status } = this.snapshot; - if (status === "playing" || status === "loading") this.pause(); + if (isPlaying(status)) this.pause(); else this.resume(); } @@ -316,20 +333,18 @@ export class PlayerController { } /** - * A Svelte action for the view's video stage. The video plays inside the stage + * A Svelte attachment for the view's video stage. The video plays inside the stage * while the stage is in the main window, and hidden in the main window otherwise. */ - readonly videoStage = (stage: HTMLElement): { destroy(): void } => { + readonly videoStage = (stage: HTMLElement): (() => void) => { this.stage = stage; this.placeElement(); const stopFollowingWindow = stage.onWindowMigrated?.(() => this.placeElement()); - return { - destroy: () => { - stopFollowingWindow?.(); - if (this.stage !== stage) return; - this.stage = null; - this.placeElement(); - }, + return () => { + stopFollowingWindow?.(); + if (this.stage !== stage) return; + this.stage = null; + this.placeElement(); }; }; @@ -351,11 +366,11 @@ export class PlayerController { private load(episode: Episode, source: PlaybackSource, time: number, endTime?: number): void { this.session = null; - const duration = this.startDuration(episode); + const duration = startDuration(episode); 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 && getEpisodeKey(shown) === getEpisodeKey(episode) + shown && isSameEpisode(shown, episode) ? this.snapshot.playbackRate : normalizePlaybackRate(this.plugin.settings.defaultPlaybackRate); if (!source.src) { @@ -444,7 +459,7 @@ export class PlayerController { /** play() unpauses the element at once, so the next intent can act on the new status. */ private start(): void { const { status } = this.snapshot; - if (status === "playing" || status === "loading") return; + if (isPlaying(status)) return; this.setState({ status: this.holdsMedia() ? "playing" : "loading" }); this.playElement(); } @@ -457,7 +472,7 @@ export class PlayerController { // A play that has since started, such as the next load's, keeps playing. const { status } = this.snapshot; if (this.disposed || !this.el.paused) return; - if (status === "loading" || status === "playing") this.settlePaused(); + if (isPlaying(status)) this.settlePaused(); }); } @@ -537,14 +552,14 @@ export class PlayerController { const { status } = this.snapshot; if (!this.holdsMedia() || this.el.paused) return; // A play the player did not start, such as from a media key, is the user's. - if (status !== "playing" && status !== "loading") this.requests++; + if (!isPlaying(status)) this.requests++; this.setState({ status: "playing" }); } /** A reset pauses without this event (see onEmptied), so a pause while loading is a real one. */ private onPause(): void { const { status } = this.snapshot; - if ((status !== "playing" && status !== "loading") || !this.el.paused) return; + if (!isPlaying(status) || !this.el.paused) return; // A pause the player did not make and no natural end made, such as from a media key. if (!this.el.ended) this.requests++; this.settlePaused(); @@ -600,7 +615,7 @@ export class PlayerController { this.load( next, selectSource(next, get(downloadedEpisodes), this.plugin.app.vault), - this.resumeTime(next), + resumeTime(next), ); } @@ -635,10 +650,6 @@ export class PlayerController { this.moved = false; } - private savedProgress(episode: Episode): PlayedEpisode | undefined { - return getPlayedEpisode(get(playedEpisodes), episode); - } - /** * Where the shown episode starts on a new load: where it is while a session holds it * or after the user moved it, else at its saved progress, which a sync may have changed. @@ -646,20 +657,7 @@ export class PlayerController { private restartTime(episode: Episode): number { const { status, currentTime } = this.snapshot; const live = status !== "ended" && (!!this.session || this.moved); - return live ? currentTime : this.resumeTime(episode); - } - - private resumeTime(episode: Episode): number { - const saved = this.savedProgress(episode); - if (!saved || saved.finished || (saved.duration > 0 && saved.time >= saved.duration)) { - return 0; - } - return saved.time; - } - - /** The length shown until the metadata loads. */ - private startDuration(episode: Episode): number { - return this.savedProgress(episode)?.duration || episode.duration || 0; + return live ? currentTime : resumeTime(episode); } private setState(patch: Partial): void { diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index 521433d0..6ab634a9 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -59,6 +59,14 @@ function chunkErrorPlaceholder(index: number): string { } const CHUNK_ERROR_PLACEHOLDER_PATTERN = /\[Error transcribing chunk \d+\]/g; +const MAX_RETRIES = 3; +const MAX_CONCURRENT_TRANSCRIPTIONS = 2; +const MAX_CONCURRENT_CHUNK_TRANSCRIPTIONS = 3; + +function queueKey(episode: Episode): string { + return `${episode.podcastName}:${episode.title}`; +} + interface TranscriptionJob { readonly episode: Episode; readonly source: @@ -70,10 +78,8 @@ export class TranscriptionService { private plugin: PodNotes; private disposed = false; private readonly lifetimeAbortController = new AbortController(); + private readonly signal = this.lifetimeAbortController.signal; private readonly activeNotices = new Set>(); - private MAX_RETRIES = 3; - private readonly MAX_CONCURRENT_TRANSCRIPTIONS = 2; - private readonly MAX_CONCURRENT_CHUNK_TRANSCRIPTIONS = 3; private pendingJobs: TranscriptionJob[] = []; private activeTranscriptions = new Set(); @@ -96,7 +102,7 @@ export class TranscriptionService { findPublishedTranscript(this.plugin.feeds, episode), ); } catch (error) { - if (this.disposed || this.lifetimeAbortController.signal.aborted) return; + if (this.disposed || this.signal.aborted) return; throw error; } @@ -157,11 +163,10 @@ export class TranscriptionService { return; } - const episodeKey = this.getEpisodeKey(job.episode); + const episodeKey = queueKey(job.episode); const isAlreadyQueued = - this.pendingJobs.some( - (pending) => this.getEpisodeKey(pending.episode) === episodeKey, - ) || this.activeTranscriptions.has(episodeKey); + this.pendingJobs.some((pending) => queueKey(pending.episode) === episodeKey) || + this.activeTranscriptions.has(episodeKey); if (isAlreadyQueued) { new Notice("This episode is already queued or transcribing."); @@ -179,7 +184,7 @@ export class TranscriptionService { return; } while ( - this.activeTranscriptions.size < this.MAX_CONCURRENT_TRANSCRIPTIONS && + this.activeTranscriptions.size < MAX_CONCURRENT_TRANSCRIPTIONS && this.pendingJobs.length > 0 ) { const job = this.pendingJobs.shift(); @@ -187,7 +192,7 @@ export class TranscriptionService { return; } - const episodeKey = this.getEpisodeKey(job.episode); + const episodeKey = queueKey(job.episode); this.activeTranscriptions.add(episodeKey); void this.transcribeEpisode(job).finally(() => { @@ -197,10 +202,6 @@ export class TranscriptionService { } } - private getEpisodeKey(episode: Episode): string { - return `${episode.podcastName}:${episode.title}`; - } - private async transcribeEpisode({ episode, source }: TranscriptionJob): Promise { this.assertActive(); const notice = TimerNotice(`Transcription: ${episode.title}`, "Preparing to transcribe..."); @@ -230,7 +231,7 @@ export class TranscriptionService { notice.stop(); updateNotice(completion); } catch (error) { - if (this.disposed || this.lifetimeAbortController.signal.aborted) return; + if (this.disposed || this.signal.aborted) return; console.error("Transcription error:", error); notice.stop(); updateNotice( @@ -256,12 +257,7 @@ export class TranscriptionService { ): Promise<{ body: string; completion: string }> { updateNotice("Fetching the published transcript..."); const parsed = await this.waitForLifecycle( - fetchPublishedTranscript( - this.plugin.feeds, - episode, - transcript, - this.lifetimeAbortController.signal, - ), + fetchPublishedTranscript(this.plugin.feeds, episode, transcript, this.signal), ); const { timestamp, transcript: settings } = this.plugin.settings; const body = renderTranscriptBody(parsed, { @@ -379,7 +375,7 @@ export class TranscriptionService { audio, apiKey, onProgress: updateNotice, - signal: this.lifetimeAbortController.signal, + signal: this.signal, }), ); this.assertActive(); @@ -395,9 +391,9 @@ export class TranscriptionService { const segments = await diarizeWithOpenAI({ apiKey: this.getApiKey(), chunkFiles, - maxRetries: this.MAX_RETRIES, + maxRetries: MAX_RETRIES, onProgress: updateNotice, - signal: this.lifetimeAbortController.signal, + signal: this.signal, }); this.assertActive(); return segments; @@ -430,13 +426,13 @@ export class TranscriptionService { const file = files[index]; let retries = 0; - while (retries < this.MAX_RETRIES) { + while (retries < MAX_RETRIES) { this.assertActive(); try { const result = await createTranscription( apiKey, { model: "whisper-1", file }, - this.lifetimeAbortController.signal, + this.signal, ); this.assertActive(); const text = (result as { text?: unknown } | null)?.text; @@ -450,9 +446,9 @@ export class TranscriptionService { } catch (error) { this.assertActive(); retries++; - if (retries >= this.MAX_RETRIES) { + if (retries >= MAX_RETRIES) { console.error( - `Failed to transcribe chunk ${index} after ${this.MAX_RETRIES} attempts:`, + `Failed to transcribe chunk ${index} after ${MAX_RETRIES} attempts:`, error, ); transcriptions[index] = chunkErrorPlaceholder(index); @@ -467,7 +463,7 @@ export class TranscriptionService { } }; - const workerCount = Math.min(this.MAX_CONCURRENT_CHUNK_TRANSCRIPTIONS, files.length); + const workerCount = Math.min(MAX_CONCURRENT_CHUNK_TRANSCRIPTIONS, files.length); const workers = Array.from({ length: workerCount }, () => worker()); await Promise.all(workers); @@ -478,7 +474,7 @@ export class TranscriptionService { private waitForRetry(delayMs: number): Promise { this.assertActive(); - const signal = this.lifetimeAbortController.signal; + const { signal } = this; return new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { @@ -498,42 +494,17 @@ export class TranscriptionService { private waitForLifecycle(operation: Promise): Promise { this.assertActive(); - const signal = this.lifetimeAbortController.signal; + const { signal } = this; return new Promise((resolve, reject) => { - let settled = false; - const cleanup = () => signal.removeEventListener("abort", onAbort); - const onAbort = () => { - if (settled) return; - settled = true; - cleanup(); + const onAbort = () => reject(toError(this.getAbortReason(), "PodNotes transcription was aborted.")); - }; - signal.addEventListener("abort", onAbort, { once: true }); - void operation.then( - (value) => { - if (settled) return; - if (signal.aborted) { - onAbort(); - return; - } - settled = true; - cleanup(); - resolve(value); - }, - (error) => { - if (settled) return; - if (signal.aborted) { - onAbort(); - return; - } - settled = true; - cleanup(); - reject(toError(error, "PodNotes transcription failed.")); - }, - ); - if (signal.aborted) onAbort(); + void operation + .then(resolve, (error: unknown) => + reject(toError(error, "PodNotes transcription failed.")), + ) + .finally(() => signal.removeEventListener("abort", onAbort)); }); } @@ -595,14 +566,14 @@ export class TranscriptionService { } private assertActive(): void { - if (this.disposed || this.lifetimeAbortController.signal.aborted) { + if (this.disposed || this.signal.aborted) { throw this.getAbortReason(); } } private getAbortReason(): unknown { return ( - this.lifetimeAbortController.signal.reason ?? + this.signal.reason ?? new DOMException("PodNotes was unloaded during transcription.", "AbortError") ); } diff --git a/src/store/index.ts b/src/store/index.ts index 997533b3..79e4a5e9 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -5,7 +5,7 @@ import type { PlayedEpisode } from "src/types/PlayedEpisode"; import type { Playlist } from "src/types/Playlist"; import type DownloadedEpisode from "src/types/DownloadedEpisode"; import type { LocalEpisode } from "src/types/LocalEpisode"; -import { LOCAL_FILES_SETTINGS } from "src/constants"; +import { FAVORITES_SETTINGS, LOCAL_FILES_SETTINGS, QUEUE_SETTINGS } from "src/constants"; import { getEpisodeKey, isSameEpisode } from "src/utility/episodeKey"; import { getPlayedEpisode, getPlayedEpisodeAliasKeys } from "src/utility/episodeStatus"; import { recordStore } from "./recordStore"; @@ -186,13 +186,7 @@ export function dedupeEpisodesByKey(episodes: Episode[] = []): Episode[] { } export const queue = (() => { - const store = writable({ - icon: "list-ordered", - name: "Queue", - episodes: [], - shouldEpisodeRemoveAfterPlay: true, - shouldRepeat: false, - }); + const store = writable({ ...QUEUE_SETTINGS, episodes: [] }); const { subscribe, update, set: setStore } = store; // The queue identifies episodes by getEpisodeKey everywhere (remove, @@ -249,13 +243,7 @@ export const queue = (() => { }; })(); -export const favorites = writable({ - icon: "lucide-star", - name: "Favorites", - episodes: [], - shouldEpisodeRemoveAfterPlay: false, - shouldRepeat: false, -}); +export const favorites = writable({ ...FAVORITES_SETTINGS, episodes: [] }); function getEpisodeFilePath(episode: Episode): string | undefined { return (episode as Partial).filePath; @@ -280,13 +268,7 @@ function sameEpisodeProjection(a: Episode[], b: Episode[]): boolean { } export const localFiles = (() => { - const store = writable({ - icon: "folder", - name: "Local Files", - episodes: [], - shouldEpisodeRemoveAfterPlay: false, - shouldRepeat: false, - }); + const store = writable({ ...LOCAL_FILES_SETTINGS, episodes: [] }); const { subscribe, update, set } = store; diff --git a/src/store/persistence.ts b/src/store/persistence.ts index 3f74c96a..54dd573c 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -29,20 +29,14 @@ interface PersistenceBinding { /** * Captures a store and its settings-write in a type-erased binding. `apply` (and * the optional `shouldPersist` guard) are written against the store's real value - * type `T`; the casts here are safe because the same store feeds both. + * type `T`; the cast here is safe because the same store feeds both. */ function bind( store: Readable, apply: (settings: IPodNotesSettings, value: T) => void, shouldPersist?: (settings: IPodNotesSettings, value: T) => boolean, ): PersistenceBinding { - return { - subscribe: (handler) => store.subscribe((value) => handler(value)), - apply: (settings, value) => apply(settings, value as T), - shouldPersist: shouldPersist - ? (settings, value) => shouldPersist(settings, value as T) - : undefined, - }; + return { subscribe: store.subscribe, apply, shouldPersist } as PersistenceBinding; } /** diff --git a/src/ui/PodcastView/MiniPlayer.svelte b/src/ui/PodcastView/MiniPlayer.svelte index cf30d948..cbc9fe0f 100644 --- a/src/ui/PodcastView/MiniPlayer.svelte +++ b/src/ui/PodcastView/MiniPlayer.svelte @@ -23,9 +23,9 @@ const capture = () => void captureTimestamp(); -{#snippet progress(kind: "pn-mini-prog" | "pn-mini-track")} +{#snippet progress(kind: "prog" | "track")}