Compare commits
12 Commits
v0.5.2
...
77531ce41d
| Author | SHA1 | Date | |
|---|---|---|---|
| 77531ce41d | |||
| 26729fa5e6 | |||
| 4127fd1181 | |||
| 6c99b96b12 | |||
| acbaf2ed1c | |||
| e7ed89056e | |||
| b5432e3e5f | |||
| 13664c3cec | |||
| 0b4a551744 | |||
| ed75c2fff7 | |||
| bd7d988741 | |||
| deac6081ca |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,3 +35,5 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|||||||
.harness/
|
.harness/
|
||||||
.ralpi
|
.ralpi
|
||||||
notes.md
|
notes.md
|
||||||
|
# pygienium run-state and check artifacts
|
||||||
|
.pygienium/
|
||||||
|
|||||||
@@ -74,6 +74,82 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extract the `<item>` blocks from an RSS document. Matches items directly
|
||||||
|
* on the full XML string — scoping to <channel> first is a redundant 5MB
|
||||||
|
* regex pass that doubles parse cost with no practical benefit (well-formed
|
||||||
|
* RSS has no items outside <channel>). */
|
||||||
|
export const getRSSItems = (xml: string): string[] => {
|
||||||
|
return xml.match(/<item[\s\S]*?<\/item>/gi) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Channel-level artwork: `<itunes:image href>` (podcasts) or RSS 2.0
|
||||||
|
* `<image><url>`. Works on the full XML — channel-level tags precede
|
||||||
|
* <item> blocks in RSS, so the first match is the channel image. */
|
||||||
|
export const parseChannelCoverUrl = (xml: string): string | undefined => {
|
||||||
|
const itunesHref = getAttr(xml, "itunes:image", "href")
|
||||||
|
if (itunesHref) return itunesHref
|
||||||
|
const url = getTagValue(xml, "image").match(/<url>([\s\S]*?)<\/url>/i)?.[1]
|
||||||
|
return url?.trim() || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a single `<item>` into an Episode. Exported so the feed store can
|
||||||
|
* parse large feeds in bounded chunks (yielding to the event loop between
|
||||||
|
* chunks) instead of one synchronous block. */
|
||||||
|
export const parseRSSItem = (item: string, feedUrl: string, index: number): Episode => {
|
||||||
|
const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}`
|
||||||
|
const epDescription = cleanField(getTagValue(item, "description"))
|
||||||
|
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
|
||||||
|
|
||||||
|
// Audio URL + file size + MIME type from <enclosure>
|
||||||
|
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
|
||||||
|
const audioUrl = enclosure?.[1] ?? ""
|
||||||
|
const fileSizeStr = getAttr(item, "enclosure", "length")
|
||||||
|
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
|
||||||
|
const mimeType = getAttr(item, "enclosure", "type") || undefined
|
||||||
|
|
||||||
|
// Duration from <itunes:duration>
|
||||||
|
const durationRaw = getTagValue(item, "itunes:duration")
|
||||||
|
const duration = parseDuration(durationRaw)
|
||||||
|
|
||||||
|
// Episode & season numbers
|
||||||
|
const episodeNumRaw = getTagValue(item, "itunes:episode")
|
||||||
|
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
|
||||||
|
const seasonNumRaw = getTagValue(item, "itunes:season")
|
||||||
|
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
|
||||||
|
|
||||||
|
// Episode type & explicit
|
||||||
|
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
|
||||||
|
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
|
||||||
|
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
|
||||||
|
|
||||||
|
// Episode image (itunes:image has href attribute)
|
||||||
|
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
|
||||||
|
|
||||||
|
const ep: Episode = {
|
||||||
|
id: `${feedUrl}#${index}`,
|
||||||
|
podcastId: feedUrl,
|
||||||
|
title: epTitle,
|
||||||
|
description: epDescription,
|
||||||
|
audioUrl,
|
||||||
|
duration,
|
||||||
|
pubDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only set optional fields if present
|
||||||
|
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
|
||||||
|
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
|
||||||
|
if (episodeType) ep.episodeType = episodeType
|
||||||
|
if (explicit !== undefined) ep.explicit = explicit
|
||||||
|
if (imageUrl) ep.imageUrl = imageUrl
|
||||||
|
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
|
||||||
|
if (mimeType) ep.mimeType = mimeType
|
||||||
|
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a full RSS document (channel metadata + all episodes). The sync
|
||||||
|
* whole-feed variant — callers that parse potentially huge feeds on a UI
|
||||||
|
* thread should prefer the store's chunked incremental parse instead. */
|
||||||
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
|
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
|
||||||
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
|
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
|
||||||
const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast"
|
const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast"
|
||||||
@@ -81,58 +157,8 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
|
|||||||
const author = decodeEntities(getTagValue(channel, "itunes:author"))
|
const author = decodeEntities(getTagValue(channel, "itunes:author"))
|
||||||
const lastUpdated = new Date()
|
const lastUpdated = new Date()
|
||||||
|
|
||||||
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
|
const items = getRSSItems(xml)
|
||||||
const episodes = items.map((item, index) => {
|
const episodes = items.map((item, index) => parseRSSItem(item, feedUrl, index))
|
||||||
const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}`
|
|
||||||
const epDescription = cleanField(getTagValue(item, "description"))
|
|
||||||
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
|
|
||||||
|
|
||||||
// Audio URL + file size + MIME type from <enclosure>
|
|
||||||
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
|
|
||||||
const audioUrl = enclosure?.[1] ?? ""
|
|
||||||
const fileSizeStr = getAttr(item, "enclosure", "length")
|
|
||||||
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
|
|
||||||
const mimeType = getAttr(item, "enclosure", "type") || undefined
|
|
||||||
|
|
||||||
// Duration from <itunes:duration>
|
|
||||||
const durationRaw = getTagValue(item, "itunes:duration")
|
|
||||||
const duration = parseDuration(durationRaw)
|
|
||||||
|
|
||||||
// Episode & season numbers
|
|
||||||
const episodeNumRaw = getTagValue(item, "itunes:episode")
|
|
||||||
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
|
|
||||||
const seasonNumRaw = getTagValue(item, "itunes:season")
|
|
||||||
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
|
|
||||||
|
|
||||||
// Episode type & explicit
|
|
||||||
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
|
|
||||||
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
|
|
||||||
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
|
|
||||||
|
|
||||||
// Episode image (itunes:image has href attribute)
|
|
||||||
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
|
|
||||||
|
|
||||||
const ep: Episode = {
|
|
||||||
id: `${feedUrl}#${index}`,
|
|
||||||
podcastId: feedUrl,
|
|
||||||
title: epTitle,
|
|
||||||
description: epDescription,
|
|
||||||
audioUrl,
|
|
||||||
duration,
|
|
||||||
pubDate,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only set optional fields if present
|
|
||||||
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
|
|
||||||
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
|
|
||||||
if (episodeType) ep.episodeType = episodeType
|
|
||||||
if (explicit !== undefined) ep.explicit = explicit
|
|
||||||
if (imageUrl) ep.imageUrl = imageUrl
|
|
||||||
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
|
|
||||||
if (mimeType) ep.mimeType = mimeType
|
|
||||||
|
|
||||||
return ep
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: feedUrl,
|
id: feedUrl,
|
||||||
@@ -142,6 +168,7 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
|
|||||||
feedUrl,
|
feedUrl,
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
isSubscribed: true,
|
isSubscribed: true,
|
||||||
|
coverUrl: parseChannelCoverUrl(channel),
|
||||||
episodes,
|
episodes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/components/GlobalActivityIndicator.tsx
Normal file
35
src/components/GlobalActivityIndicator.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Show } from "solid-js";
|
||||||
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useSearchStore } from "@/stores/search";
|
||||||
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { useActivityStore } from "@/stores/activity";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalActivityIndicator — one global top-right signal that ANY feed
|
||||||
|
* refresh, fetch-more, subscribe fetch, search, or download is in flight.
|
||||||
|
* Per-page spinners are unchanged; this overlays the content row and status
|
||||||
|
* bar as a single app-wide "something is happening" indicator.
|
||||||
|
*/
|
||||||
|
export function GlobalActivityIndicator() {
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const searchStore = useSearchStore();
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
|
||||||
|
/** True while any tracked activity is in flight */
|
||||||
|
const isActive = () =>
|
||||||
|
feedStore.isLoadingFeeds() ||
|
||||||
|
feedStore.isLoadingMore() ||
|
||||||
|
searchStore.isSearching() ||
|
||||||
|
downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 ||
|
||||||
|
activity.isActive();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={isActive()}>
|
||||||
|
<box position="absolute" top={0} right={0} paddingRight={1}>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -63,6 +63,9 @@ export type PaneRowProps = {
|
|||||||
/** Number of visible columns. `3` (default) = parent|current|preview;
|
/** Number of visible columns. `3` (default) = parent|current|preview;
|
||||||
* `2` = parent|current (preview omitted, current grows to fill). */
|
* `2` = parent|current (preview omitted, current grows to fill). */
|
||||||
panes?: 2 | 3;
|
panes?: 2 | 3;
|
||||||
|
/** Which sides of the current column's border render. Defaults to
|
||||||
|
* `["left", "right"]` (the standard focused-list frame). */
|
||||||
|
currentBorder?: boolean | BorderSides[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
@@ -181,6 +184,9 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
? PANE_RATIO.current + PANE_RATIO.preview
|
? PANE_RATIO.current + PANE_RATIO.preview
|
||||||
: PANE_RATIO.current,
|
: PANE_RATIO.current,
|
||||||
);
|
);
|
||||||
|
const currentBorder = createMemo<boolean | BorderSides[]>(
|
||||||
|
() => props.currentBorder ?? ["left", "right"],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||||
@@ -197,7 +203,7 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
grow={currentGrow()}
|
grow={currentGrow()}
|
||||||
label={() => ""}
|
label={() => ""}
|
||||||
content={currentContent}
|
content={currentContent}
|
||||||
border={["left", "right"]}
|
border={currentBorder()}
|
||||||
scrollFocused={() => focused()}
|
scrollFocused={() => focused()}
|
||||||
/>
|
/>
|
||||||
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
|
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { TABS } from "@/utils/navigation";
|
|||||||
import { createDispatcher } from "@/utils/dispatch";
|
import { createDispatcher } from "@/utils/dispatch";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
|
import { GlobalActivityIndicator } from "@/components/GlobalActivityIndicator";
|
||||||
|
|
||||||
export function Shell() {
|
export function Shell() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -396,6 +397,8 @@ export function Shell() {
|
|||||||
theme={t as any}
|
theme={t as any}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
{/* ── Global activity indicator (top-right overlay) ─────────────────────── */}
|
||||||
|
<GlobalActivityIndicator />
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { onCleanup } from "solid-js";
|
|||||||
import {
|
import {
|
||||||
cachedCoverPath,
|
cachedCoverPath,
|
||||||
fetchCoverArt,
|
fetchCoverArt,
|
||||||
prefetchCoverArt,
|
|
||||||
} from "../utils/cover-art";
|
} from "../utils/cover-art";
|
||||||
import {
|
import {
|
||||||
createAudioBackend,
|
createAudioBackend,
|
||||||
@@ -57,6 +56,7 @@ import {
|
|||||||
import type { Episode, Progress } from "../types/episode";
|
import type { Episode, Progress } from "../types/episode";
|
||||||
import type { Feed } from "../types/feed";
|
import type { Feed } from "../types/feed";
|
||||||
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
||||||
|
import { useDownloadStore } from "../stores/download";
|
||||||
import { useFeedStore } from "../stores/feed";
|
import { useFeedStore } from "../stores/feed";
|
||||||
|
|
||||||
export interface AudioControls {
|
export interface AudioControls {
|
||||||
@@ -316,12 +316,26 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
// Cover art must NEVER gate playback (it was a curl subprocess blocking
|
// Play the downloaded file when present (offline + no network stalls);
|
||||||
// play() by up to 8s). Serve the disk-cached file synchronously when it
|
// otherwise stream. Cover resolves to the feed art, falling back to the
|
||||||
// exists; on a miss, start playback bare and fetch in the background —
|
// episode's own image (feeds added by URL may lack a channel cover).
|
||||||
// the backend applies late art at runtime (mpv video-add).
|
const downloadStore = useDownloadStore();
|
||||||
const coverUrl = feed?.podcast.coverUrl;
|
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
|
||||||
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
|
const coverUrl = feed?.podcast.coverUrl ?? episode.imageUrl;
|
||||||
|
// Cover art only applies at file LOAD (the runtime video-add fallback
|
||||||
|
// never becomes an albumart track), so a cold-cache play must wait for
|
||||||
|
// the fetch or play artless. Serve the disk cache synchronously; on a
|
||||||
|
// miss, await the single-flight fetch with a 1.2s cap (covers fetch in
|
||||||
|
// ~300ms typically) — past the cap, play bare and let the fetch warm
|
||||||
|
// the cache for next time.
|
||||||
|
let coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
|
||||||
|
if (coverUrl && !coverArtPath) {
|
||||||
|
const path = await Promise.race([
|
||||||
|
fetchCoverArt(coverUrl),
|
||||||
|
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1200)),
|
||||||
|
]);
|
||||||
|
if (path) coverArtPath = path;
|
||||||
|
}
|
||||||
|
|
||||||
// Resume from saved progress if available and not completed
|
// Resume from saved progress if available and not completed
|
||||||
const savedProgress = progressStore.get(episode.id);
|
const savedProgress = progressStore.get(episode.id);
|
||||||
@@ -330,24 +344,14 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
startPos = savedProgress.position;
|
startPos = savedProgress.position;
|
||||||
}
|
}
|
||||||
|
|
||||||
await b.play(episode.audioUrl, {
|
await b.play(url, {
|
||||||
volume: vol,
|
volume: vol,
|
||||||
speed: spd,
|
speed: spd,
|
||||||
startPosition: startPos > 0 ? startPos : undefined,
|
startPosition: startPos > 0 ? startPos : undefined,
|
||||||
mediaTitle: podcastTitle ? `${podcastTitle} — ${episode.title}` : episode.title,
|
mediaTitle: episode.title,
|
||||||
coverArtPath: coverArtPath ?? undefined,
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (coverUrl && !coverArtPath) {
|
|
||||||
fetchCoverArt(coverUrl)
|
|
||||||
.then((path) => {
|
|
||||||
if (path && currentEpisode()?.id === episode.id) {
|
|
||||||
b.addCoverArt(path).catch(() => {});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentEpisode(episode);
|
setCurrentEpisode(episode);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
setPosition(startPos);
|
setPosition(startPos);
|
||||||
@@ -423,21 +427,25 @@ async function load(episode: Episode): Promise<void> {
|
|||||||
// fills its demuxer cache while parked, so the user's first Play flips
|
// fills its demuxer cache while parked, so the user's first Play flips
|
||||||
// `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget
|
// `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget
|
||||||
// — a failed preload just makes the first play take the cold path.
|
// — a failed preload just makes the first play take the cold path.
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
|
||||||
if (episode.audioUrl && backend) {
|
if (episode.audioUrl && backend) {
|
||||||
const coverUrl = feed?.podcast.coverUrl;
|
// The preload must carry the cover AT LOAD: cover-art-files only
|
||||||
if (coverUrl) prefetchCoverArt(coverUrl);
|
// applies when the file loads, and the runtime video-add fallback
|
||||||
|
// never becomes an albumart track (verified). Restore already waits
|
||||||
|
// on feeds/progress at boot, so the bounded fetch (~300ms typical,
|
||||||
|
// 8s worst case) is free. Falls back to the episode's own image when
|
||||||
|
// the feed has no channel cover.
|
||||||
|
const coverUrl = feed?.podcast.coverUrl ?? episode.imageUrl;
|
||||||
|
const coverArtPath = coverUrl ? await fetchCoverArt(coverUrl) : null;
|
||||||
const backendSnap = backend;
|
const backendSnap = backend;
|
||||||
backendSnap
|
backendSnap
|
||||||
.preload(episode.audioUrl, {
|
.preload(url, {
|
||||||
volume: volume(),
|
volume: volume(),
|
||||||
speed: storeSpeed || speed(),
|
speed: storeSpeed || speed(),
|
||||||
startPosition: pos > 0 ? pos : undefined,
|
startPosition: pos > 0 ? pos : undefined,
|
||||||
mediaTitle: podcastTitle
|
mediaTitle: episode.title,
|
||||||
? `${podcastTitle} — ${episode.title}`
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
: episode.title,
|
|
||||||
coverArtPath: coverUrl
|
|
||||||
? (cachedCoverPath(coverUrl) ?? undefined)
|
|
||||||
: undefined,
|
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -602,15 +610,15 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
.feeds()
|
.feeds()
|
||||||
.find((f) => f.podcast.id === ep.podcastId);
|
.find((f) => f.podcast.id === ep.podcastId);
|
||||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
const coverUrl = feed?.podcast.coverUrl;
|
const url =
|
||||||
|
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
|
||||||
|
const coverUrl = feed?.podcast.coverUrl ?? ep.imageUrl;
|
||||||
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
|
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
|
||||||
await backend.play(ep.audioUrl, {
|
await backend.play(url, {
|
||||||
startPosition: pos,
|
startPosition: pos,
|
||||||
volume: vol,
|
volume: vol,
|
||||||
speed: spd,
|
speed: spd,
|
||||||
mediaTitle: podcastTitle
|
mediaTitle: ep.title,
|
||||||
? `${podcastTitle} — ${ep.title}`
|
|
||||||
: ep.title,
|
|
||||||
coverArtPath: coverArtPath ?? undefined,
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
});
|
});
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Feed } from "./types/feed"
|
import type { Feed } from "./types/feed"
|
||||||
import type { Episode } from "./types/episode"
|
import type { Episode } from "./types/episode"
|
||||||
|
|
||||||
const VERSION = "0.5.2";
|
const VERSION = "0.6.1";
|
||||||
|
|
||||||
interface CliArgs {
|
interface CliArgs {
|
||||||
version: boolean;
|
version: boolean;
|
||||||
@@ -186,12 +186,16 @@ async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
|
|||||||
const backend = createAudioBackend()
|
const backend = createAudioBackend()
|
||||||
if (episodeResult.audioUrl) {
|
if (episodeResult.audioUrl) {
|
||||||
// Stage the podcast cover so the system Now Playing shows
|
// Stage the podcast cover so the system Now Playing shows
|
||||||
// artwork (mpv --cover-art-files), like the UI path does.
|
// artwork (mpv --cover-art-files), like the UI path does. Falls
|
||||||
const coverArtPath = feedResult.podcast.coverUrl
|
// back to the episode's own image when the feed has no channel
|
||||||
? await fetchCoverArt(feedResult.podcast.coverUrl)
|
// cover (URL-added feeds).
|
||||||
|
const coverUrl =
|
||||||
|
feedResult.podcast.coverUrl ?? episodeResult.imageUrl;
|
||||||
|
const coverArtPath = coverUrl
|
||||||
|
? await fetchCoverArt(coverUrl)
|
||||||
: null
|
: null
|
||||||
await backend.play(episodeResult.audioUrl, {
|
await backend.play(episodeResult.audioUrl, {
|
||||||
mediaTitle: `${feedResult.podcast.title} — ${episodeResult.title}`,
|
mediaTitle: episodeResult.title,
|
||||||
coverArtPath: coverArtPath ?? undefined,
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
})
|
})
|
||||||
console.log("Playback started (use the UI to control)")
|
console.log("Playback started (use the UI to control)")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-j
|
|||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useDownloadStore } from "@/stores/download";
|
import { useDownloadStore } from "@/stores/download";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { prefetchCoverArt } from "@/utils/cover-art";
|
||||||
import { DownloadStatus } from "@/types/episode";
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
@@ -63,6 +64,22 @@ function FeedPage() {
|
|||||||
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Cover warm-up ────────────────────────────────────────────────────────
|
||||||
|
// Prefetch covers for episodes around the focus (plus the top of the
|
||||||
|
// list) so plays land on a warm cache: cover-art-files only applies at
|
||||||
|
// file load, and there is no working runtime fallback. Single-flight +
|
||||||
|
// cache short-circuit keep repeat runs cheap (hits resolve immediately).
|
||||||
|
createEffect(() => {
|
||||||
|
const list = episodes();
|
||||||
|
const focusIdx = focusedEpIdx();
|
||||||
|
const start = Math.max(0, focusIdx - 10);
|
||||||
|
const end = Math.min(list.length, focusIdx + 11);
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
const item = list[i];
|
||||||
|
if (item?.feed.podcast.coverUrl) prefetchCoverArt(item.feed.podcast.coverUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Fetch More ───────────────────────────────────────────────────────────
|
// ── Fetch More ───────────────────────────────────────────────────────────
|
||||||
// A "[Fetch More]" row at the bottom of the list advances every feed's
|
// A "[Fetch More]" row at the bottom of the list advances every feed's
|
||||||
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
|
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
|
||||||
@@ -236,7 +253,7 @@ function FeedPage() {
|
|||||||
<Show
|
<Show
|
||||||
when={episodes().length > 0}
|
when={episodes().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1} alignItems="center">
|
||||||
<Show
|
<Show
|
||||||
when={feedStore.isLoadingFeeds()}
|
when={feedStore.isLoadingFeeds()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -355,7 +372,7 @@ function FeedPage() {
|
|||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={feedStore.isLoadingFeeds()}>
|
<Show when={feedStore.isLoadingFeeds()}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box alignItems="center" paddingTop={1}>
|
||||||
<LoadingIndicator label="Refreshing…" />
|
<LoadingIndicator label="Refreshing…" />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export function PlayerPage() {
|
|||||||
currentLabel="Player"
|
currentLabel="Player"
|
||||||
panes={2}
|
panes={2}
|
||||||
focused={isActive}
|
focused={isActive}
|
||||||
|
currentBorder={["left"]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export function ProgressBar() {
|
|||||||
padding={0}
|
padding={0}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={0}
|
gap={0}
|
||||||
|
// The bar's block-char texts are non-selectable below: a drag
|
||||||
|
// over the bar is a seek gesture, not a text selection — otherwise
|
||||||
|
// mouse-up would copy █/░ to the clipboard via the global
|
||||||
|
// selection handler.
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
bar = el;
|
bar = el;
|
||||||
}}
|
}}
|
||||||
@@ -58,9 +62,11 @@ export function ProgressBar() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{playedChars() > 0 && (
|
{playedChars() > 0 && (
|
||||||
<text fg={theme.primary}>{"\u2588".repeat(playedChars())}</text>
|
<text fg={theme.primary} selectable={false}>
|
||||||
|
{"\u2588".repeat(playedChars())}
|
||||||
|
</text>
|
||||||
)}
|
)}
|
||||||
<text fg={remainingColor}>
|
<text fg={remainingColor} selectable={false}>
|
||||||
{"\u2591".repeat(width() - playedChars())}
|
{"\u2591".repeat(width() - playedChars())}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -4,13 +4,15 @@
|
|||||||
* driven by the Shell router via nav.action.
|
* driven by the Shell router via nav.action.
|
||||||
*
|
*
|
||||||
* Auto-download (global setting, see stores/feed.ts runAutoDownload):
|
* Auto-download (global setting, see stores/feed.ts runAutoDownload):
|
||||||
* • Auto Download — master toggle (default: off)
|
|
||||||
* • Auto Download Count — X most recent episodes per show (default: 2,
|
|
||||||
* any positive integer — type it in the editor)
|
|
||||||
* • Auto Download Scope — which shows: all / none / whitelist (default: all)
|
|
||||||
* • Auto Download Whitelist — shown only when scope is "whitelist": search
|
* • Auto Download Whitelist — shown only when scope is "whitelist": search
|
||||||
* field over subscribed shows; suggestions toggle
|
* field over subscribed shows; suggestions toggle
|
||||||
* in/out with Space (j/k to move, Esc to browse).
|
* in/out with Space (j/k to move, Esc to browse).
|
||||||
|
* • Episode Cache Mode — date or count bound for the episode list
|
||||||
|
* (default: date)
|
||||||
|
* • Episode Cache Count — N most recent episodes when mode is count
|
||||||
|
* (default: 25)
|
||||||
|
* • Episode Cache Days — rolling N-day window when mode is date
|
||||||
|
* (default: 60)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
|
import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
|
||||||
@@ -30,7 +32,7 @@ import {
|
|||||||
import { on } from "@/utils/event-bus";
|
import { on } from "@/utils/event-bus";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import { TABS } from "@/utils/navigation";
|
import { TABS } from "@/utils/navigation";
|
||||||
import type { AutoDownloadScope, ThemeName } from "@/types/settings";
|
import type { AutoDownloadScope, EpisodeCacheMode, ThemeName } from "@/types/settings";
|
||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
import type { SettingItem } from "./types";
|
import type { SettingItem } from "./types";
|
||||||
|
|
||||||
@@ -48,6 +50,14 @@ const SCOPE_LABELS: Array<{ value: AutoDownloadScope; label: string }> = [
|
|||||||
{ value: "none", label: "None" },
|
{ value: "none", label: "None" },
|
||||||
{ value: "whitelist", label: "Whitelist" },
|
{ value: "whitelist", label: "Whitelist" },
|
||||||
];
|
];
|
||||||
|
const CACHE_MODE_LABELS: Array<{ value: EpisodeCacheMode; label: string }> = [
|
||||||
|
{ value: "date", label: "Date" },
|
||||||
|
{ value: "count", label: "Count" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function cacheModeLabel(mode: EpisodeCacheMode): string {
|
||||||
|
return CACHE_MODE_LABELS.find((s) => s.value === mode)?.label ?? mode;
|
||||||
|
}
|
||||||
|
|
||||||
function scopeLabel(scope: AutoDownloadScope): string {
|
function scopeLabel(scope: AutoDownloadScope): string {
|
||||||
return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope;
|
return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope;
|
||||||
@@ -205,6 +215,79 @@ export function usePreferencesItems(): SettingItem[] {
|
|||||||
autoJumpToPlayer: !prefs().autoJumpToPlayer,
|
autoJumpToPlayer: !prefs().autoJumpToPlayer,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "episodeCacheMode",
|
||||||
|
label: "Episode Cache Mode",
|
||||||
|
kind: "select",
|
||||||
|
display: () => cacheModeLabel(prefs().episodeCacheMode),
|
||||||
|
help: () =>
|
||||||
|
`How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below).\nCount: keep the N most recent episodes (see Cache Count below).\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const idx = CACHE_MODE_LABELS.findIndex(
|
||||||
|
(s) => s.value === prefs().episodeCacheMode,
|
||||||
|
);
|
||||||
|
const next =
|
||||||
|
CACHE_MODE_LABELS[
|
||||||
|
(idx + dir + CACHE_MODE_LABELS.length) % CACHE_MODE_LABELS.length
|
||||||
|
].value;
|
||||||
|
app.updatePreferences({ episodeCacheMode: next });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "episodeCacheCount",
|
||||||
|
label: "Episode Cache Count",
|
||||||
|
kind: "number",
|
||||||
|
display: () =>
|
||||||
|
prefs().episodeCacheMode === "count"
|
||||||
|
? `${prefs().episodeCacheCount} eps`
|
||||||
|
: "(date mode)",
|
||||||
|
help: () =>
|
||||||
|
`Number of most-recent episodes to keep in the Feed/My Shows lists when mode is Count.\nType: number (any positive integer)\nDefault: 25\nCurrent: ${prefs().episodeCacheCount}\nj/k to −/+1 · Enter to type a value.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const next = Math.max(1, prefs().episodeCacheCount + dir);
|
||||||
|
app.updatePreferences({ episodeCacheCount: next });
|
||||||
|
},
|
||||||
|
renderEditor: () => (
|
||||||
|
<NumberInputEditor
|
||||||
|
label="Episode Cache Count"
|
||||||
|
value={() => prefs().episodeCacheCount}
|
||||||
|
commit={(n) => {
|
||||||
|
app.updatePreferences({
|
||||||
|
episodeCacheCount: Math.max(1, n),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "episodeCacheDays",
|
||||||
|
label: "Episode Cache Days",
|
||||||
|
kind: "number",
|
||||||
|
display: () =>
|
||||||
|
prefs().episodeCacheMode === "date"
|
||||||
|
? `${prefs().episodeCacheDays} days`
|
||||||
|
: "(count mode)",
|
||||||
|
help: () =>
|
||||||
|
`Rolling window in days for the Feed/My Shows episode lists when mode is Date.\nType: number (1–365)\nDefault: 60\nCurrent: ${prefs().episodeCacheDays} days\nj/k to −/+5 · Enter to type a value.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const next = Math.min(
|
||||||
|
365,
|
||||||
|
Math.max(1, prefs().episodeCacheDays + dir * 5),
|
||||||
|
);
|
||||||
|
app.updatePreferences({ episodeCacheDays: next });
|
||||||
|
},
|
||||||
|
renderEditor: () => (
|
||||||
|
<NumberInputEditor
|
||||||
|
label="Episode Cache Days"
|
||||||
|
value={() => prefs().episodeCacheDays}
|
||||||
|
commit={(n) => {
|
||||||
|
app.updatePreferences({
|
||||||
|
episodeCacheDays: Math.min(365, Math.max(1, n)),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "fetchMore",
|
id: "fetchMore",
|
||||||
label: "Fetch More",
|
label: "Fetch More",
|
||||||
|
|||||||
75
src/stores/activity.ts
Normal file
75
src/stores/activity.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Activity store for PodTUI
|
||||||
|
*
|
||||||
|
* Shared leak-proof activity counter: any store can surface "something is
|
||||||
|
* loading/downloading" to the global top-right indicator. beginActivity
|
||||||
|
* returns an end token that removes exactly THAT instance, so concurrent
|
||||||
|
* overlapping activities compose correctly; prefer track() so callers
|
||||||
|
* cannot strand the counter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
|
||||||
|
/** Create activity store */
|
||||||
|
function createActivityStore() {
|
||||||
|
const [count, setCount] = createSignal(0);
|
||||||
|
const [labels, setLabels] = createSignal<string[]>([]);
|
||||||
|
|
||||||
|
/** Begin a tracked activity and return its end function. Every begin
|
||||||
|
* MUST be paired with exactly one call of the returned end (via the
|
||||||
|
* token); prefer track() so the pairing is automatic. Duplicate labels
|
||||||
|
* are allowed — each end removes exactly one instance (found by
|
||||||
|
* indexOf). */
|
||||||
|
const beginActivity = (label: string): (() => void) => {
|
||||||
|
setLabels((prev) => [...prev, label]);
|
||||||
|
setCount((c) => c + 1);
|
||||||
|
let ended = false;
|
||||||
|
return () => {
|
||||||
|
if (ended) return;
|
||||||
|
ended = true;
|
||||||
|
setLabels((prev) => {
|
||||||
|
const idx = prev.indexOf(label);
|
||||||
|
if (idx === -1) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
next.splice(idx, 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setCount((c) => Math.max(0, c - 1));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Track a promise: begin an activity, auto-end when it settles, and
|
||||||
|
* re-throw on rejection so the caller's error handling is untouched. */
|
||||||
|
const track = async <T,>(p: Promise<T>, label: string): Promise<T> => {
|
||||||
|
const end = beginActivity(label);
|
||||||
|
try {
|
||||||
|
return await p;
|
||||||
|
} finally {
|
||||||
|
end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True while at least one activity is in flight */
|
||||||
|
const isActive = (): boolean => count() > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
count,
|
||||||
|
labels,
|
||||||
|
// Actions
|
||||||
|
beginActivity,
|
||||||
|
track,
|
||||||
|
// Getters
|
||||||
|
isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton activity store */
|
||||||
|
let activityStoreInstance: ReturnType<typeof createActivityStore> | null = null;
|
||||||
|
|
||||||
|
export function useActivityStore() {
|
||||||
|
if (!activityStoreInstance) {
|
||||||
|
activityStoreInstance = createActivityStore();
|
||||||
|
}
|
||||||
|
return activityStoreInstance;
|
||||||
|
}
|
||||||
@@ -45,6 +45,9 @@ const defaultPreferences: UserPreferences = {
|
|||||||
autoJumpToPlayer: true,
|
autoJumpToPlayer: true,
|
||||||
fetchMoreMode: "auto",
|
fetchMoreMode: "auto",
|
||||||
refreshIntervalMinutes: 30,
|
refreshIntervalMinutes: 30,
|
||||||
|
episodeCacheMode: "date",
|
||||||
|
episodeCacheCount: 25,
|
||||||
|
episodeCacheDays: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
|
|||||||
@@ -237,21 +237,67 @@ function createDownloadStore() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Write the podcast cover beside the audio so mpv's
|
// Write the podcast cover beside the audio so mpv's
|
||||||
// --cover-art-auto=exact picks it up for Now Playing art.
|
// --cover-art-auto=exact picks it up for Now Playing art when the
|
||||||
const coverUrl = useFeedStore()
|
// local file plays (same basename, .jpg extension — verified
|
||||||
.feeds()
|
// against mpv 0.41). curl, NOT fetch: Bun's fetch hangs in
|
||||||
.find((f) => f.id === item.feedId)?.podcast.coverUrl;
|
// compiled binaries, so the shipped app never wrote this file.
|
||||||
|
// Falls back to the episode's own image when the feed has no
|
||||||
|
// channel cover (URL-added feeds).
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const episode = feedStore.findEpisode(item.episodeId);
|
||||||
|
const coverUrl =
|
||||||
|
feedStore
|
||||||
|
.feeds()
|
||||||
|
.find((f) => f.id === item.feedId)?.podcast.coverUrl ??
|
||||||
|
episode?.imageUrl;
|
||||||
if (coverUrl && result.filePath) {
|
if (coverUrl && result.filePath) {
|
||||||
const dot = result.filePath.lastIndexOf(".");
|
const dot = result.filePath.lastIndexOf(".");
|
||||||
if (dot > 0) {
|
if (dot > 0) {
|
||||||
const coverPath = result.filePath.slice(0, dot) + ".jpg";
|
const coverPath = result.filePath.slice(0, dot) + ".jpg";
|
||||||
fetch(coverUrl)
|
Bun.spawn([
|
||||||
.then(async (r) => {
|
"curl",
|
||||||
if (!r.ok) return;
|
"-sS",
|
||||||
await Bun.write(
|
"--fail",
|
||||||
coverPath,
|
"-m",
|
||||||
new Uint8Array(await r.arrayBuffer()),
|
"8",
|
||||||
);
|
"--max-filesize",
|
||||||
|
"2097152",
|
||||||
|
"-o",
|
||||||
|
coverPath,
|
||||||
|
coverUrl,
|
||||||
|
])
|
||||||
|
.exited.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag the local file (codec-copy, no re-encode) so mpv's Now
|
||||||
|
// Playing metadata for local playback is title=episode,
|
||||||
|
// artist=podcast — the source streams carry no usable tags and
|
||||||
|
// macOS composes "title - artist" from exactly these fields.
|
||||||
|
// Atomic: ffmpeg writes a temp file, then renames into place.
|
||||||
|
if (result.filePath && episode) {
|
||||||
|
const podcastTitle =
|
||||||
|
feedStore.feeds().find((f) => f.id === item.feedId)?.podcast.title ??
|
||||||
|
downloads().get(item.episodeId)?.podcastTitle;
|
||||||
|
if (podcastTitle) {
|
||||||
|
const tmp = `${result.filePath}.tag.mp3`;
|
||||||
|
Bun.spawn([
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
result.filePath,
|
||||||
|
"-c",
|
||||||
|
"copy",
|
||||||
|
"-metadata",
|
||||||
|
`title=${episode.title}`,
|
||||||
|
"-metadata",
|
||||||
|
`artist=${podcastTitle}`,
|
||||||
|
tmp,
|
||||||
|
])
|
||||||
|
.exited.then(async (code) => {
|
||||||
|
if (code !== 0) return;
|
||||||
|
const { renameSync } = await import("node:fs");
|
||||||
|
renameSync(tmp, result.filePath);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,18 @@ import type { Podcast } from "../types/podcast";
|
|||||||
import type { Episode } from "../types/episode";
|
import type { Episode } from "../types/episode";
|
||||||
import type { PodcastSource } from "../types/source";
|
import type { PodcastSource } from "../types/source";
|
||||||
import { DEFAULT_SOURCES } from "../types/source";
|
import { DEFAULT_SOURCES } from "../types/source";
|
||||||
import { parseRSSFeed } from "../api/rss-parser";
|
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
|
||||||
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
|
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
|
||||||
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
||||||
|
import { mergeEpisodesBounded } from "../utils/episode-merge";
|
||||||
import {
|
import {
|
||||||
|
episodeInWindow,
|
||||||
loadFeedsFromFile,
|
loadFeedsFromFile,
|
||||||
saveFeedsToFile,
|
saveFeedsToFile,
|
||||||
loadSourcesFromFile,
|
loadSourcesFromFile,
|
||||||
saveSourcesToFile,
|
saveSourcesToFile,
|
||||||
} from "../utils/feeds-persistence";
|
} from "../utils/feeds-persistence";
|
||||||
|
import { useActivityStore } from "./activity";
|
||||||
import { useDownloadStore } from "./download";
|
import { useDownloadStore } from "./download";
|
||||||
import { useAppStore } from "./app";
|
import { useAppStore } from "./app";
|
||||||
import { DownloadStatus } from "../types/episode";
|
import { DownloadStatus } from "../types/episode";
|
||||||
@@ -33,18 +36,95 @@ const MAX_EPISODES_SUBSCRIBE = 20;
|
|||||||
* the background refresh loop. */
|
* the background refresh loop. */
|
||||||
const FETCH_TIMEOUT_MS = 20_000;
|
const FETCH_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
/** Bounds simultaneous RSS requests during a refresh batch — a hung feed
|
||||||
|
* burns at most one slot for FETCH_TIMEOUT_MS instead of pinning the whole
|
||||||
|
* batch. */
|
||||||
|
const FETCH_CONCURRENCY = 4;
|
||||||
|
|
||||||
/** Default minutes between automatic background feed refreshes. */
|
/** Default minutes between automatic background feed refreshes. */
|
||||||
const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
|
const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
|
||||||
|
|
||||||
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */
|
/** Max episodes parsed per chunk before yielding to the event loop — bounds
|
||||||
|
* the synchronous regex work per frame so one huge feed (or a batch of
|
||||||
|
* feeds) can't stall the renderer. */
|
||||||
|
const PARSE_CHUNK_SIZE = 5;
|
||||||
|
|
||||||
|
/** Yield to the event loop (task queue) so the renderer can paint between
|
||||||
|
* parse chunks. MessageChannel instead of setTimeout/setImmediate because
|
||||||
|
* bun:test fake timers trap those (feed-refresh/pagination tests run under
|
||||||
|
* vi.useFakeTimers and await refreshes, so a trapped yield would deadlock
|
||||||
|
* them); MessageChannel posts are real task-queue turns that fire in both
|
||||||
|
* environments. */
|
||||||
|
const yieldToUI = (): Promise<void> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const { port1, port2 } = new MessageChannel();
|
||||||
|
port1.onmessage = () => {
|
||||||
|
port1.close();
|
||||||
|
port2.close();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
port2.postMessage(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Parse all episodes from feed XML in bounded chunks, yielding to the event
|
||||||
|
* loop between chunks. The whole-feed sync `parseRSSFeed` would otherwise
|
||||||
|
* block the UI thread for the combined parse time of every feed in a
|
||||||
|
* refresh batch. */
|
||||||
|
const parseEpisodesIncremental = async (
|
||||||
|
xml: string,
|
||||||
|
feedUrl: string,
|
||||||
|
): Promise<Episode[]> => {
|
||||||
|
const items = getRSSItems(xml);
|
||||||
|
// Yield after the item-extraction regex (which scans the full XML
|
||||||
|
// synchronously) so the renderer paints before the first parse chunk.
|
||||||
|
await yieldToUI();
|
||||||
|
const episodes: Episode[] = new Array(items.length);
|
||||||
|
for (let start = 0; start < items.length; start += PARSE_CHUNK_SIZE) {
|
||||||
|
const end = Math.min(start + PARSE_CHUNK_SIZE, items.length);
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
episodes[i] = parseRSSItem(items[i], feedUrl, i);
|
||||||
|
}
|
||||||
|
if (end < items.length) await yieldToUI();
|
||||||
|
}
|
||||||
|
return episodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Cache of ALL parsed episodes per feed (feedId -> Episode[]). Holds the
|
||||||
|
* full parse — the bound (count or date) is applied when reading, not when
|
||||||
|
* writing, so changing the preference takes effect without a refetch.
|
||||||
|
* Fetch-more reads beyond the bound from this cache (volatile only — the
|
||||||
|
* cache itself is never extended by fetch-more). */
|
||||||
const fullEpisodeCache = new Map<string, Episode[]>();
|
const fullEpisodeCache = new Map<string, Episode[]>();
|
||||||
|
|
||||||
/** Track how many episodes are currently loaded per feed */
|
/** Track how many episodes are currently loaded (visible) per feed. The
|
||||||
|
* loaded window grows via fetch-more but never exceeds what the cache
|
||||||
|
* holds — when it reaches the cache length, hasMoreEpisodes flips false. */
|
||||||
const episodeLoadCount = new Map<string, number>();
|
const episodeLoadCount = new Map<string, number>();
|
||||||
|
|
||||||
/** Save feeds to file (async, fire-and-forget) */
|
/** Read the episode cache bound from preferences: a closure that decides
|
||||||
|
* whether the episode at `index` (0 = newest, after sort) is kept. */
|
||||||
|
function episodeKeepFn(prefs: {
|
||||||
|
episodeCacheMode: "date" | "count";
|
||||||
|
episodeCacheCount: number;
|
||||||
|
episodeCacheDays: number;
|
||||||
|
}): (ep: Episode, index: number) => boolean {
|
||||||
|
const now = new Date();
|
||||||
|
if (prefs.episodeCacheMode === "count") {
|
||||||
|
const count = Math.max(1, prefs.episodeCacheCount);
|
||||||
|
return (_ep: Episode, index: number) => index < count;
|
||||||
|
}
|
||||||
|
const days = Math.max(1, prefs.episodeCacheDays);
|
||||||
|
return (ep: Episode) => episodeInWindow(ep, now, days);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save feeds to file (async, fire-and-forget). */
|
||||||
function saveFeeds(feeds: Feed[]): void {
|
function saveFeeds(feeds: Feed[]): void {
|
||||||
saveFeedsToFile(feeds);
|
const prefs = useAppStore().state().preferences;
|
||||||
|
const days =
|
||||||
|
prefs.episodeCacheMode === "date"
|
||||||
|
? Math.max(1, prefs.episodeCacheDays)
|
||||||
|
: undefined;
|
||||||
|
saveFeedsToFile(feeds, days);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save sources to file (async, fire-and-forget) */
|
/** Save sources to file (async, fire-and-forget) */
|
||||||
@@ -96,15 +176,42 @@ async function migratePlaintextCredentials(
|
|||||||
return changed ? migrated : sources;
|
return changed ? migrated : sources;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when two episode lists hold the same episodes (id-set equality,
|
/** True when the freshly fetched window matches the corresponding PREFIX of
|
||||||
* order-insensitive). Refreshes compare fetched content against this so an
|
* the existing episode list (id-set equality, order-insensitive). With
|
||||||
* unchanged feed keeps its `lastUpdated` — and therefore its place in the
|
* union semantics the merged list legitimately contains episodes BEYOND the
|
||||||
* "updated" sort — instead of reordering the list on every background
|
* fetched window, so unchanged-detection must compare the fetched window
|
||||||
* refresh. */
|
* against the existing list's prefix — comparing full lists would bump
|
||||||
function sameEpisodes(a: Episode[], b: Episode[]): boolean {
|
* `lastUpdated` on every refresh. */
|
||||||
if (a.length !== b.length) return false;
|
function sameRefreshWindow(existing: Episode[], fetched: Episode[]): boolean {
|
||||||
const ids = new Set(a.map((e) => e.id));
|
if (fetched.length === 0) return true;
|
||||||
return b.every((e) => ids.has(e.id));
|
const prefix = existing.slice(0, fetched.length);
|
||||||
|
const ids = new Set(prefix.map((e) => e.id));
|
||||||
|
return fetched.every((e) => ids.has(e.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run `fn` over every item with at most `limit` executions in flight — a
|
||||||
|
* classic worker pool. Workers pull indexes from a shared counter, so the
|
||||||
|
* first `limit` calls start immediately and each completion frees its slot
|
||||||
|
* for the next item; results are assembled in INPUT order regardless of
|
||||||
|
* completion order. A hung `fn` holds at most one slot. */
|
||||||
|
async function mapWithConcurrency<T, R>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
fn: (item: T) => Promise<R>,
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results = new Array<R>(items.length);
|
||||||
|
let nextIndex = 0;
|
||||||
|
const workers = Array.from(
|
||||||
|
{ length: Math.min(limit, items.length) },
|
||||||
|
async () => {
|
||||||
|
let i: number;
|
||||||
|
while ((i = nextIndex++) < items.length) {
|
||||||
|
results[i] = await fn(items[i]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await Promise.all(workers);
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create feed store */
|
/** Create feed store */
|
||||||
@@ -122,6 +229,39 @@ function createFeedStore() {
|
|||||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||||
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
||||||
|
|
||||||
|
// ── Debounced persistence ───────────────────────────────────────────────
|
||||||
|
/** Trailing-edge debounce window for config.json writes. */
|
||||||
|
const SAVE_DEBOUNCE_MS = 250;
|
||||||
|
/** True when a save is scheduled but has not flushed yet. */
|
||||||
|
let savePending = false;
|
||||||
|
let pendingSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/** Schedule a config.json write (trailing edge) — rapid state changes
|
||||||
|
* (a refresh batch landing feed-by-feed, pin toggles, load-more pages)
|
||||||
|
* collapse into one final write instead of one file rewrite per step. */
|
||||||
|
const scheduleSaveFeeds = (): void => {
|
||||||
|
savePending = true;
|
||||||
|
if (pendingSaveTimer) clearTimeout(pendingSaveTimer);
|
||||||
|
pendingSaveTimer = setTimeout(() => {
|
||||||
|
pendingSaveTimer = null;
|
||||||
|
flushPendingSave();
|
||||||
|
}, SAVE_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Persist immediately when anything is dirty; exported for tests and
|
||||||
|
* quit hooks. Cancels a pending debounced save — the state it would
|
||||||
|
* have written is already reflected in feeds(), so writing now is
|
||||||
|
* strictly more current. */
|
||||||
|
const flushPendingSave = (): void => {
|
||||||
|
if (pendingSaveTimer) {
|
||||||
|
clearTimeout(pendingSaveTimer);
|
||||||
|
pendingSaveTimer = null;
|
||||||
|
}
|
||||||
|
if (!savePending) return;
|
||||||
|
savePending = false;
|
||||||
|
saveFeeds(feeds());
|
||||||
|
};
|
||||||
|
|
||||||
/** Get filtered and sorted feeds */
|
/** Get filtered and sorted feeds */
|
||||||
const getFilteredFeeds = (): Feed[] => {
|
const getFilteredFeeds = (): Feed[] => {
|
||||||
let result = [...feeds()];
|
let result = [...feeds()];
|
||||||
@@ -208,40 +348,65 @@ function createFeedStore() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes.
|
/** Fetch latest episodes from an RSS feed URL, caching ALL parsed
|
||||||
* Returns NULL when the feed could not be fetched (network error, non-OK
|
* episodes in fullEpisodeCache. The visible episodes returned are
|
||||||
* response, timeout) — callers must treat null as "unchanged" and keep
|
* bounded by the user's cache preference (count or date); the full
|
||||||
* the previously loaded episodes. A failed refresh must never look like
|
* cache survives so fetch-more can page beyond the bound without a
|
||||||
* an empty feed, or the store would wipe a subscribed show's episodes. */
|
* refetch (volatile only — the cache is never extended by fetch-more).
|
||||||
|
* Returns NULL episodes on any failure — a failed fetch must not look
|
||||||
|
* like an empty feed, or the store would wipe a subscribed show's
|
||||||
|
* episodes. Also returns the channel-level artwork so callers can
|
||||||
|
* backfill a feed's coverUrl (subscribe + refresh). */
|
||||||
const fetchEpisodes = async (
|
const fetchEpisodes = async (
|
||||||
feedUrl: string,
|
feedUrl: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
feedId?: string,
|
feedId?: string,
|
||||||
): Promise<Episode[] | null> => {
|
): Promise<{ episodes: Episode[] | null; coverUrl: string | undefined }> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(feedUrl, {
|
const response = await fetch(feedUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
"Accept-Encoding": "identity",
|
"Accept-Encoding": "identity",
|
||||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||||
},
|
},
|
||||||
// Hung feeds must not stall a refresh batch (or the background
|
// Hung feeds must not stall a refresh batch (or the
|
||||||
// refresh loop) indefinitely.
|
// background refresh loop) indefinitely.
|
||||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return { episodes: null, coverUrl: undefined };
|
||||||
const xml = await response.text();
|
const xml = await response.text();
|
||||||
const parsed = parseRSSFeed(xml, feedUrl);
|
// Yield after the network read so the renderer gets a turn
|
||||||
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
|
// before the sync regex + parse work begins.
|
||||||
|
await yieldToUI();
|
||||||
|
const allEpisodes = sortEpisodesReverseChronological(
|
||||||
|
await parseEpisodesIncremental(xml, feedUrl),
|
||||||
|
);
|
||||||
|
|
||||||
// Cache all parsed episodes for pagination
|
|
||||||
if (feedId) {
|
if (feedId) {
|
||||||
|
// Cache the FULL parse — the bound is applied when reading,
|
||||||
|
// not when writing, so a preference change takes effect
|
||||||
|
// without a refetch.
|
||||||
fullEpisodeCache.set(feedId, allEpisodes);
|
fullEpisodeCache.set(feedId, allEpisodes);
|
||||||
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return allEpisodes.slice(0, limit);
|
// Bound the visible window by the user's cache preference.
|
||||||
|
const prefs = useAppStore().state().preferences;
|
||||||
|
const keep = episodeKeepFn(prefs);
|
||||||
|
const bounded = allEpisodes.filter((ep, i) => keep(ep, i));
|
||||||
|
const visible = bounded.slice(0, limit);
|
||||||
|
|
||||||
|
if (feedId) {
|
||||||
|
// Track how many episodes are visible — the bounded window,
|
||||||
|
// not the full parse. hasMoreEpisodes compares this to the
|
||||||
|
// full cache length to decide if fetch-more can page deeper.
|
||||||
|
episodeLoadCount.set(feedId, visible.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
episodes: visible,
|
||||||
|
coverUrl: parseChannelCoverUrl(xml),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return { episodes: null, coverUrl: undefined };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -256,45 +421,54 @@ function createFeedStore() {
|
|||||||
sourceId: string,
|
sourceId: string,
|
||||||
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
||||||
): Promise<Feed | null> => {
|
): Promise<Feed | null> => {
|
||||||
// A directory stub (e.g. a show delisted from Apple Podcasts) has no
|
const activity = useActivityStore();
|
||||||
// feed URL; resolve the real feed from its directory page before
|
// The "Subscribing" label covers the directory-resolve + subscribe
|
||||||
// subscribing. Refuse when it can't be resolved rather than adding a
|
// fetch stretch — the gaps no existing signal (isLoadingFeeds,
|
||||||
// broken feed.
|
// per-pane spinners) covers.
|
||||||
if (!podcast.feedUrl) {
|
return activity.track((async () => {
|
||||||
if (!podcast.directoryUrl) return null;
|
// A directory stub (e.g. a show delisted from Apple Podcasts) has no
|
||||||
const resolved = await resolveItunesFeedUrl(podcast.directoryUrl);
|
// feed URL; resolve the real feed from its directory page before
|
||||||
if (!resolved) return null;
|
// subscribing. Refuse when it can't be resolved rather than adding a
|
||||||
podcast = { ...podcast, feedUrl: resolved, directoryUrl: undefined };
|
// broken feed.
|
||||||
}
|
if (!podcast.feedUrl) {
|
||||||
|
if (!podcast.directoryUrl) return null;
|
||||||
|
const resolved = await resolveItunesFeedUrl(podcast.directoryUrl);
|
||||||
|
if (!resolved) return null;
|
||||||
|
podcast = { ...podcast, feedUrl: resolved, directoryUrl: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
// Guard: don't add a feed we already have (matched by feedUrl)
|
// Guard: don't add a feed we already have (matched by feedUrl)
|
||||||
if (hasFeedByUrl(podcast.feedUrl)) {
|
if (hasFeedByUrl(podcast.feedUrl)) {
|
||||||
return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
|
return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedId = crypto.randomUUID();
|
const feedId = crypto.randomUUID();
|
||||||
const episodes = await fetchEpisodes(
|
const { episodes, coverUrl } = await fetchEpisodes(
|
||||||
podcast.feedUrl,
|
podcast.feedUrl,
|
||||||
MAX_EPISODES_SUBSCRIBE,
|
MAX_EPISODES_SUBSCRIBE,
|
||||||
feedId,
|
feedId,
|
||||||
);
|
);
|
||||||
const newFeed: Feed = {
|
if (!podcast.coverUrl && coverUrl) {
|
||||||
id: feedId,
|
podcast = { ...podcast, coverUrl };
|
||||||
podcast,
|
}
|
||||||
episodes: episodes ?? [],
|
const newFeed: Feed = {
|
||||||
visibility,
|
id: feedId,
|
||||||
sourceId,
|
podcast,
|
||||||
lastUpdated: new Date(),
|
episodes: episodes ?? [],
|
||||||
isPinned: false,
|
visibility,
|
||||||
};
|
sourceId,
|
||||||
setFeeds((prev) => {
|
lastUpdated: new Date(),
|
||||||
const updated = [...prev, newFeed];
|
isPinned: false,
|
||||||
saveFeeds(updated);
|
};
|
||||||
return updated;
|
setFeeds((prev) => {
|
||||||
});
|
const updated = [...prev, newFeed];
|
||||||
// Global auto-download: newly subscribed shows join the next pass.
|
scheduleSaveFeeds();
|
||||||
runAutoDownload();
|
return updated;
|
||||||
return newFeed;
|
});
|
||||||
|
// Global auto-download: newly subscribed shows join the next pass.
|
||||||
|
runAutoDownload();
|
||||||
|
return newFeed;
|
||||||
|
})(), "Subscribing");
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Download the N most recent episodes of every in-scope show, per the
|
/** Download the N most recent episodes of every in-scope show, per the
|
||||||
@@ -332,78 +506,105 @@ function createFeedStore() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Apply a freshly fetched episode list to one feed, bumping `lastUpdated`
|
/** Apply a freshly fetched episode list to one feed, bumping `lastUpdated`
|
||||||
* only when the content actually changed (see sameEpisodes). Returns the
|
* only when the content actually changed (see sameRefreshWindow). The
|
||||||
* ORIGINAL array reference when nothing changed so callers skip
|
* fetched window is MERGED into the existing episodes (fetched copy wins
|
||||||
* persistence entirely — a refresh that fetched identical episodes must
|
* on id collision) so a refresh never shrinks the in-memory list; the
|
||||||
* not re-sort the "updated" view. */
|
* union is pruned by the user's cache bound (count or date) so episodes
|
||||||
|
* outside the bound fall out of the visible list on the next refresh.
|
||||||
|
* Returns the ORIGINAL array reference when nothing changed so callers
|
||||||
|
* skip persistence entirely — a refresh that fetched identical episodes
|
||||||
|
* must not re-sort the "updated" view. */
|
||||||
const applyRefreshedEpisodes = (
|
const applyRefreshedEpisodes = (
|
||||||
prev: Feed[],
|
prev: Feed[],
|
||||||
feedId: string,
|
feedId: string,
|
||||||
episodes: Episode[],
|
episodes: Episode[],
|
||||||
): Feed[] => {
|
): Feed[] => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
const prefs = useAppStore().state().preferences;
|
||||||
|
const keep = episodeKeepFn(prefs);
|
||||||
const updated = prev.map((f) => {
|
const updated = prev.map((f) => {
|
||||||
if (f.id !== feedId) return f;
|
if (f.id !== feedId) return f;
|
||||||
if (sameEpisodes(f.episodes, episodes)) return f;
|
const merged = mergeEpisodesBounded(f.episodes, episodes, keep);
|
||||||
|
if (sameRefreshWindow(f.episodes, episodes)) return f;
|
||||||
changed = true;
|
changed = true;
|
||||||
return { ...f, episodes, lastUpdated: new Date() };
|
return { ...f, episodes: merged, lastUpdated: new Date() };
|
||||||
});
|
});
|
||||||
return changed ? updated : prev;
|
return changed ? updated : prev;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Refresh a single feed - re-fetch latest 50 episodes */
|
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||||
const refreshFeed = async (feedId: string) => {
|
const refreshFeed = async (feedId: string) => {
|
||||||
const feed = getFeed(feedId);
|
const activity = useActivityStore();
|
||||||
if (!feed) return;
|
return activity.track((async () => {
|
||||||
const episodes = await fetchEpisodes(
|
const feed = getFeed(feedId);
|
||||||
feed.podcast.feedUrl,
|
if (!feed) return;
|
||||||
MAX_EPISODES_REFRESH,
|
const { episodes, coverUrl } = await fetchEpisodes(
|
||||||
feedId,
|
feed.podcast.feedUrl,
|
||||||
);
|
MAX_EPISODES_REFRESH,
|
||||||
// Fetch failed (null): keep the currently loaded episodes untouched.
|
feedId,
|
||||||
if (!episodes) return;
|
);
|
||||||
setFeeds((prev) => {
|
// Fetch failed (null): keep the currently loaded episodes untouched.
|
||||||
const updated = applyRefreshedEpisodes(prev, feedId, episodes);
|
if (!episodes) return;
|
||||||
if (updated !== prev) saveFeeds(updated);
|
setFeeds((prev) => {
|
||||||
return updated;
|
let updated = applyRefreshedEpisodes(prev, feedId, episodes);
|
||||||
});
|
if (coverUrl) {
|
||||||
|
updated = updated.map((f) =>
|
||||||
|
f.id === feedId && !f.podcast.coverUrl && coverUrl
|
||||||
|
? { ...f, podcast: { ...f.podcast, coverUrl } }
|
||||||
|
: f,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (updated !== prev) scheduleSaveFeeds();
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
// Global auto-download: ensure the N most recent episodes of in-scope
|
// Global auto-download: ensure the N most recent episodes of in-scope
|
||||||
// shows are available offline after every refresh (idempotent).
|
// shows are available offline after every refresh (idempotent).
|
||||||
runAutoDownload();
|
runAutoDownload();
|
||||||
|
})(), "Refreshing");
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Refresh all feeds — fetch every feed in parallel, then apply ONE
|
/** Refresh all feeds — bounded concurrency (at most FETCH_CONCURRENCY
|
||||||
* atomic update. Per-feed incremental setFeeds re-sorted the list once
|
* in-flight requests), and each feed's refreshed episodes are applied
|
||||||
* per completion (each refresh bumped lastUpdated and the "updated" sort
|
* AS ITS OWN FETCH LANDS (no Promise.all barrier). Per-feed apply is
|
||||||
* re-ran), which showed up as the list order flapping until the batch
|
* safe because applyRefreshedEpisodes keeps unchanged feeds' object
|
||||||
* finished. */
|
* identity and lastUpdated (union merge), so each feed's refreshed
|
||||||
|
* episodes render as its own fetch resolves — the order flapping the
|
||||||
|
* old atomic barrier existed to hide can no longer happen. */
|
||||||
const refreshAllFeeds = async () => {
|
const refreshAllFeeds = async () => {
|
||||||
setIsLoadingFeeds(true);
|
setIsLoadingFeeds(true);
|
||||||
try {
|
try {
|
||||||
const currentFeeds = feeds();
|
await mapWithConcurrency(
|
||||||
const results = await Promise.all(
|
feeds(),
|
||||||
currentFeeds.map(async (feed) => [
|
FETCH_CONCURRENCY,
|
||||||
feed.id,
|
async (feed) => {
|
||||||
await fetchEpisodes(
|
const { episodes, coverUrl } = await fetchEpisodes(
|
||||||
feed.podcast.feedUrl,
|
feed.podcast.feedUrl,
|
||||||
MAX_EPISODES_REFRESH,
|
MAX_EPISODES_REFRESH,
|
||||||
feed.id,
|
feed.id,
|
||||||
),
|
);
|
||||||
] as const),
|
|
||||||
);
|
|
||||||
setFeeds((prev) => {
|
|
||||||
let updated = prev;
|
|
||||||
for (const [feedId, episodes] of results) {
|
|
||||||
// A failed fetch (null) leaves that feed untouched.
|
// A failed fetch (null) leaves that feed untouched.
|
||||||
if (!episodes) continue;
|
if (!episodes) return;
|
||||||
updated = applyRefreshedEpisodes(updated, feedId, episodes);
|
setFeeds((prev) => {
|
||||||
}
|
let updated = applyRefreshedEpisodes(prev, feed.id, episodes);
|
||||||
if (updated !== prev) saveFeeds(updated);
|
if (coverUrl) {
|
||||||
return updated;
|
updated = updated.map((f) =>
|
||||||
});
|
f.id === feed.id && !f.podcast.coverUrl && coverUrl
|
||||||
|
? { ...f, podcast: { ...f.podcast, coverUrl } }
|
||||||
|
: f,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (updated !== prev) scheduleSaveFeeds();
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
// Global auto-download: one idempotent pass after the batch.
|
// Global auto-download: one idempotent pass after the batch.
|
||||||
runAutoDownload();
|
runAutoDownload();
|
||||||
|
// A refresh batch always ends with a persisted write when
|
||||||
|
// anything changed — never leave the debounce's trailing edge
|
||||||
|
// pending across a process exit.
|
||||||
|
flushPendingSave();
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingFeeds(false);
|
setIsLoadingFeeds(false);
|
||||||
}
|
}
|
||||||
@@ -415,7 +616,11 @@ function createFeedStore() {
|
|||||||
const { promise: feedsReady, resolve: resolveFeedsReady } =
|
const { promise: feedsReady, resolve: resolveFeedsReady } =
|
||||||
Promise.withResolvers<void>();
|
Promise.withResolvers<void>();
|
||||||
(async () => {
|
(async () => {
|
||||||
const loadedFeeds = await loadFeedsFromFile();
|
const loadedFeeds = await loadFeedsFromFile(
|
||||||
|
useAppStore().state().preferences.episodeCacheMode === "date"
|
||||||
|
? Math.max(1, useAppStore().state().preferences.episodeCacheDays)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
||||||
resolveFeedsReady();
|
resolveFeedsReady();
|
||||||
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
||||||
@@ -476,7 +681,10 @@ function createFeedStore() {
|
|||||||
episodeLoadCount.delete(feedId);
|
episodeLoadCount.delete(feedId);
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.filter((f) => f.id !== feedId);
|
const updated = prev.filter((f) => f.id !== feedId);
|
||||||
saveFeeds(updated);
|
// Unsubscribe intent must not sit in the debounce window if the
|
||||||
|
// process exits — persist the removal immediately.
|
||||||
|
scheduleSaveFeeds();
|
||||||
|
flushPendingSave();
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -489,7 +697,10 @@ function createFeedStore() {
|
|||||||
episodeLoadCount.delete(feed.id);
|
episodeLoadCount.delete(feed.id);
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl);
|
const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl);
|
||||||
saveFeeds(updated);
|
// Unsubscribe intent must not sit in the debounce window if
|
||||||
|
// the process exits — persist the removal immediately.
|
||||||
|
scheduleSaveFeeds();
|
||||||
|
flushPendingSave();
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -501,7 +712,7 @@ function createFeedStore() {
|
|||||||
const updated = prev.map((f) =>
|
const updated = prev.map((f) =>
|
||||||
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
|
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
|
||||||
);
|
);
|
||||||
saveFeeds(updated);
|
scheduleSaveFeeds();
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -512,7 +723,7 @@ function createFeedStore() {
|
|||||||
const updated = prev.map((f) =>
|
const updated = prev.map((f) =>
|
||||||
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
|
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
|
||||||
);
|
);
|
||||||
saveFeeds(updated);
|
scheduleSaveFeeds();
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -587,7 +798,12 @@ function createFeedStore() {
|
|||||||
return id ? getFeed(id) : undefined;
|
return id ? getFeed(id) : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Check if a feed has more episodes available beyond what's currently loaded */
|
/** Check if a feed has more episodes available beyond what's currently
|
||||||
|
* loaded. The full parse cache holds ALL episodes (including beyond the
|
||||||
|
* cache bound), so fetch-more can always page deeper — the bound limits
|
||||||
|
* what the Feed/My Shows list shows initially, not what fetch-more can
|
||||||
|
* reach. When the loaded window reaches the cache length, this flips
|
||||||
|
* false. */
|
||||||
const hasMoreEpisodes = (feedId: string): boolean => {
|
const hasMoreEpisodes = (feedId: string): boolean => {
|
||||||
const cached = fullEpisodeCache.get(feedId);
|
const cached = fullEpisodeCache.get(feedId);
|
||||||
if (!cached) return false;
|
if (!cached) return false;
|
||||||
@@ -595,7 +811,13 @@ function createFeedStore() {
|
|||||||
return loaded < cached.length;
|
return loaded < cached.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Load the next chunk of episodes for one feed from the cache.
|
/** Load the next chunk of episodes for one feed from the full parse
|
||||||
|
* cache — VOLATILE only: the episodes surfaced beyond the cache bound
|
||||||
|
* are held in the feed's in-memory episode list (so the user can browse
|
||||||
|
* them) but are NOT written back to fullEpisodeCache (the cache keeps
|
||||||
|
* its original bounded shape; these episodes vanish on the next
|
||||||
|
* refresh or restart). The cache is populated by fetchEpisodes/refresh;
|
||||||
|
* a cold cache (post-restart) triggers a refetch here.
|
||||||
* No global guard — callers own the `isLoadingMore` flag so batches
|
* No global guard — callers own the `isLoadingMore` flag so batches
|
||||||
* (loadMoreAllFeeds) can loop over multiple feeds in one go. */
|
* (loadMoreAllFeeds) can loop over multiple feeds in one go. */
|
||||||
const loadMoreEpisodesForFeed = async (feedId: string) => {
|
const loadMoreEpisodesForFeed = async (feedId: string) => {
|
||||||
@@ -604,18 +826,33 @@ function createFeedStore() {
|
|||||||
|
|
||||||
let cached = fullEpisodeCache.get(feedId);
|
let cached = fullEpisodeCache.get(feedId);
|
||||||
|
|
||||||
// If no cache, re-fetch and parse the full feed
|
// If no cache, re-fetch and parse the full feed (cold path after a
|
||||||
|
// restart). The cache holds the FULL parse — no bound applied here.
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
const response = await fetch(feed.podcast.feedUrl, {
|
try {
|
||||||
headers: {
|
const response = await fetch(feed.podcast.feedUrl, {
|
||||||
"Accept-Encoding": "identity",
|
headers: {
|
||||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
"Accept-Encoding": "identity",
|
||||||
},
|
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||||
});
|
},
|
||||||
if (!response.ok) return;
|
// A hung feed must not stall the load-more path forever —
|
||||||
const xml = await response.text();
|
// mirror fetchEpisodes' per-feed timeout.
|
||||||
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
cached = parsed.episodes;
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const xml = await response.text();
|
||||||
|
cached = await parseEpisodesIncremental(xml, feed.podcast.feedUrl);
|
||||||
|
} catch {
|
||||||
|
// Failed/hung refetch: leave the feed's loaded episodes
|
||||||
|
// untouched rather than throwing out of loadMoreEpisodes.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cold-refetch parse output is unsorted; sort it newest-first.
|
||||||
|
// Yield before the sync sort (the parse already yielded before
|
||||||
|
// this point, but the sort of potentially hundreds of episodes
|
||||||
|
// is its own sync block).
|
||||||
|
await yieldToUI();
|
||||||
|
cached = sortEpisodesReverseChronological(cached);
|
||||||
fullEpisodeCache.set(feedId, cached);
|
fullEpisodeCache.set(feedId, cached);
|
||||||
// Set current load count to match what's already displayed
|
// Set current load count to match what's already displayed
|
||||||
episodeLoadCount.set(feedId, feed.episodes.length);
|
episodeLoadCount.set(feedId, feed.episodes.length);
|
||||||
@@ -629,14 +866,24 @@ function createFeedStore() {
|
|||||||
|
|
||||||
if (newCount <= currentCount) return; // nothing more to load
|
if (newCount <= currentCount) return; // nothing more to load
|
||||||
|
|
||||||
|
// Advance the loaded window — volatile: the episodes beyond the cache
|
||||||
|
// bound are held in feed.episodes (visible) but the cache itself is
|
||||||
|
// NOT extended. episodeLoadCount tracks the volatile window size.
|
||||||
episodeLoadCount.set(feedId, newCount);
|
episodeLoadCount.set(feedId, newCount);
|
||||||
const episodes = cached.slice(0, newCount);
|
const episodes = cached.slice(0, newCount);
|
||||||
|
|
||||||
|
// Yield a real macrotask turn before the sync state update so the
|
||||||
|
// renderer paints the spinner and processes keyboard input before the
|
||||||
|
// (potentially large, per-feed in loadMoreAllFeeds) setFeeds + sort
|
||||||
|
// runs. Without this, the whole body executes in one microtask batch
|
||||||
|
// and the UI freezes through every feed in the batch.
|
||||||
|
await yieldToUI();
|
||||||
|
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.map((f) =>
|
const updated = prev.map((f) =>
|
||||||
f.id === feedId ? { ...f, episodes } : f,
|
f.id === feedId ? { ...f, episodes } : f,
|
||||||
);
|
);
|
||||||
saveFeeds(updated);
|
scheduleSaveFeeds();
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -713,6 +960,7 @@ function createFeedStore() {
|
|||||||
loadMoreEpisodes,
|
loadMoreEpisodes,
|
||||||
loadMoreAllFeeds,
|
loadMoreAllFeeds,
|
||||||
hasMoreAcrossAll,
|
hasMoreAcrossAll,
|
||||||
|
flushPendingSave,
|
||||||
addSource,
|
addSource,
|
||||||
removeSource,
|
removeSource,
|
||||||
toggleSource,
|
toggleSource,
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ export type FetchMoreMode = "manual" | "auto";
|
|||||||
/** Which shows the auto-download setting applies to (default: all). */
|
/** Which shows the auto-download setting applies to (default: all). */
|
||||||
export type AutoDownloadScope = "all" | "none" | "whitelist";
|
export type AutoDownloadScope = "all" | "none" | "whitelist";
|
||||||
|
|
||||||
|
/** How the episode cache (the Feed / My Shows list + the pagination cache)
|
||||||
|
* is bounded: by a rolling date window or by a count of most-recent
|
||||||
|
* episodes (default: date). */
|
||||||
|
export type EpisodeCacheMode = "date" | "count";
|
||||||
|
|
||||||
export type UserPreferences = {
|
export type UserPreferences = {
|
||||||
showExplicit: boolean;
|
showExplicit: boolean;
|
||||||
autoDownload: boolean;
|
autoDownload: boolean;
|
||||||
@@ -111,6 +116,12 @@ export type UserPreferences = {
|
|||||||
fetchMoreMode: FetchMoreMode;
|
fetchMoreMode: FetchMoreMode;
|
||||||
/** Minutes between automatic background feed refreshes (default: 30). */
|
/** Minutes between automatic background feed refreshes (default: 30). */
|
||||||
refreshIntervalMinutes: number;
|
refreshIntervalMinutes: number;
|
||||||
|
/** How the episode list cache is bounded — by date or by count (default: date). */
|
||||||
|
episodeCacheMode: EpisodeCacheMode;
|
||||||
|
/** Number of most-recent episodes to keep when mode is "count" (default: 25). */
|
||||||
|
episodeCacheCount: number;
|
||||||
|
/** Rolling window in days for the episode list when mode is "date" (default: 60). */
|
||||||
|
episodeCacheDays: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppState = {
|
export type AppState = {
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ const defaultPreferences: UserPreferences = {
|
|||||||
autoJumpToPlayer: true,
|
autoJumpToPlayer: true,
|
||||||
fetchMoreMode: "auto",
|
fetchMoreMode: "auto",
|
||||||
refreshIntervalMinutes: 30,
|
refreshIntervalMinutes: 30,
|
||||||
|
episodeCacheMode: "date",
|
||||||
|
episodeCacheCount: 25,
|
||||||
|
episodeCacheDays: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export interface AudioBackend {
|
|||||||
* (mpv `video-add`). Lets play() start without waiting on art; the
|
* (mpv `video-add`). Lets play() start without waiting on art; the
|
||||||
* Now Playing artwork pops in when the download lands.
|
* Now Playing artwork pops in when the download lands.
|
||||||
*/
|
*/
|
||||||
addCoverArt(path: string): Promise<void>;
|
|
||||||
pause(): Promise<void>;
|
pause(): Promise<void>;
|
||||||
resume(): Promise<void>;
|
resume(): Promise<void>;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
@@ -561,12 +560,6 @@ export class MpvBackend implements AudioBackend {
|
|||||||
Math.round((opts?.volume ?? 1) * 100),
|
Math.round((opts?.volume ?? 1) * 100),
|
||||||
]);
|
]);
|
||||||
await this.send(["set_property", "speed", opts?.speed ?? 1]);
|
await this.send(["set_property", "speed", opts?.speed ?? 1]);
|
||||||
if (opts?.coverArtPath) {
|
|
||||||
// File is already loaded: cover-art-files only applies at
|
|
||||||
// load, so add the art as a runtime albumart track instead.
|
|
||||||
await this.send(["set_property", "cover-art-files", opts.coverArtPath]);
|
|
||||||
await this.send(["video-add", opts.coverArtPath]);
|
|
||||||
}
|
|
||||||
if (opts?.mediaTitle) {
|
if (opts?.mediaTitle) {
|
||||||
await this.send(["set_property", "force-media-title", opts.mediaTitle]);
|
await this.send(["set_property", "force-media-title", opts.mediaTitle]);
|
||||||
}
|
}
|
||||||
@@ -591,14 +584,6 @@ export class MpvBackend implements AudioBackend {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async addCoverArt(path: string): Promise<void> {
|
|
||||||
if (!this._loadedUrl) return;
|
|
||||||
// Keep the property pointing at the latest art too, so a subsequent
|
|
||||||
// loadfile of the same episode carries it.
|
|
||||||
await this.send(["set_property", "cover-art-files", path]);
|
|
||||||
await this.send(["video-add", path]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async pause(): Promise<void> {
|
async pause(): Promise<void> {
|
||||||
await this.send(["set_property", "pause", true]);
|
await this.send(["set_property", "pause", true]);
|
||||||
this._intentPlaying = false;
|
this._intentPlaying = false;
|
||||||
@@ -716,7 +701,6 @@ class NoopBackend implements AudioBackend {
|
|||||||
readonly name: BackendName = "none";
|
readonly name: BackendName = "none";
|
||||||
async play(): Promise<void> {}
|
async play(): Promise<void> {}
|
||||||
async preload(): Promise<void> {}
|
async preload(): Promise<void> {}
|
||||||
async addCoverArt(): Promise<void> {}
|
|
||||||
async pause(): Promise<void> {}
|
async pause(): Promise<void> {}
|
||||||
async resume(): Promise<void> {}
|
async resume(): Promise<void> {}
|
||||||
async stop(): Promise<void> {}
|
async stop(): Promise<void> {}
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ export function updateConfig(patch: Partial<PodTuiConfig>): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve once every queued config write has flushed. Tests await this to
|
||||||
|
* observe the serialized result of pending saveFeedsToFile/updateConfig
|
||||||
|
* calls before asserting on config.json. */
|
||||||
|
export function whenConfigIdle(): Promise<void> {
|
||||||
|
return writeChain;
|
||||||
|
}
|
||||||
|
|
||||||
/** Guards so migration runs exactly once per process. */
|
/** Guards so migration runs exactly once per process. */
|
||||||
let migrationDone = false;
|
let migrationDone = false;
|
||||||
let migrationPromise: Promise<void> | null = null;
|
let migrationPromise: Promise<void> | null = null;
|
||||||
|
|||||||
30
src/utils/episode-merge.ts
Normal file
30
src/utils/episode-merge.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Episode } from "../types/episode";
|
||||||
|
|
||||||
|
/** Sort key for an episode's pubDate — missing/invalid dates sort as NEWEST
|
||||||
|
* (Infinity) so undated episodes float to the top instead of dropping into
|
||||||
|
* the oldest slot. */
|
||||||
|
const ts = (ep: Episode): number => {
|
||||||
|
const t = ep.pubDate?.getTime()
|
||||||
|
return t === undefined || Number.isNaN(t) ? Infinity : t
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of two episode lists keyed by id — on collision the fetched copy
|
||||||
|
* wins (fresh metadata). Result is sorted newest-first by pubDate and pruned
|
||||||
|
* by the supplied `keep` predicate: episodes outside the configured cache
|
||||||
|
* bound (date window or count) are dropped. Never mutates either input.
|
||||||
|
*
|
||||||
|
* The caller supplies `keep` so this module stays free of the preference
|
||||||
|
* types — the feed store passes a closure bound to the user's mode/count/days.
|
||||||
|
*/
|
||||||
|
export function mergeEpisodesBounded(
|
||||||
|
existing: Episode[],
|
||||||
|
fetched: Episode[],
|
||||||
|
keep: (ep: Episode, index: number) => boolean,
|
||||||
|
): Episode[] {
|
||||||
|
const byId = new Map<string, Episode>()
|
||||||
|
for (const ep of existing) byId.set(ep.id, ep)
|
||||||
|
for (const ep of fetched) byId.set(ep.id, ep)
|
||||||
|
const sorted = [...byId.values()].sort((a, b) => ts(b) - ts(a))
|
||||||
|
return sorted.filter((ep, i) => keep(ep, i))
|
||||||
|
}
|
||||||
@@ -4,9 +4,70 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { loadConfig, updateConfig } from "./config";
|
import { loadConfig, updateConfig } from "./config";
|
||||||
|
import { getConfigFilePath } from "./config-dir";
|
||||||
|
import { DownloadStatus } from "../types/episode";
|
||||||
|
import type { Episode } from "../types/episode";
|
||||||
import type { Feed } from "../types/feed";
|
import type { Feed } from "../types/feed";
|
||||||
import type { PodcastSource } from "../types/source";
|
import type { PodcastSource } from "../types/source";
|
||||||
|
|
||||||
|
/** Default episode lifecycle window in days — used when no preference is
|
||||||
|
* configured (legacy configs, first launch). The actual bound is the user's
|
||||||
|
* episodeCacheDays preference; this is just the fail-safe default. */
|
||||||
|
export const DEFAULT_EPISODE_WINDOW_DAYS = 60;
|
||||||
|
|
||||||
|
/** True when an episode falls inside a rolling date window of `days` days.
|
||||||
|
* A missing/invalid pubDate is ALWAYS kept (fail-safe: never drop an
|
||||||
|
* undatable episode) — the volatile cache must agree with
|
||||||
|
* episodeIsPersistable so an episode the persistence layer retains can
|
||||||
|
* never be silently pruned from the list. */
|
||||||
|
export function episodeInWindow(
|
||||||
|
ep: Episode,
|
||||||
|
now: Date,
|
||||||
|
days: number = DEFAULT_EPISODE_WINDOW_DAYS,
|
||||||
|
): boolean {
|
||||||
|
const t = ep.pubDate?.getTime();
|
||||||
|
if (!t || Number.isNaN(t)) return true;
|
||||||
|
return t >= now.getTime() - days * 24 * 3600 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when an episode may be persisted: a completed download, or it falls
|
||||||
|
* inside the lifecycle window (undatable episodes always kept). */
|
||||||
|
export function episodeIsPersistable(
|
||||||
|
ep: Episode,
|
||||||
|
downloadedIds: Set<string>,
|
||||||
|
now: Date,
|
||||||
|
days: number = DEFAULT_EPISODE_WINDOW_DAYS,
|
||||||
|
): boolean {
|
||||||
|
return downloadedIds.has(ep.id) || episodeInWindow(ep, now, days);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Episode ids of completed downloads, read from downloads.json. In-flight
|
||||||
|
* downloads are NOT exempted from the retention window — a just-completed
|
||||||
|
* download is re-included by the next save because the in-memory
|
||||||
|
* feed.episodes still holds it. Missing/unreadable/invalid file → empty set. */
|
||||||
|
async function readDownloadedEpisodeIds(): Promise<Set<string>> {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(getConfigFilePath("downloads.json"));
|
||||||
|
if (!(await file.exists())) return new Set();
|
||||||
|
const raw = await file.json();
|
||||||
|
if (!Array.isArray(raw)) return new Set();
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const rec of raw) {
|
||||||
|
if (
|
||||||
|
rec &&
|
||||||
|
typeof rec === "object" &&
|
||||||
|
rec.status === DownloadStatus.COMPLETED &&
|
||||||
|
typeof rec.episodeId === "string"
|
||||||
|
) {
|
||||||
|
ids.add(rec.episodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Deserialize date strings back to Date objects in feed data */
|
/** Deserialize date strings back to Date objects in feed data */
|
||||||
function reviveDates(feed: Feed): Feed {
|
function reviveDates(feed: Feed): Feed {
|
||||||
return {
|
return {
|
||||||
@@ -22,23 +83,57 @@ function reviveDates(feed: Feed): Feed {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
/** Load feeds from config.json, pruning episodes outside the retention
|
||||||
/** Load feeds from config.json */
|
* window (completed downloads always kept). When anything was pruned, the
|
||||||
export async function loadFeedsFromFile(): Promise<Feed[]> {
|
* pruned list is rewritten to config.json (startup cleanup for legacy
|
||||||
|
* configs). The read path is awaited so the returned value is deterministic. */
|
||||||
|
export async function loadFeedsFromFile(
|
||||||
|
windowDays?: number,
|
||||||
|
): Promise<Feed[]> {
|
||||||
try {
|
try {
|
||||||
const cfg = await loadConfig();
|
const cfg = await loadConfig();
|
||||||
if (!Array.isArray(cfg.feeds)) return [];
|
if (!Array.isArray(cfg.feeds)) return [];
|
||||||
return cfg.feeds.map(reviveDates);
|
const feeds = cfg.feeds.map(reviveDates);
|
||||||
|
const downloadedIds = await readDownloadedEpisodeIds();
|
||||||
|
const now = new Date();
|
||||||
|
let prunedAny = false;
|
||||||
|
const pruned = feeds.map((f) => {
|
||||||
|
const kept = f.episodes.filter((ep) =>
|
||||||
|
episodeIsPersistable(ep, downloadedIds, now, windowDays),
|
||||||
|
);
|
||||||
|
if (kept.length !== f.episodes.length) prunedAny = true;
|
||||||
|
return { ...f, episodes: kept };
|
||||||
|
});
|
||||||
|
if (prunedAny) {
|
||||||
|
// Fire-and-forget cleanup rewrite of the legacy config.
|
||||||
|
saveFeedsToFile(pruned, windowDays);
|
||||||
|
}
|
||||||
|
return pruned;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save feeds to config.json */
|
/** Save feeds to config.json, pruning episodes outside the retention window
|
||||||
export function saveFeedsToFile(feeds: Feed[]): void {
|
* (completed downloads always kept). Fire-and-forget: the prune reads
|
||||||
updateConfig({ feeds });
|
* downloads.json asynchronously, then enqueues the write. On any error the
|
||||||
|
* UNPRUNED feeds are saved instead, so data is never lost. */
|
||||||
|
export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const downloadedIds = await readDownloadedEpisodeIds();
|
||||||
|
const pruned = feeds.map((f) => ({
|
||||||
|
...f,
|
||||||
|
episodes: f.episodes.filter((ep) =>
|
||||||
|
episodeIsPersistable(ep, downloadedIds, new Date(), windowDays),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
updateConfig({ feeds: pruned });
|
||||||
|
} catch {
|
||||||
|
updateConfig({ feeds }); /* never lose data on an error path */
|
||||||
|
}
|
||||||
|
})().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load sources from config.json */
|
/** Load sources from config.json */
|
||||||
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
||||||
try {
|
try {
|
||||||
@@ -49,7 +144,6 @@ export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save sources to config.json */
|
/** Save sources to config.json */
|
||||||
export function saveSourcesToFile<T>(sources: T[]): void {
|
export function saveSourcesToFile<T>(sources: T[]): void {
|
||||||
updateConfig({ sources: sources as unknown as PodcastSource[] });
|
updateConfig({ sources: sources as unknown as PodcastSource[] });
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 01. Persist only a 30-day episode window, keep downloaded episodes, clean up stale data
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: bounded-feed-lifecycle-01
|
||||||
|
feature: bounded-feed-lifecycle
|
||||||
|
priority: P1
|
||||||
|
depends_on: []
|
||||||
|
tags: [implementation, tests-required]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
|
||||||
|
- Bound what the app writes to `config.json`: each persisted feed keeps only episodes published within the last 30 days, plus any episode whose download is completed — everything older lives in volatile memory only (wired up in task 02). Loading an over-window legacy config must prune it automatically (cleanup on first launch).
|
||||||
|
|
||||||
|
background (read this before touching code):
|
||||||
|
|
||||||
|
- Feeds persist through `src/utils/feeds-persistence.ts`. `saveFeedsToFile(feeds)` is a fire-and-forget wrapper around `updateConfig({ feeds })` in `src/utils/config.ts`, which read-modify-writes the whole `config.json` behind a serialized promise chain (`writeChain`).
|
||||||
|
- Today `saveFeedsToFile` writes every loaded episode, so `config.json` grows forever (the Feed page's "Fetch More" keeps expanding `feed.episodes` and saving).
|
||||||
|
- Downloads persist separately in `downloads.json` (same config dir, see `src/utils/config-dir.ts` `getConfigFilePath("downloads.json")`). Each record has `episodeId`, `status`, `feedId`, etc. The `DownloadStatus` enum lives in `src/types/episode.ts` — read it there for the completed member's string value; do NOT hardcode a guessed string.
|
||||||
|
- `src/stores/feed.ts` calls `saveFeedsToFile` from a module-scope `saveFeeds()` helper. Callers must not change in this task.
|
||||||
|
- Style: match the file you edit. `feeds-persistence.ts` and `config.ts` are tab-indented WITH semicolons (some other repo files aren't — don't "fix" that anywhere).
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
|
||||||
|
- `src/utils/feeds-persistence.ts`:
|
||||||
|
- New exported constant `EPISODE_WINDOW_DAYS = 30` — the lifecycle window: bounds BOTH persistence (here) and the volatile episode list/cache (task 02).
|
||||||
|
- New exported pure function `episodeInWindow(ep: Episode, now: Date): boolean` — returns `true` when `ep.pubDate` is missing/not a valid `Date` (fail-safe: never drop an undatable episode) OR `ep.pubDate.getTime() >= now.getTime() - EPISODE_WINDOW_DAYS * 24 * 3600 * 1000`.
|
||||||
|
- New exported pure function `episodeIsPersistable(ep: Episode, downloadedIds: Set<string>, now: Date): boolean` — returns `true` when `downloadedIds.has(ep.id)` OR `episodeInWindow(ep, now)`.
|
||||||
|
- New (module-private) async helper `readDownloadedEpisodeIds(): Promise<Set<string>>` — reads `getConfigFilePath("downloads.json")` with `Bun.file`, returns the `episodeId`s of records whose `status` equals `DownloadStatus.COMPLETED`; returns an empty set on any error or missing file. Note: an episode whose download is merely in-flight is NOT exempted; it will be re-included by the next save after completion, since the in-memory `feed.episodes` still holds it — document this in the function comment.
|
||||||
|
- `saveFeedsToFile(feeds: Feed[])` — before calling `updateConfig`, map each feed to `{ ...feed, episodes: feed.episodes.filter(ep => episodeIsPersistable(ep, downloadedIds, new Date())) }`. The downloaded-ids lookup is async, so wrap the whole body in a fire-and-forget async IIFE (`.catch(() => {})`) that preserves the existing sync/fire-and-forget signature; on any lookup failure, save the feeds unpruned (never lose data on an error path).
|
||||||
|
- `loadFeedsFromFile()` — after `reviveDates`, apply the same prune to the loaded feeds; if the prune removed at least one episode, call `saveFeedsToFile(pruned)` to rewrite `config.json` (this is the startup cleanup for legacy configs). `await` the prune path deterministically (the function is already async).
|
||||||
|
- `src/utils/config.ts`:
|
||||||
|
- New exported `whenConfigIdle(): Promise<void>` returning the module-internal `writeChain` promise. Tests need a way to await pending serialized writes; today `updateConfig` hides the chain and tests cannot observe when a write lands.
|
||||||
|
- `tests/feed-retention.test.ts` (new) — see tests section.
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
1. Read `src/types/episode.ts` to confirm `DownloadStatus.COMPLETED`'s runtime value and the `Episode` shape (`id`, `pubDate`).
|
||||||
|
2. Read `src/utils/feeds-persistence.ts` and `src/utils/config.ts` fully (they are short).
|
||||||
|
3. Add `whenConfigIdle()` to `config.ts` next to `updateConfig`.
|
||||||
|
4. In `feeds-persistence.ts`: add imports (`getConfigFilePath` from `./config-dir`, `DownloadStatus` and `type Episode` from `../types/episode`), the constant, `episodeIsPersistable`, `readDownloadedEpisodeIds`, then rework `saveFeedsToFile` and `loadFeedsFromFile` per deliverables. Keep `reviveDates` untouched.
|
||||||
|
5. Ensure `saveFeeds` in `src/stores/feed.ts` still compiles unchanged (signature-compatible).
|
||||||
|
6. Write `tests/feed-retention.test.ts`, run it, then run the full suite and lint.
|
||||||
|
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- Conventions (copy them): `tests/feed-refresh.test.ts` shows the harness — `mkdtempSync` into `process.env.XDG_CONFIG_HOME` **before** importing anything under test (module-level init reads the config dir), `rmSync` in `afterAll`, tabs/no-semicolon style not required but match repo.
|
||||||
|
- New `tests/feed-retention.test.ts`:
|
||||||
|
- Unit (Arrange–Act–Assert) for `episodeIsPersistable`:
|
||||||
|
- episode 40 days old, not downloaded → `false`.
|
||||||
|
- episode 40 days old, id in `downloadedIds` → `true`.
|
||||||
|
- episode 5 days old → `true`.
|
||||||
|
- episode with `pubDate: new Date(NaN)` → `true` (fail-safe).
|
||||||
|
- Save-path integration:
|
||||||
|
- Arrange: write a `downloads.json` in the temp config dir containing one `completed` record for `old-downloaded-id` (include all fields the loader reads in `src/stores/download.ts`'s `DownloadRecord`: at minimum `episodeId`, `feedId`, `status`, `filePath: null`, `downloadedAt: null`, `fileSize: 0`, `error: null`, `audioUrl: ""`, `episodeTitle: ""`).
|
||||||
|
- Act: call `saveFeedsToFile([feed])` where the feed has three episodes — recent, old-not-downloaded (`id: "old-plain-id"`), old-downloaded (`id: "old-downloaded-id"`). Await `whenConfigIdle()` (plus one more microtask/`await Promise.resolve()` round if the async IIFE resolves after the chain call — flush both).
|
||||||
|
- Assert: parse `config.json` raw; the feed's persisted `episodes` contain the recent and `old-downloaded-id` episodes and NOT `old-plain-id`.
|
||||||
|
- Load-path cleanup:
|
||||||
|
- Arrange: seed `config.json` (write it directly with `Bun.write`) with one feed holding only over-window episodes; no `downloads.json`.
|
||||||
|
- Act: `await loadFeedsFromFile()`, then `await whenConfigIdle()`.
|
||||||
|
- Assert: returned feed has zero episodes AND re-reading `config.json` shows the episodes pruned (cleanup rewrite happened).
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
|
||||||
|
- `saveFeedsToFile` never writes an episode older than 30 days unless its id is a completed download in `downloads.json`.
|
||||||
|
- `loadFeedsFromFile` prunes over-window episodes from legacy configs and rewrites `config.json` when it pruned anything.
|
||||||
|
- Undatable episodes (`pubDate` missing/invalid) are always persisted.
|
||||||
|
- No call site of `saveFeedsToFile`/`loadFeedsFromFile` needed to change (compatible signatures).
|
||||||
|
- `bun test tests/feed-retention.test.ts` passes; the existing `bun test` suite passes; `bun run lint` is clean.
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
- `bun test tests/feed-retention.test.ts`
|
||||||
|
- `bun test` (full suite — watch `feed-refresh`/`feed-pagination` for regressions)
|
||||||
|
- `bun run lint`
|
||||||
|
- Manual smoke (optional): `bun start`, subscribe to any feed, quit, then `cat ~/.config/podtui/config.json | python3 -c "import sys,json; print(max(e['pubDate'] for f in json.load(sys.stdin)['feeds'] for e in f['episodes']))"` and confirm no persisted episode is older than 30 days.
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- `updateConfig` captures the patched data eagerly at call time (`JSON.parse(JSON.stringify(patch))`), so pruning in `saveFeedsToFile` before the `updateConfig` call is exactly where the filter must live — filtering later would be silently ineffective for already-queued writes.
|
||||||
|
- This task intentionally does NOT change in-memory behavior, refresh merging, or cache bounds — that is task 02. If both are worked on in parallel, 02 imports nothing from 01 except the documented window semantics; the module-level contract above is the seam.
|
||||||
|
- `downloads.json` is written by `src/stores/download.ts` (`saveDownloads`); reading it directly here avoids a store→module import cycle (download.ts already imports the feed store).
|
||||||
85
tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md
Normal file
85
tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# 02. Merge refreshes against the volatile in-memory episode window with a date-windowed cache
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: bounded-feed-lifecycle-02
|
||||||
|
feature: bounded-feed-lifecycle
|
||||||
|
priority: P2
|
||||||
|
depends_on: [bounded-feed-lifecycle-01]
|
||||||
|
tags: [implementation, tests-required]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
|
||||||
|
- Refreshing a feed must UNION the freshly fetched latest window with the episodes already in memory (instead of replacing), so episodes that task 01 pruned from disk — or deep episodes pulled in via "Fetch More" — survive refreshes within a session. Bound in-memory retention by the SAME date window persistence uses (`EPISODE_WINDOW_DAYS`, 30 days) instead of an episode count: the visible list and the pagination cache hold every episode from the last 30 days, and episodes older than that age out of the list on the next refresh (`fullEpisodeCache` currently holds every parsed episode of every feed ever fetched).
|
||||||
|
|
||||||
|
background (read this before touching code):
|
||||||
|
|
||||||
|
- All work lands in `src/stores/feed.ts` plus one new pure-utils module. Current behavior to change:
|
||||||
|
- `fetchEpisodes(feedUrl, limit, feedId?)` parses the whole feed, stores ALL episodes in the module-level `fullEpisodeCache` Map, returns the first `limit`.
|
||||||
|
- `refreshFeed` / `refreshAllFeeds` pass the fetched window through `applyRefreshedEpisodes`, which REPLACES `feed.episodes` when ids differ (`sameEpisodes` id-set compare; unchanged → keep object identity and skip save — this order-stability contract is pinned by `tests/feed-refresh.test.ts` and must keep passing).
|
||||||
|
- `loadMoreEpisodesForFeed` grows the displayed window from `fullEpisodeCache` (fetching+parsing the full feed when the cache is cold — e.g. after a restart), tracking progress in `episodeLoadCount`.
|
||||||
|
- Task 01 made persistence prune everything over 30 days old (except completed downloads). After a restart, `feed.episodes` therefore only contains the 30-day persisted window; the full cached episode list is rebuilt lazily by the first fetch-more or refresh within the new session. This task makes the session-time behavior correct: fetched refreshes merge (never replace), and the volatile list + cache are bounded by the SAME 30-day window persistence uses — what can be browsed is exactly what can be persisted, and episodes older than the window age out on refresh.
|
||||||
|
- Style: `feed.ts` is tab-indented WITH semicolons. New utils file: match `src/api/rss-parser.ts` style (2-space, no semicolons).
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
|
||||||
|
- `src/utils/feeds-persistence.ts` (the canonical window owner):
|
||||||
|
- Rename the retention constant to `EPISODE_WINDOW_DAYS = 30` — it now bounds the volatile cache/list as well as persistence.
|
||||||
|
- New exported `episodeInWindow(ep: Episode, now: Date): boolean` — `pubDate >= now - EPISODE_WINDOW_DAYS`; missing/invalid pubDates are ALWAYS kept (fail-safe mirror of the persistence rule, so cache and disk can never disagree about an undatable episode). `episodeIsPersistable` becomes `downloadedIds.has(ep.id) || episodeInWindow(ep, now)`.
|
||||||
|
- Rework `src/utils/episode-merge.ts` (pure, store-free, unit-testable):
|
||||||
|
- `mergeEpisodesInWindow(existing: Episode[], fetched: Episode[], now: Date): Episode[]` — union by `ep.id`; on id collision the `fetched` copy wins (fresh metadata); result sorted by `pubDate` descending; pruned to the lifecycle window via `episodeInWindow` (out-of-window episodes dropped, undated kept). No count cap — the bound is the date.
|
||||||
|
- Invariants: never mutates inputs; stable output for `existing=[]`; entries with invalid `pubDate` sort as newest (use `getTime()`, treat `NaN` as `+Infinity` with a small `ts()` helper).
|
||||||
|
- `src/stores/feed.ts`:
|
||||||
|
- Delete `MAX_EPISODES_IN_MEMORY` — no episode-count bound anywhere.
|
||||||
|
- `fetchEpisodes`: window-filter the parsed feed (`allEpisodes.filter(ep => episodeInWindow(ep, new Date()))`) BEFORE caching and returning: `fullEpisodeCache.set(feedId, windowed)` and `episodes: windowed.slice(0, limit)`. The limit is a page size; the window is the bound.
|
||||||
|
- `applyRefreshedEpisodes(prev, feedId, episodes)`: replace the `sameEpisodes` replace-with-fetched logic with merge semantics:
|
||||||
|
- Compute `merged = mergeEpisodesInWindow(f.episodes, episodes, new Date())`.
|
||||||
|
- Unchanged detection must compare the FETCHED window against the corresponding prefix of the existing list, i.e. keep a small `sameRefreshWindow(existing: Episode[], fetched: Episode[])` helper next to (and replacing the use of) `sameEpisodes`: `fetched.length === 0 → true`; otherwise compare id-sets of `fetched` and `existing.slice(0, fetched.length)`. Rationale: with union semantics `merged` legitimately contains episodes beyond the fetched window, so comparing full lists would bump `lastUpdated` on every refresh and resurrect the order-flapping bug `tests/feed-refresh.test.ts` guards.
|
||||||
|
- Return unmodified `prev` when every feed's window is unchanged (preserve the existing identity-no-save contract); on change, set `{ ...f, episodes: merged, lastUpdated: new Date() }`.
|
||||||
|
- Delete the now-unused `sameEpisodes` if nothing else references it (grep first: `grep sameEpisodes src tests`).
|
||||||
|
- `loadMoreEpisodesForFeed`: window-filter the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before filtering); everything else (window growth by `MAX_EPISODES_REFRESH`, `hasMoreEpisodes` comparing `episodeLoadCount < cached.length`) works unchanged against the filtered cache.
|
||||||
|
- `tests/feed-volatile-merge.test.ts` (reworked) — see tests section.
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
1. Read `src/stores/feed.ts` fully and `tests/feed-refresh.test.ts` + `tests/feed-pagination.test.ts` (they pin the contracts you must not break; reuse their harness).
|
||||||
|
2. Rework `src/utils/episode-merge.ts` to `mergeEpisodesInWindow`; add `episodeInWindow` (and rename `PERSISTED_WINDOW_DAYS` → `EPISODE_WINDOW_DAYS`) in `feeds-persistence.ts`.
|
||||||
|
3. Integrate in `feed.ts`: replace `sameEpisodes` usage with `sameRefreshWindow` + `mergeEpisodesInWindow` in `applyRefreshedEpisodes`; window-filter `fullEpisodeCache` writes and the returned window in `fetchEpisodes` and `loadMoreEpisodesForFeed`; delete `MAX_EPISODES_IN_MEMORY`.
|
||||||
|
4. Run the existing feed tests — all must pass unchanged (merge must keep order stability and pagination intact).
|
||||||
|
5. Write the new tests, run, then full suite + lint.
|
||||||
|
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- New `tests/feed-volatile-merge.test.ts`:
|
||||||
|
- Pure unit (Arrange–Act–Assert) for `mergeEpisodesInWindow(existing, fetched, now)`:
|
||||||
|
- dedupe on collision, fetched copy wins (mutate title in the fetched twin, assert the merged entry shows the new title).
|
||||||
|
- union of disjoint lists sorted by `pubDate` desc.
|
||||||
|
- window prune drops out-of-window episodes from BOTH inputs and keeps undated (NaN pubDate) episodes.
|
||||||
|
- input arrays not mutated.
|
||||||
|
- Store integration (harness per `tests/feed-refresh.test.ts`: temp `XDG_CONFIG_HOME` BEFORE imports, `Bun.serve` on port 0 serving generated RSS, fake timers):
|
||||||
|
- Refresh-keeps-volatile-window: serve 3 episodes at t0, `addFeed`; then serve the same 3 plus 2 new ones, `refreshFeed`. Assert `feed.episodes.length === 5` AND `lastUpdated` advanced AND a second identical refresh leaves `lastUpdated` untouched (window-compare, not union-compare).
|
||||||
|
- Boundary: a 25-day-old episode loads; a 31-day-old episode is neither visible nor cached.
|
||||||
|
- Out-of-window never cached: 600 items at 2h spacing span ~50 days — only the in-window tail is loadable (fewer than the old 500 cap), `hasMoreEpisodes` flips false there.
|
||||||
|
- No count ceiling: 600 items at 1h spacing (all within 25 days) are ALL loadable — the bound is the date, not a number.
|
||||||
|
- Clock constraint: these tests run under fake timers, and a large `vi.advanceTimersByTime` (past ~5 days of fake time) makes Bun 1.3.8 hang every subsequent network fetch — the boundary is pinned with relative pubDates, never by moving the clock across it.
|
||||||
|
- Existing suites that must keep passing: `tests/feed-refresh.test.ts`, `tests/feed-pagination.test.ts`, `tests/feed-refresh-spinner.test.tsx`.
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
|
||||||
|
- A refresh never removes an episode that was visible before the refresh during the same session — except episodes that aged past the window, which drop out on refresh (the date bound).
|
||||||
|
- An unchanged refresh does not bump `lastUpdated` (object identity of the feed is preserved).
|
||||||
|
- Per-feed cached/parsed episodes are exactly the in-window set: nothing outside the last `EPISODE_WINDOW_DAYS` days is cached or loadable, and everything inside is (no count ceiling).
|
||||||
|
- After a simulated restart (fresh store boot from a pruned config), fetch-more re-parses the feed and applies the same window to the cache.
|
||||||
|
- `bun test` full suite passes; `bun run lint` clean.
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
- `bun test tests/feed-volatile-merge.test.ts tests/feed-refresh.test.ts tests/feed-pagination.test.ts`
|
||||||
|
- `bun test`
|
||||||
|
- `bun run lint`
|
||||||
|
- Manual smoke: `bun start`, drill a show in My Shows, fetch-more a few pages, press `r` to refresh — the in-window pages stay; quit and relaunch — the list holds only the 30-day window, and fetch-more re-parses the feed with the same window applied.
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- Depends on task 01 only conceptually: without the persisted-window prune, this merge is still correct but harder to observe. If 01 isn't merged yet, the store tests still pass; the "restart keeps only 30 days" manual check requires 01.
|
||||||
|
- `fullEpisodeCache`/`episodeLoadCount` are module-level Maps in `feed.ts` — the window filter belongs at the two write sites named in deliverables, not in a wrapper.
|
||||||
|
- Do not touch persistence writes in this task; debounced save behavior is task 03. Keep calling the module-scope `saveFeeds(updated)` helper exactly as today.
|
||||||
84
tasks/bounded-feed-lifecycle/03-nonblocking-feed-refresh.md
Normal file
84
tasks/bounded-feed-lifecycle/03-nonblocking-feed-refresh.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# 03. Make refresh/fetch-more/persistence nonblocking — bounded fetch concurrency, incremental per-feed apply, debounced saves
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: bounded-feed-lifecycle-03
|
||||||
|
feature: bounded-feed-lifecycle
|
||||||
|
priority: P1
|
||||||
|
depends_on: [bounded-feed-lifecycle-01, bounded-feed-lifecycle-02]
|
||||||
|
tags: [implementation, tests-required]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
|
||||||
|
- Feed loading must never block or stall the UI: refresh results render as each feed lands instead of after a `Promise.all` barrier, fetch concurrency is capped so 50 subscriptions don't fire 50 simultaneous requests, and `config.json` writes (full file read-modify-write on every change today) collapse into one debounced trailing write per settle window.
|
||||||
|
|
||||||
|
background (read this before touching code):
|
||||||
|
|
||||||
|
- Work lands in `src/stores/feed.ts` only (plus its tests). Current posture:
|
||||||
|
- `refreshAllFeeds()` fires `fetchEpisodes` for every feed at once via `Promise.all` and applies results in ONE `setFeeds` at the end — the user sees nothing until the slowest feed resolves or hits `FETCH_TIMEOUT_MS` (20s).
|
||||||
|
- `parseEpisodesIncremental` already chunks XML parsing and yields to the event loop via MessageChannel — keep that mechanism untouched; the blocking/stall risk today is the fetch barrier and the save path.
|
||||||
|
- `loadMoreEpisodesForFeed`'s cold-cache refetch has NO timeout (copy the `AbortSignal.timeout(FETCH_TIMEOUT_MS)` pattern from `fetchEpisodes`).
|
||||||
|
- Persistence: `saveFeeds(updated)` → `saveFeedsToFile` → `updateConfig`, a serialized full-file read-`JSON.parse`-stringify-`Bun.write` chain in `src/utils/config.ts`. Called from `refreshFeed`, `refreshAllFeeds`, `loadMoreEpisodesForFeed`, `addFeed`, `removeFeed*`, `updateFeed`, `togglePinned`.
|
||||||
|
- The boot IIFE calls `refreshAllFeeds()` right after `loadFeedsFromFile()` — this is the cold-start refresh users currently feel; first paint already happens because module init is async, but nothing renders per-feed until the barrier resolves.
|
||||||
|
- Single feed `refreshFeed` applies its own `setFeeds` immediately — reuse exactly that shape (fetch → apply-if-changed → mark save dirty) for the incremental batch path.
|
||||||
|
- Tasks 01+02 must be merged first: this task debounces the pruned save path (01) and applies per-feed results through `applyRefreshedEpisodes`/`mergeEpisodes` (02).
|
||||||
|
- Style: tab-indented WITH semicolons, JSDoc comments on non-obvious functions, section dividers `// ── Name ──…` per repo convention.
|
||||||
|
- Tests here use `vi.useFakeTimers()` — `setTimeout`-based debounce must therefore be advanced with `vi.advanceTimersByTime` in tests; don't use `queueMicrotask`-style scheduling for the debounce.
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
|
||||||
|
- `src/stores/feed.ts`:
|
||||||
|
- New constant `FETCH_CONCURRENCY = 4` (comment: bounds simultaneous RSS requests; a hung feed burns at most one slot for `FETCH_TIMEOUT_MS`).
|
||||||
|
- New module-level async helper `mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>` — classic worker-pool: `limit` workers pulling indexes from a shared counter, results in input order. Pure and generic enough to unit-test.
|
||||||
|
- Rewritten `refreshAllFeeds()`:
|
||||||
|
- `setIsLoadingFeeds(true)` … `finally setIsLoadingFeeds(false)` as today.
|
||||||
|
- Process feeds through `mapWithConcurrency(feeds(), FETCH_CONCURRENCY, async (feed) => ...)`.
|
||||||
|
- Inside the per-feed callback: `fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feed.id)`; if non-null, immediately `setFeeds(prev => { const updated = applyRefreshedEpisodes(prev, feed.id, episodes); if (updated !== prev) scheduleSaveFeeds(); return updated; })`. Failed feeds (null) stay untouched, as today.
|
||||||
|
- After all workers settle: ONE `runAutoDownload()` (as today), and `flushPendingSave()` (below) so a refresh batch always ends with a persisted write when anything changed.
|
||||||
|
- Debounced save plumbing (module scope, replacing direct calls):
|
||||||
|
- `let pendingSaveTimer: ReturnType<typeof setTimeout> | null = null; const SAVE_DEBOUNCE_MS = 250;`
|
||||||
|
- `scheduleSaveFeeds()` — after a state-changing update, mark dirty: set a `savePending = true` flag and (re)arm the trailing timer to fire `flushPendingSave()`.
|
||||||
|
- `flushPendingSave()` — if `savePending`, snapshot `feeds()`, call `saveFeeds(snapshot)`, clear flag/timer. Export it on the store's returned object (tests need it; also lets task 04/a future quit hook force a write).
|
||||||
|
- Convert ALL direct `saveFeeds(updated)` / `saveFeeds(newList)` call sites inside `setFeeds` callbacks to `scheduleSaveFeeds()` EXCEPT `removeFeed`/`removeFeedByUrl`, which must call both `scheduleSaveFeeds()` AND `flushPendingSave()` (an unsubscribe intent should not sit unsaved through the debounce window if the process exits). Keep the change mechanical: same call sites, new indirection.
|
||||||
|
- `loadMoreEpisodesForFeed`: add `signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)` to the cold refetch and return early on non-OK/throw (wrap in try/catch mirroring `fetchEpisodes`).
|
||||||
|
- `tests/feed-nonblocking.test.ts` (new) — see tests section.
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
1. Read `src/stores/feed.ts` and confirm tasks 01/02 are merged (`episodeIsPersistable` in `src/utils/feeds-persistence.ts`, `mergeEpisodes` in `src/utils/episode-merge.ts`).
|
||||||
|
2. Add `FETCH_CONCURRENCY`, `mapWithConcurrency`, and the debounce plumbing.
|
||||||
|
3. Rewrite `refreshAllFeeds` per deliverables; convert the save call sites.
|
||||||
|
4. Add the fetch timeout to `loadMoreEpisodesForFeed`'s cold refetch.
|
||||||
|
5. Export `flushPendingSave` from the store's return object (Actions section).
|
||||||
|
6. Write `tests/feed-nonblocking.test.ts`; run new + existing feed tests; full suite; lint.
|
||||||
|
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- Harness conventions: copy `tests/feed-refresh.test.ts` (temp `XDG_CONFIG_HOME` BEFORE store imports; `Bun.serve` port 0; `vi.useFakeTimers()` in `beforeEach`). Note `vi.advanceTimersByTime(...)` also drives the debounce timer and the MessageChannel yields used by the parser are real task-queue turns (safe under fake timers per the comment on `yieldToUI`).
|
||||||
|
- New `tests/feed-nonblocking.test.ts`:
|
||||||
|
- Concurrency bound: server records concurrent in-flight requests (increment on entry, `await new Promise(r => setTimeout(r, 50_000))` under fake-timer awareness: use a gate promise the test controls instead of real sleeps — release gates with `vi.advanceTimersByTime` after asserting). Register 10 feeds; start `refreshAllFeeds()` (don't await); assert the server's max-concurrent counter never exceeded 4; release all gates and await completion.
|
||||||
|
- Incremental apply: 2 feeds — one served instantly, one gated. Start refresh; resolve the fast gate only; assert the fast feed's `lastUpdated`/episodes already updated in `feeds()` BEFORE the slow feed resolves (this is the acceptance proof the `Promise.all` barrier is gone). Then release the slow gate and assert both applied.
|
||||||
|
- Debounce: mock-observe writes by seeding the temp config dir and spawning two rapid refreshes whose content changed; `await` both, then `vi.advanceTimersByTime(SAVE_DEBOUNCE_MS)`; read raw `config.json` ONCE — assert both new episodes are present in a single coherent write. (Counting writes precisely is brittle against `updateConfig`'s chain; asserting final content + that the pre-debounce file lacks the episodes is the binary check: before advancing the debounce, `config.json` must NOT yet contain the new episodes; after, it must.)
|
||||||
|
- `flushPendingSave`: refresh with changed content, call `store.flushPendingSave()` without advancing timers, assert `config.json` already contains the new episode.
|
||||||
|
- Existing suites must pass unchanged: `feed-refresh.test.ts`, `feed-pagination.test.ts`, `feed-volatile-merge.test.ts`, `feed-refresh-spinner.test.tsx`, `restore-session.test.ts`.
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
|
||||||
|
- During a refresh batch, no more than `FETCH_CONCURRENCY` HTTP requests are ever in flight.
|
||||||
|
- Each feed's refreshed episodes are visible in `feeds()` as soon as its own fetch resolves — no waiting for the slowest feed.
|
||||||
|
- Writes to `config.json` are trailing-edge debounced: rapid successive updates produce one final write after the settle window, and `flushPendingSave()` persists immediately.
|
||||||
|
- `loadMoreEpisodesForFeed`'s refetch aborts at `FETCH_TIMEOUT_MS` instead of hanging forever.
|
||||||
|
- `bun test` full suite passes; `bun run lint` clean.
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
- `bun test tests/feed-nonblocking.test.ts tests/feed-refresh.test.ts tests/feed-pagination.test.ts tests/feed-volatile-merge.test.ts`
|
||||||
|
- `bun test`
|
||||||
|
- `bun run lint`
|
||||||
|
- Manual smoke: `bun start` with several subscriptions; hold `j` during the startup refresh — selection moves smoothly and per-feed results appear as they land; quit/relaunch and confirm the last refresh's episodes persisted.
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- The background refresh timer (`scheduleNextRefresh`) already skips ticks while `isLoadingFeeds()` is true — unchanged.
|
||||||
|
- Do not introduce a real "sleep" anywhere in tests; gates + fake timers only, matching existing suites.
|
||||||
|
- `mapWithConcurrency` is generic; keep it module-private in `feed.ts` (no premature new util file).
|
||||||
|
- `updateConfig` snapshots its patch at call time (`JSON.parse(JSON.stringify(patch))`), so debouncing by delaying the `saveFeeds` CALL is correct — a pending write always serializes the latest feeds it was handed.
|
||||||
77
tasks/bounded-feed-lifecycle/04-global-activity-indicator.md
Normal file
77
tasks/bounded-feed-lifecycle/04-global-activity-indicator.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 04. Add a shared activity store and global top-right loading indicator
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: bounded-feed-lifecycle-04
|
||||||
|
feature: bounded-feed-lifecycle
|
||||||
|
priority: P2
|
||||||
|
depends_on: [bounded-feed-lifecycle-03]
|
||||||
|
tags: [implementation, tests-required]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
|
||||||
|
- One global indicator, always in the top-right corner of the app, visible whenever ANYTHING is being loaded or downloaded: feed refreshes (all-feeds and single-feed), fetch-more, subscribe fetches, searches, and episode downloads. Per-page spinners stay as-is; this adds the global signal that activity is happening anywhere.
|
||||||
|
|
||||||
|
background (read this before touching code):
|
||||||
|
|
||||||
|
- `src/components/Shell.tsx` renders the whole chrome: one full-width content row (`LayerGraph[nav.activeTab()]()` / `PaneRow`) plus a bottom status/command bar. There is no header row — the top-right corner belongs to whatever page is active, so the indicator must be an ABSOLUTE-POSITIONED overlay drawn after the content so it paints on top (opentui `box` supports `position="absolute"`, `top`, `right`).
|
||||||
|
- Existing activity signals (read them, don't recreate per-store bookkeeping): `useFeedStore().isLoadingFeeds()` / `.isLoadingMore()`; `useSearchStore().isSearching()` (`src/stores/search.ts`); `useDownloadStore().getActiveCount()` and `.getQueue().length` (`src/stores/download.ts`). Gaps these don't cover: single `refreshFeed`, `addFeed`'s subscribe fetch, iTunes feed resolution inside `addFeed` — hence the activity store.
|
||||||
|
- `src/components/LoadingIndicator.tsx` is the braille spinner (prop `label?: string`); reuse it inside the overlay.
|
||||||
|
- Activity tracking must be leak-proof: every `begin` paired with an `end` via a token, PLUS a `track(promise, label)` helper that auto-ends on settle so callers can't strand the counter.
|
||||||
|
- Task 03 added the incremental per-feed apply inside `refreshAllFeeds`; wire activity around the whole batch ( `isLoadingFeeds` already brackets it — prefer reusing the signal, adding explicit `begin/end` ONLY where no signal exists).
|
||||||
|
- Style: Solid + `@opentui/solid` JSX (no `className`; props like `fg`, `paddingRight`, `position`); store files tab-indented with semicolons; components match `LoadingIndicator.tsx` conventions. Style imports use `@/` alias in components, relative paths in stores.
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
|
||||||
|
- New `src/stores/activity.ts`:
|
||||||
|
- Signals: `count` (number), `labels` (string[]).
|
||||||
|
- Actions: `beginActivity(label: string): () => void` (returns the matching end function; each call adds the label, ending removes that exact instance — duplicates allowed), `track<T>(p: Promise<T>, label: string): Promise<T>` (begins, ends in `finally`, re-throws).
|
||||||
|
- Computed: `isActive(): boolean` (`count() > 0`).
|
||||||
|
- Singleton + `useActivityStore()` accessor, mirroring `src/stores/download.ts`'s module pattern.
|
||||||
|
- Wire the gaps in `src/stores/feed.ts` (only where no existing signal covers the operation):
|
||||||
|
- `refreshFeed`: `await activity.track(...)` around the fetch+apply, label `"Refreshing"`.
|
||||||
|
- `addFeed`: wrap the directory-resolve + `fetchEpisodes` stretch, label `"Subscribing"`.
|
||||||
|
- Do NOT wrap `refreshAllFeeds`/`loadMoreEpisodes*` — `isLoadingFeeds`/`isLoadingMore` already cover them (double-counting just lengthens the spinner's on-time cosmetically; the point is no visual gap).
|
||||||
|
- New `src/components/GlobalActivityIndicator.tsx`:
|
||||||
|
- Computes active state from: `feedStore.isLoadingFeeds() || feedStore.isLoadingMore() || searchStore.isSearching() || downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 || activity.isActive()`.
|
||||||
|
- Label selection: downloads in flight → `Downloading N` (+`M queued` when queue non-empty); else the activity store's latest label + `…` (e.g. `Refreshing…`); else `Loading…`.
|
||||||
|
- Renders `<LoadingIndicator label={…} />` inside `<box position="absolute" top={0} right={0} paddingRight={1}>`; renders nothing (returns `null`) when inactive so it never eats layout when idle.
|
||||||
|
- `src/components/Shell.tsx`: mount `<GlobalActivityIndicator />` as the LAST child of the root `<box flexDirection="column" …>` (after the content row, bottom bar, and help overlay so it paints on top).
|
||||||
|
- `tests/global-activity-indicator.test.tsx` (new) — see tests section.
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
1. Read `src/stores/download.ts`, `src/stores/search.ts`, `src/components/LoadingIndicator.tsx`, and the render JSX of `src/components/Shell.tsx`.
|
||||||
|
2. Write `src/stores/activity.ts` (small; ~60 lines).
|
||||||
|
3. Wire `refreshFeed`/`addFeed` in `src/stores/feed.ts` via `useActivityStore().track(...)`. Import cycle note: `activity.ts` must import NOTHING from other stores (pure counter) so `feed.ts` importing it is safe.
|
||||||
|
4. Write `src/components/GlobalActivityIndicator.tsx`; mount it in `Shell.tsx` last (paints on top).
|
||||||
|
5. Write tests; run new tests, full suite, lint; manual smoke per validation.
|
||||||
|
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- New `tests/global-activity-indicator.test.tsx` (component-test conventions: copy the render harness from `tests/feed-refresh-spinner.test.tsx` — temp `XDG_CONFIG_HOME` before imports; if a jsdom-like setup is used there, reuse it as-is):
|
||||||
|
- Activity store unit asserts: two `begin`s → `isActive()` true; ending one → still true; ending both → false. `track(failingPromise)` still decrements (rejects propagate, counter returns to baseline).
|
||||||
|
- Component asserts: render `<GlobalActivityIndicator />` in isolation —
|
||||||
|
- idle → no text rendered;
|
||||||
|
- `useActivityStore().beginActivity("Refreshing")` → spinner/label present in rendered output; matching end → gone;
|
||||||
|
- with the download store: enqueue via `downloadStore.startDownload`-equivalent the way `tests/download-unsubscribed.test.ts` does (assert indicator renders while `getActiveCount() + queue > 0`); skip actual network by following that test's existing mocking pattern.
|
||||||
|
- Existing suites must pass: `feed-refresh-spinner.test.tsx` (per-page spinners unchanged), full `bun test`.
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
|
||||||
|
- Indicator visible in the top-right overlay while any of: all-feeds refresh, single-feed refresh, fetch-more, subscribe fetch, search, active/queued download — and hidden when none are active.
|
||||||
|
- Counter never strands: every completed/failed tracked operation returns `isActive()` to its prior value (proven by the `track` rejection test).
|
||||||
|
- Idle UI unchanged: when inactive the overlay renders nothing and occupies zero layout.
|
||||||
|
- `bun test` full suite passes; `bun run lint` clean.
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
- `bun test tests/global-activity-indicator.test.tsx tests/feed-refresh-spinner.test.tsx`
|
||||||
|
- `bun test`
|
||||||
|
- `bun run lint`
|
||||||
|
- Manual smoke: `bun start`; (a) on cold boot with subscriptions, the top-right spinner appears during startup refresh and disappears when done; (b) press `r` on Feed — spinner appears; (c) download an episode from Search — `Downloading` label shows while the transfer runs; (d) leave idle — top-right is empty.
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- Depends on 03 only for ordering cleanliness — the activity wiring hooks onto the restructured refresh paths; nothing in 03's API is required beyond the store exporting the same signals.
|
||||||
|
- The overlay intentionally does NOT replace per-pane spinners (`Refreshing…` in Feed/MyShows/Discover/Search stay) — removing those is out of scope.
|
||||||
|
- If `position="absolute"` proves unavailable for text-draw ordering in `@opentui/solid`, the fallback is a dedicated 1-row header (`height={1}`) above the content row with the indicator right-aligned — only take this path with evidence (broken render), and note the tradeoff (loses one row of content height) in the commit message.
|
||||||
27
tasks/bounded-feed-lifecycle/README.md
Normal file
27
tasks/bounded-feed-lifecycle/README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Bounded Feed Lifecycle
|
||||||
|
|
||||||
|
Objective: Bound feed episode storage to a rolling 30-day persisted window (older episodes volatile-only unless downloaded), keep feed loading nonblocking, and surface all load/download activity in a global top-right indicator.
|
||||||
|
|
||||||
|
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||||
|
|
||||||
|
Tasks
|
||||||
|
|
||||||
|
- [x] 01 — persisted-retention-window → `01-persisted-retention-window.md`
|
||||||
|
- [x] 02 — volatile-episode-merge → `02-volatile-episode-merge.md`
|
||||||
|
- [x] 03 — nonblocking-feed-refresh → `03-nonblocking-feed-refresh.md`
|
||||||
|
- [x] 04 — global-activity-indicator → `04-global-activity-indicator.md`
|
||||||
|
|
||||||
|
Dependencies
|
||||||
|
|
||||||
|
- 02 depends on 01 (the volatile merge preserves exactly what 01 drops from disk)
|
||||||
|
- 03 depends on 01 (debounced persistence layers onto the pruning save path)
|
||||||
|
- 03 depends on 02 (incremental per-feed apply consumes the merge helper from 02)
|
||||||
|
- 04 depends on 03 (the indicator subscribes to the activity wiring added across refresh/load-more paths in 03)
|
||||||
|
|
||||||
|
Exit criteria
|
||||||
|
|
||||||
|
- After any refresh + save, `config.json` `feeds[*].episodes` contains only episodes with `pubDate` within the last 30 days or episodes marked `completed` in `downloads.json`; loading a legacy config prunes stale episodes on first launch.
|
||||||
|
- The volatile episode list and pagination cache are bounded by the same 30-day window as persistence: only in-window episodes are cached/loadable, and everything in-window is (no episode-count ceiling).
|
||||||
|
- A refresh batch never exceeds a fixed fetch concurrency, applies each feed's result as it lands (no `Promise.all` barrier), and persistence writes are debounced; keyboard input stays responsive throughout.
|
||||||
|
- The top-right indicator is visible iff at least one feed refresh, fetch-more, subscribe fetch, search, or episode download is in flight, hidden otherwise.
|
||||||
|
- `bun test` and `bun run lint` pass.
|
||||||
323
tests/feed-nonblocking.test.ts
Normal file
323
tests/feed-nonblocking.test.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* Non-blocking feed refresh tests — task 03 of the bounded-feed-lifecycle
|
||||||
|
* feature.
|
||||||
|
*
|
||||||
|
* Pins the contracts that make a refresh batch feel non-blocking:
|
||||||
|
*
|
||||||
|
* 1. refreshAllFeeds never holds more than FETCH_CONCURRENCY (4) RSS
|
||||||
|
* requests in flight — a worker pool bounds the batch instead of
|
||||||
|
* Promise.all firing every feed at once.
|
||||||
|
* 2. Each feed's refreshed episodes are applied AS ITS OWN FETCH LANDS —
|
||||||
|
* the old Promise.all barrier is gone, so a slow feed no longer hides
|
||||||
|
* the fast feeds' fresh episodes.
|
||||||
|
* 3. config.json writes are trailing-edge debounced (rapid changes
|
||||||
|
* collapse into one final write) and flushPendingSave() persists
|
||||||
|
* immediately, without waiting out the debounce window.
|
||||||
|
*
|
||||||
|
* Polling note (why the polls below use setImmediate, not microtasks):
|
||||||
|
* vi's fake timers trap setTimeout/setInterval/Date/Bun.sleep, so the
|
||||||
|
* debounce is driven with vi.advanceTimersByTime. But a poll loop of pure
|
||||||
|
* microtask turns (`await Promise.resolve()`) can NEVER observe an
|
||||||
|
* in-flight refresh: it keeps the microtask queue non-empty, the event
|
||||||
|
* loop's poll phase is never reached, and Bun.serve never even receives
|
||||||
|
* the fetch (verified empirically). setImmediate is a real macrotask that
|
||||||
|
* fake timers do NOT trap, and it lets the socket I/O progress — each
|
||||||
|
* `tick()` below is one bounded event-loop turn. No real sleeps anywhere.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
// Point the config dir at a throwaway directory BEFORE importing the stores
|
||||||
|
// (their module-level init reads it).
|
||||||
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-nonblocking-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
|
||||||
|
import { useFeedStore } from "../src/stores/feed";
|
||||||
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
import { whenConfigIdle } from "../src/utils/config";
|
||||||
|
|
||||||
|
interface ServedEpisode {
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||||
|
let servedEpisodes: ServedEpisode[] = [];
|
||||||
|
|
||||||
|
/** Per-pathname request gates: while a path has an unresolved gate, the
|
||||||
|
* server parks that request until the test resolves it. */
|
||||||
|
let gates = new Map<string, { gate: Promise<void>; resolve: () => void }>();
|
||||||
|
/** Requests currently inside the fetch handler (entered, not yet answered). */
|
||||||
|
let inFlight = 0;
|
||||||
|
/** High-water mark of `inFlight` — the concurrency-bound assertion source. */
|
||||||
|
let maxConcurrent = 0;
|
||||||
|
|
||||||
|
// Bun runs test files in ONE process, so the store singleton is shared with
|
||||||
|
// other test files. Track the feeds we add and remove them in afterAll so
|
||||||
|
// whichever file runs next sees a pristine store.
|
||||||
|
const addedFeedIds: string[] = [];
|
||||||
|
/** Feed created by the debounce test, reused by the flushPendingSave test. */
|
||||||
|
let debounceFeedId = "";
|
||||||
|
|
||||||
|
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
||||||
|
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||||
|
const items = episodes
|
||||||
|
.map(
|
||||||
|
(ep, i) => `<item>
|
||||||
|
<title>${ep.title}</title>
|
||||||
|
<pubDate>${ep.date}</pubDate>
|
||||||
|
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||||
|
</item>`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"><channel>
|
||||||
|
<title>Non-Blocking Test Show</title>
|
||||||
|
<description>Non-blocking refresh test feed</description>
|
||||||
|
${items}
|
||||||
|
</channel></rss>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makePodcast = (feedUrl: string): Podcast => ({
|
||||||
|
id: feedUrl,
|
||||||
|
title: "Non-Blocking Test Show",
|
||||||
|
description: "Non-blocking refresh test feed",
|
||||||
|
author: "tester",
|
||||||
|
feedUrl,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Park a request path behind an unresolved gate. */
|
||||||
|
function setGate(path: string): void {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<void>();
|
||||||
|
gates.set(path, { gate: promise, resolve });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve every gate currently set. */
|
||||||
|
function releaseAllGates(): void {
|
||||||
|
for (const { resolve } of gates.values()) resolve();
|
||||||
|
gates.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One real macrotask turn — see the polling note in the header. */
|
||||||
|
const tick = (): Promise<void> => {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<void>();
|
||||||
|
setImmediate(resolve);
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Poll `cond` across up to `iterations` event-loop turns (one setImmediate
|
||||||
|
* each). Returns whether the condition held by the deadline. */
|
||||||
|
async function pollUntil(
|
||||||
|
cond: () => boolean,
|
||||||
|
iterations = 500,
|
||||||
|
): Promise<boolean> {
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
if (cond()) return true;
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
return cond();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw config.json text ("" when the file does not exist yet). */
|
||||||
|
const readConfigRaw = (): Promise<string> =>
|
||||||
|
Bun.file(join(process.env.XDG_CONFIG_HOME!, "podtui", "config.json"))
|
||||||
|
.text()
|
||||||
|
.catch(() => "");
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
async fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
inFlight++;
|
||||||
|
if (inFlight > maxConcurrent) maxConcurrent = inFlight;
|
||||||
|
try {
|
||||||
|
const gate = gates.get(url.pathname);
|
||||||
|
if (gate) await gate.gate;
|
||||||
|
if (url.pathname.endsWith(".xml")) {
|
||||||
|
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||||
|
headers: { "Content-Type": "application/rss+xml" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
} finally {
|
||||||
|
inFlight--;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
gates.clear();
|
||||||
|
inFlight = 0;
|
||||||
|
maxConcurrent = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
const store = useFeedStore();
|
||||||
|
for (const id of addedFeedIds) store.removeFeed(id);
|
||||||
|
server?.stop(true);
|
||||||
|
rmSync(configHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refreshAllFeeds never exceeds FETCH_CONCURRENCY in-flight requests", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
servedEpisodes = [{ title: "Bound Ep 0", date: "2026-08-10T00:00:00Z" }];
|
||||||
|
const urls = Array.from(
|
||||||
|
{ length: 10 },
|
||||||
|
(_, n) => `http://127.0.0.1:${server!.port}/bound-${n}.xml`,
|
||||||
|
);
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const url of urls) {
|
||||||
|
const feed = await store.addFeed(makePodcast(url), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
ids.push(feed!.id);
|
||||||
|
addedFeedIds.push(feed!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate every path so the batch's requests pile up at the server. addFeed
|
||||||
|
// ran sequentially above (its own fetches never exceed 1 in flight), so
|
||||||
|
// the counter below measures the batch alone.
|
||||||
|
for (const url of urls) setGate(new URL(url).pathname);
|
||||||
|
inFlight = 0;
|
||||||
|
maxConcurrent = 0;
|
||||||
|
|
||||||
|
const refreshPromise = store.refreshAllFeeds(); // NOT awaited
|
||||||
|
const sawBound = await pollUntil(() => maxConcurrent >= 4);
|
||||||
|
expect(sawBound).toBe(true);
|
||||||
|
// The worker pool caps the batch at 4 — exactly 4 gated requests are
|
||||||
|
// parked (nothing has been released, so nothing completed yet), and
|
||||||
|
// nothing may exceed the bound, now or as the batch drains.
|
||||||
|
expect(maxConcurrent).toBe(4);
|
||||||
|
expect(maxConcurrent).toBeLessThanOrEqual(4);
|
||||||
|
|
||||||
|
releaseAllGates();
|
||||||
|
await refreshPromise;
|
||||||
|
expect(maxConcurrent).toBeLessThanOrEqual(4);
|
||||||
|
for (const id of ids) {
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refreshAllFeeds applies each feed as its own fetch lands (no barrier)", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
servedEpisodes = [{ title: "Incr Ep 0", date: "2026-08-10T00:00:00Z" }];
|
||||||
|
const aUrl = `http://127.0.0.1:${server!.port}/incr-a.xml`;
|
||||||
|
const bUrl = `http://127.0.0.1:${server!.port}/incr-b.xml`;
|
||||||
|
const a = await store.addFeed(makePodcast(aUrl), "test-source");
|
||||||
|
const b = await store.addFeed(makePodcast(bUrl), "test-source");
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
expect(b).not.toBeNull();
|
||||||
|
const aId = a!.id;
|
||||||
|
const bId = b!.id;
|
||||||
|
addedFeedIds.push(aId, bId);
|
||||||
|
|
||||||
|
// A new episode appears for both feeds; B's fetch is parked at the
|
||||||
|
// server, A's is not.
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "Incr Ep 0", date: "2026-08-10T00:00:00Z" },
|
||||||
|
{ title: "Incr Ep 1", date: "2026-08-09T00:00:00Z" },
|
||||||
|
];
|
||||||
|
setGate(new URL(bUrl).pathname);
|
||||||
|
|
||||||
|
const beforeA = store.getFeed(aId)!.lastUpdated.getTime();
|
||||||
|
const beforeB = store.getFeed(bId)!.lastUpdated.getTime();
|
||||||
|
// Advance the (mocked) clock so the refresh's `new Date()` lastUpdated
|
||||||
|
// bump is observably greater than beforeA (the fake clock otherwise
|
||||||
|
// never moves — same pattern as feed-refresh.test.ts).
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
const refreshPromise = store.refreshAllFeeds(); // NOT awaited
|
||||||
|
|
||||||
|
const applied = await pollUntil(
|
||||||
|
() => store.getFeed(aId)!.lastUpdated.getTime() > beforeA,
|
||||||
|
);
|
||||||
|
expect(applied).toBe(true);
|
||||||
|
// A's refreshed window is visible in feeds() while B is STILL gated —
|
||||||
|
// the proof that per-feed results apply as they land.
|
||||||
|
expect(store.getFeed(aId)!.episodes.length).toBe(2);
|
||||||
|
expect(store.getFeed(bId)!.episodes.length).toBe(1);
|
||||||
|
expect(store.getFeed(bId)!.lastUpdated.getTime()).toBe(beforeB);
|
||||||
|
|
||||||
|
releaseAllGates();
|
||||||
|
await refreshPromise;
|
||||||
|
expect(store.getFeed(aId)!.episodes.length).toBe(2);
|
||||||
|
expect(store.getFeed(bId)!.episodes.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config.json writes are trailing-edge debounced (two refreshes, one save)", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
servedEpisodes = [{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" }];
|
||||||
|
const url = `http://127.0.0.1:${server!.port}/debounce.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(url), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
debounceFeedId = feed!.id;
|
||||||
|
addedFeedIds.push(debounceFeedId);
|
||||||
|
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" },
|
||||||
|
{ title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" },
|
||||||
|
];
|
||||||
|
await store.refreshFeed(debounceFeedId);
|
||||||
|
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" },
|
||||||
|
{ title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" },
|
||||||
|
{ title: "Deb Ep 2", date: "2026-08-08T00:00:00Z" },
|
||||||
|
];
|
||||||
|
await store.refreshFeed(debounceFeedId);
|
||||||
|
expect(store.getFeed(debounceFeedId)!.episodes.length).toBe(3);
|
||||||
|
|
||||||
|
// No timer advanced: the debounced saves have NOT fired — the refreshed
|
||||||
|
// episodes exist only in memory (await whenConfigIdle first so a
|
||||||
|
// straggler write from an earlier test cannot race this read).
|
||||||
|
await whenConfigIdle();
|
||||||
|
const before = await readConfigRaw();
|
||||||
|
expect(before).not.toContain("Deb Ep 1");
|
||||||
|
expect(before).not.toContain("Deb Ep 2");
|
||||||
|
|
||||||
|
// SAVE_DEBOUNCE_MS = 250 (module-private in feed.ts — hardcoded here).
|
||||||
|
vi.advanceTimersByTime(250);
|
||||||
|
await whenConfigIdle();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await whenConfigIdle();
|
||||||
|
|
||||||
|
// One read, both refreshed episodes: the two refreshes collapsed into a
|
||||||
|
// single trailing-edge write.
|
||||||
|
const after = await readConfigRaw();
|
||||||
|
expect(after).toContain("Deb Ep 1");
|
||||||
|
expect(after).toContain("Deb Ep 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("flushPendingSave persists immediately, without waiting out the debounce", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
// Same feed as the debounce test (still in the singleton): serve a 4th
|
||||||
|
// episode and refresh — the save is scheduled, then flushed by hand.
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" },
|
||||||
|
{ title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" },
|
||||||
|
{ title: "Deb Ep 2", date: "2026-08-08T00:00:00Z" },
|
||||||
|
{ title: "Deb Ep 3", date: "2026-08-07T00:00:00Z" },
|
||||||
|
];
|
||||||
|
await store.refreshFeed(debounceFeedId);
|
||||||
|
expect(store.getFeed(debounceFeedId)!.episodes.length).toBe(4);
|
||||||
|
|
||||||
|
// No advanceTimersByTime: flushPendingSave must write right now.
|
||||||
|
store.flushPendingSave();
|
||||||
|
await whenConfigIdle();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await whenConfigIdle();
|
||||||
|
|
||||||
|
const raw = await readConfigRaw();
|
||||||
|
expect(raw).toContain("Deb Ep 3");
|
||||||
|
});
|
||||||
@@ -3,11 +3,12 @@
|
|||||||
* row in a drilled show's episode list (My Shows depth 1) and the Feed
|
* row in a drilled show's episode list (My Shows depth 1) and the Feed
|
||||||
* page's row.
|
* page's row.
|
||||||
*
|
*
|
||||||
* addFeed caches the FULL parsed feed while exposing only the first
|
* addFeed caches every episode inside the lifecycle window (the last
|
||||||
* MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes` reports when the
|
* EPISODE_WINDOW_DAYS days — the date bound, not a count) while exposing
|
||||||
* cache holds more than the loaded window; `loadMoreEpisodes` advances that
|
* only the first MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes`
|
||||||
* window in MAX_EPISODES_REFRESH (50) chunks until it is exhausted. This
|
* reports when the cache holds more than the loaded window;
|
||||||
* pins:
|
* `loadMoreEpisodes` advances that window in MAX_EPISODES_REFRESH (50)
|
||||||
|
* chunks until it is exhausted. This pins:
|
||||||
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
|
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
|
||||||
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
|
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
|
||||||
* needed) and hasMoreEpisodes flips false once the window reaches the end.
|
* needed) and hasMoreEpisodes flips false once the window reaches the end.
|
||||||
@@ -27,6 +28,8 @@ process.env.XDG_CONFIG_HOME = configHome;
|
|||||||
import { useFeedStore } from "../src/stores/feed";
|
import { useFeedStore } from "../src/stores/feed";
|
||||||
import type { Podcast } from "../src/types/podcast";
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
|
||||||
|
const HOUR = 3600 * 1000;
|
||||||
|
|
||||||
interface ServedEpisode {
|
interface ServedEpisode {
|
||||||
title: string;
|
title: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -94,10 +97,12 @@ afterAll(() => {
|
|||||||
|
|
||||||
test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", async () => {
|
test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", async () => {
|
||||||
const store = useFeedStore();
|
const store = useFeedStore();
|
||||||
// 60 episodes: 20 shown at subscribe, 40 held back in the cache.
|
// 60 episodes: 20 shown at subscribe, 40 held back in the cache. All
|
||||||
|
// inside the lifecycle window (11h apart ≈ 27.5 days) so every one is
|
||||||
|
// cacheable — the cache bound is the date window, not a count.
|
||||||
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
|
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
|
||||||
title: `Ep ${60 - i}`,
|
title: `Ep ${60 - i}`,
|
||||||
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
|
date: new Date(Date.now() - (60 - i) * 11 * HOUR).toISOString(),
|
||||||
}));
|
}));
|
||||||
const feedUrl = `http://127.0.0.1:${server!.port}/paged.xml`;
|
const feedUrl = `http://127.0.0.1:${server!.port}/paged.xml`;
|
||||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
@@ -123,9 +128,10 @@ test("loadMoreEpisodes advances one feed's window from the cache, then no-ops",
|
|||||||
test("hasMoreEpisodes stays true across chunked loads until the end", async () => {
|
test("hasMoreEpisodes stays true across chunked loads until the end", async () => {
|
||||||
const store = useFeedStore();
|
const store = useFeedStore();
|
||||||
// 120 episodes: 20 shown, 100 cached — two 50-episode chunks remaining.
|
// 120 episodes: 20 shown, 100 cached — two 50-episode chunks remaining.
|
||||||
|
// All inside the lifecycle window (5h apart = 25 days).
|
||||||
servedEpisodes = Array.from({ length: 120 }, (_, i) => ({
|
servedEpisodes = Array.from({ length: 120 }, (_, i) => ({
|
||||||
title: `Ep ${120 - i}`,
|
title: `Ep ${120 - i}`,
|
||||||
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
|
date: new Date(Date.now() - (120 - i) * 5 * HOUR).toISOString(),
|
||||||
}));
|
}));
|
||||||
const feedUrl = `http://127.0.0.1:${server!.port}/paged-chunked.xml`;
|
const feedUrl = `http://127.0.0.1:${server!.port}/paged-chunked.xml`;
|
||||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
|||||||
162
tests/feed-refresh-spinner.test.tsx
Normal file
162
tests/feed-refresh-spinner.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* FeedPage refresh spinner — while feeds are being fetched (manual `r` and
|
||||||
|
* the background refresh timer both route through refreshAllFeeds →
|
||||||
|
* isLoadingFeeds), a braille spinner renders at the BOTTOM of the episode
|
||||||
|
* list, horizontally centered in the current pane.
|
||||||
|
*
|
||||||
|
* The refresh is left in flight on purpose (the test server delays its
|
||||||
|
* response) so the loading state is visible in the captured frame.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
import type { Server } from "bun";
|
||||||
|
import { mkdtempSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
// Point the config dir at a throwaway directory BEFORE importing the stores
|
||||||
|
// (their module-level init reads it) and silence the audio backend.
|
||||||
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-spinner-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||||
|
|
||||||
|
import { testRender } from "@opentui/solid";
|
||||||
|
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||||
|
import { NavigationProvider } from "../src/context/NavigationContext";
|
||||||
|
import { FeedPage } from "../src/pages/Feed/FeedPage";
|
||||||
|
import { useFeedStore } from "../src/stores/feed";
|
||||||
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
|
||||||
|
// The LoadingIndicator glyph cycle.
|
||||||
|
const SPINNER_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
|
||||||
|
|
||||||
|
type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
|
||||||
|
const frameLines = (f: Frame): string[] =>
|
||||||
|
f.lines.map((l) => l.spans.map((s) => s.text).join(""));
|
||||||
|
|
||||||
|
let server: Server<undefined> | null = null;
|
||||||
|
/** Response delay (ms) for the next fetch — 0 during setup, >0 while the
|
||||||
|
* refresh is in flight so the loading state is observable. */
|
||||||
|
let delayMs = 0;
|
||||||
|
let feedUrl = "";
|
||||||
|
let feedId = "";
|
||||||
|
|
||||||
|
/** 3 episodes × 3 rows = 9 list rows: the spinner sits right below them.
|
||||||
|
* Dated inside the lifecycle window (1–3 days ago) so all three render. */
|
||||||
|
function feedXml(origin: string): string {
|
||||||
|
const items = Array.from({ length: 3 }, (_, i) => `<item>
|
||||||
|
<title>Spin Ep ${3 - i}</title>
|
||||||
|
<pubDate>${new Date(Date.now() - (3 - i) * 24 * 3600 * 1000).toISOString()}</pubDate>
|
||||||
|
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||||
|
</item>`).join("\n");
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"><channel>
|
||||||
|
<title>Spinner Show</title>
|
||||||
|
<description>spinner test feed</description>
|
||||||
|
${items}
|
||||||
|
</channel></rss>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (!url.pathname.endsWith(".xml")) {
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
}
|
||||||
|
const { promise, resolve } = Promise.withResolvers<Response>();
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
resolve(
|
||||||
|
new Response(feedXml(url.origin), {
|
||||||
|
headers: { "Content-Type": "application/rss+xml" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
delayMs,
|
||||||
|
);
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const podcast: Podcast = {
|
||||||
|
id: "",
|
||||||
|
title: "Spinner Show",
|
||||||
|
description: "spinner test feed",
|
||||||
|
author: "tester",
|
||||||
|
feedUrl: "",
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: true,
|
||||||
|
};
|
||||||
|
feedUrl = `http://127.0.0.1:${server.port}/spinner.xml`;
|
||||||
|
const store = useFeedStore();
|
||||||
|
const feed = await store.addFeed(
|
||||||
|
{ ...podcast, feedUrl },
|
||||||
|
"test-source",
|
||||||
|
);
|
||||||
|
feedId = feed!.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
store.removeFeed(feedId);
|
||||||
|
server?.stop(true);
|
||||||
|
rmSync(configHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh spinner renders at the bottom of the list, centered in the current pane", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
const setup = await testRender(
|
||||||
|
() => (
|
||||||
|
<ThemeProvider mode="dark">
|
||||||
|
<NavigationProvider>
|
||||||
|
<FeedPage />
|
||||||
|
</NavigationProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
),
|
||||||
|
{ width: 100, height: 30, useThread: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Settle until the episode list is mounted.
|
||||||
|
let lines: string[] | null = null;
|
||||||
|
for (let i = 0; i < 40 && !lines; i++) {
|
||||||
|
await setup.renderOnce();
|
||||||
|
const ls = frameLines(setup.captureSpans() as unknown as Frame);
|
||||||
|
if (ls.some((l) => l.includes("Spin Ep 3"))) lines = ls;
|
||||||
|
else await new Promise((r) => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
if (!lines) throw new Error("FeedPage did not render episodes before timeout");
|
||||||
|
|
||||||
|
// Kick off a refresh and leave it in flight: isLoadingFeeds flips true
|
||||||
|
// synchronously, so the very next frame shows the spinner.
|
||||||
|
delayMs = 400;
|
||||||
|
const refreshing = store.refreshAllFeeds();
|
||||||
|
await setup.renderOnce();
|
||||||
|
const loading = frameLines(setup.captureSpans() as unknown as Frame);
|
||||||
|
|
||||||
|
// Locate the spinner row and the current pane's borders ("│" columns;
|
||||||
|
// only the current pane is bordered in PaneRow).
|
||||||
|
const spinnerRow = loading.findIndex((l) => SPINNER_RE.test(l));
|
||||||
|
expect(spinnerRow).toBeGreaterThan(-1);
|
||||||
|
|
||||||
|
const spinnerCol = loading[spinnerRow].search(SPINNER_RE);
|
||||||
|
const borderCols = loading
|
||||||
|
.map((l, i) => (i <= spinnerRow ? [...l].map((ch, x) => (ch === "│" ? x : -1)) : []))
|
||||||
|
.flat()
|
||||||
|
.filter((x) => x >= 0);
|
||||||
|
const paneLeft = Math.min(...borderCols);
|
||||||
|
const paneRight = Math.max(...borderCols);
|
||||||
|
const paneCenter = (paneLeft + paneRight) / 2;
|
||||||
|
expect(paneLeft).toBeGreaterThan(0); // borders actually found
|
||||||
|
|
||||||
|
// Bottom of the list: below the last episode row.
|
||||||
|
const lastEpRow = loading.findLastIndex((l) => l.includes("Spin Ep"));
|
||||||
|
expect(spinnerRow).toBeGreaterThan(lastEpRow);
|
||||||
|
|
||||||
|
// Horizontally centered in the current pane (not left-padded).
|
||||||
|
expect(Math.abs(spinnerCol - paneCenter)).toBeLessThanOrEqual(8);
|
||||||
|
|
||||||
|
// Let the refresh finish so teardown is clean.
|
||||||
|
delayMs = 0;
|
||||||
|
await refreshing;
|
||||||
|
setup.renderer.destroy();
|
||||||
|
});
|
||||||
@@ -29,6 +29,8 @@ process.env.XDG_CONFIG_HOME = configHome;
|
|||||||
import { useFeedStore } from "../src/stores/feed";
|
import { useFeedStore } from "../src/stores/feed";
|
||||||
import type { Podcast } from "../src/types/podcast";
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
|
||||||
|
const HOUR = 3600 * 1000;
|
||||||
|
|
||||||
interface ServedEpisode {
|
interface ServedEpisode {
|
||||||
title: string;
|
title: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -137,6 +139,7 @@ test("refresh with a genuinely new episode bumps lastUpdated", async () => {
|
|||||||
|
|
||||||
test("a failed refresh does not wipe the feed's episodes", async () => {
|
test("a failed refresh does not wipe the feed's episodes", async () => {
|
||||||
const store = useFeedStore();
|
const store = useFeedStore();
|
||||||
|
const savedEpisodes = servedEpisodes;
|
||||||
servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }];
|
servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }];
|
||||||
const feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`;
|
const feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`;
|
||||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
@@ -158,6 +161,11 @@ test("a failed refresh does not wipe the feed's episodes", async () => {
|
|||||||
|
|
||||||
failPath = null;
|
failPath = null;
|
||||||
store.removeFeed(feedId);
|
store.removeFeed(feedId);
|
||||||
|
// Restore the shared served content: with union merge semantics (volatile
|
||||||
|
// episodes survive refreshes) this feed keeps its larger in-memory window,
|
||||||
|
// so later tests must serve the same episodes they added — a shrink here
|
||||||
|
// would make the next test's "unchanged" refresh genuinely different.
|
||||||
|
servedEpisodes = savedEpisodes;
|
||||||
});
|
});
|
||||||
|
|
||||||
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
|
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
|
||||||
@@ -183,3 +191,46 @@ test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () =>
|
|||||||
expect(store.getFeed(id)!.lastUpdated.getTime()).toBe(tsBefore[id]);
|
expect(store.getFeed(id)!.lastUpdated.getTime()).toBe(tsBefore[id]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("refresh parses in bounded chunks, yielding to the event loop between them", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
// 60 episodes: a chunked parse (25/chunk) must yield between chunks; a
|
||||||
|
// monolithic parse would complete without yielding at all. All dated
|
||||||
|
// inside the lifecycle window (11h apart ≈ 27.5 days) so every one is
|
||||||
|
// cacheable and the window assertions below hold.
|
||||||
|
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
|
||||||
|
title: `Ep ${60 - i}`,
|
||||||
|
date: new Date(Date.now() - (60 - i) * 11 * HOUR).toISOString(),
|
||||||
|
}));
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/chunky.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
const feedId = feed!.id;
|
||||||
|
expect(store.getFeed(feedId)!.episodes.length).toBe(20); // subscribe window
|
||||||
|
|
||||||
|
// Count event-loop yields during the refresh: each parse-chunk boundary
|
||||||
|
// posts through a MessageChannel (the yield primitive in feed.ts — the
|
||||||
|
// one macrotask turn bun's fake timers do not trap, which also pins that
|
||||||
|
// the yield works under fake timers). This runs under fake timers like
|
||||||
|
// the other tests; a setTimeout-based yield would deadlock here.
|
||||||
|
const OriginalMessageChannel = globalThis.MessageChannel;
|
||||||
|
let posts = 0;
|
||||||
|
globalThis.MessageChannel = class extends OriginalMessageChannel {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
posts++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
await store.refreshFeed(feedId);
|
||||||
|
} finally {
|
||||||
|
globalThis.MessageChannel = OriginalMessageChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(posts).toBeGreaterThan(0);
|
||||||
|
expect(store.getFeed(feedId)!.episodes.length).toBe(50); // refresh window
|
||||||
|
|
||||||
|
// Leave the shared singleton as we found it (see the addedFeedIds note
|
||||||
|
// in feed-pagination.test.ts — bun runs test files in one process).
|
||||||
|
store.removeFeed(feedId);
|
||||||
|
});
|
||||||
|
|||||||
286
tests/feed-retention.test.ts
Normal file
286
tests/feed-retention.test.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* Bounded-feed-lifecycle persistence tests — task 01 (retention window).
|
||||||
|
*
|
||||||
|
* Pins the persistence contract:
|
||||||
|
* 1. saveFeedsToFile never writes an episode older than DEFAULT_EPISODE_WINDOW_DAYS
|
||||||
|
* unless its id is a completed download in downloads.json.
|
||||||
|
* 2. loadFeedsFromFile prunes over-window episodes from legacy configs and
|
||||||
|
* rewrites config.json when it pruned anything.
|
||||||
|
* 3. Undatable episodes (missing/invalid pubDate) are always persisted.
|
||||||
|
*
|
||||||
|
* The async saveFeedsToFile IIFE reads downloads.json then enqueues an
|
||||||
|
* updateConfig write on the serialized write chain, so assertions wait via
|
||||||
|
* whenConfigIdle() + a short real-timer settle (no fake timers here — see
|
||||||
|
* settleWrites).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, afterAll } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
// Point the config dir at a throwaway directory BEFORE importing anything
|
||||||
|
// under test (config-dir reads XDG_CONFIG_HOME lazily, but stay consistent
|
||||||
|
// with the store test harness). Do NOT import the feed store — its module
|
||||||
|
// boot IIFE would hit the network.
|
||||||
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-retention-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_EPISODE_WINDOW_DAYS,
|
||||||
|
episodeIsPersistable,
|
||||||
|
loadFeedsFromFile,
|
||||||
|
saveFeedsToFile,
|
||||||
|
} from "../src/utils/feeds-persistence";
|
||||||
|
import { whenConfigIdle } from "../src/utils/config";
|
||||||
|
import { FeedVisibility } from "../src/types/feed";
|
||||||
|
import type { Feed } from "../src/types/feed";
|
||||||
|
import type { Episode } from "../src/types/episode";
|
||||||
|
|
||||||
|
const configJsonPath = join(configHome, "podtui", "config.json");
|
||||||
|
const downloadsJsonPath = join(configHome, "podtui", "downloads.json");
|
||||||
|
|
||||||
|
/** Milliseconds in one day — mirrors the window math in feeds-persistence. */
|
||||||
|
const DAY = 24 * 3600 * 1000;
|
||||||
|
|
||||||
|
function makeEpisode(partial: Partial<Episode> & { id: string }): Episode {
|
||||||
|
return {
|
||||||
|
podcastId: "feed-1",
|
||||||
|
title: partial.id,
|
||||||
|
description: "",
|
||||||
|
audioUrl: `https://example.com/audio/${partial.id}.mp3`,
|
||||||
|
duration: 600,
|
||||||
|
pubDate: new Date(),
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFeed(episodes: Episode[]): Feed {
|
||||||
|
return {
|
||||||
|
id: "feed-1",
|
||||||
|
podcast: {
|
||||||
|
id: "feed-1",
|
||||||
|
title: "Retention Show",
|
||||||
|
description: "Retention test feed",
|
||||||
|
author: "tester",
|
||||||
|
feedUrl: "https://example.com/feed.xml",
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes,
|
||||||
|
visibility: FeedVisibility.PUBLIC,
|
||||||
|
sourceId: "source-1",
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isPinned: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<void>();
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the fire-and-forget save chain to drain. saveFeedsToFile's IIFE is
|
||||||
|
* not awaitable: it reads downloads.json first and only THEN enqueues its
|
||||||
|
* write on the serialized chain, so the first whenConfigIdle() may observe
|
||||||
|
* the chain BEFORE the write is queued. A real-timer settle is the only way
|
||||||
|
* to let the IIFE's async read land without fake timers (which would stall
|
||||||
|
* the Bun.file I/O and the write chain itself); the poll fallback in the
|
||||||
|
* assertions absorbs any residual scheduling skew on a loaded machine.
|
||||||
|
*/
|
||||||
|
async function settleWrites(): Promise<void> {
|
||||||
|
await whenConfigIdle();
|
||||||
|
await delay(20);
|
||||||
|
await whenConfigIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Episode ids of the first feed in config.json, or null when absent. */
|
||||||
|
async function readPersistedEpisodeIds(): Promise<string[] | null> {
|
||||||
|
const raw = await Bun.file(configJsonPath).json().catch(() => null);
|
||||||
|
if (!raw || typeof raw !== "object" || !("feeds" in raw)) return null;
|
||||||
|
const feeds = raw.feeds;
|
||||||
|
if (!Array.isArray(feeds) || feeds.length === 0) return null;
|
||||||
|
const first = feeds[0];
|
||||||
|
if (!first || typeof first !== "object" || !("episodes" in first)) return null;
|
||||||
|
const episodes = first.episodes;
|
||||||
|
if (!Array.isArray(episodes)) return null;
|
||||||
|
return episodes.map((ep) => {
|
||||||
|
if (ep && typeof ep === "object" && "id" in ep) return String(ep.id);
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait (up to ~1s) until config.json's first feed has exactly these ids. */
|
||||||
|
async function pollConfigFor(ids: string[]): Promise<void> {
|
||||||
|
const expected = [...ids].sort().join(",");
|
||||||
|
const deadline = Date.now() + 1000;
|
||||||
|
for (;;) {
|
||||||
|
const actual = (await readPersistedEpisodeIds())?.sort().join(",");
|
||||||
|
if (actual === expected) return;
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
throw new Error(
|
||||||
|
`config.json never reached expected episode ids [${ids.join(", ")}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await delay(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(configHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Unit: episodeIsPersistable ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("episodeIsPersistable drops a 70-day-old episode that is not downloaded", () => {
|
||||||
|
const ep = makeEpisode({
|
||||||
|
id: "old-plain-id",
|
||||||
|
pubDate: new Date(Date.now() - 70 * DAY),
|
||||||
|
});
|
||||||
|
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("episodeIsPersistable keeps a 70-day-old episode whose id is a completed download", () => {
|
||||||
|
const ep = makeEpisode({
|
||||||
|
id: "old-downloaded-id",
|
||||||
|
pubDate: new Date(Date.now() - 70 * DAY),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
episodeIsPersistable(ep, new Set(["old-downloaded-id"]), new Date()),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("episodeIsPersistable keeps a 5-day-old episode", () => {
|
||||||
|
const ep = makeEpisode({
|
||||||
|
id: "recent-id",
|
||||||
|
pubDate: new Date(Date.now() - 5 * DAY),
|
||||||
|
});
|
||||||
|
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("episodeIsPersistable keeps an episode with an invalid pubDate", () => {
|
||||||
|
const ep = makeEpisode({ id: "undatable-id", pubDate: new Date(NaN) });
|
||||||
|
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DEFAULT_EPISODE_WINDOW_DAYS is 60", () => {
|
||||||
|
expect(DEFAULT_EPISODE_WINDOW_DAYS).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Save path: retention window applied with completed-download exemption ──
|
||||||
|
|
||||||
|
test("saveFeedsToFile prunes over-window episodes but keeps completed downloads", async () => {
|
||||||
|
// Arrange: downloads.json lists one completed download.
|
||||||
|
mkdirSync(join(configHome, "podtui"), { recursive: true });
|
||||||
|
await Bun.write(
|
||||||
|
downloadsJsonPath,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
episodeId: "old-downloaded-id",
|
||||||
|
feedId: "feed-1",
|
||||||
|
status: "completed",
|
||||||
|
filePath: null,
|
||||||
|
downloadedAt: null,
|
||||||
|
fileSize: 0,
|
||||||
|
error: null,
|
||||||
|
audioUrl: "",
|
||||||
|
episodeTitle: "",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act: recent episode, old plain episode, old downloaded episode.
|
||||||
|
const feed = makeFeed([
|
||||||
|
makeEpisode({
|
||||||
|
id: "recent-id",
|
||||||
|
pubDate: new Date(Date.now() - 5 * DAY),
|
||||||
|
}),
|
||||||
|
makeEpisode({
|
||||||
|
id: "old-plain-id",
|
||||||
|
pubDate: new Date(Date.now() - 70 * DAY),
|
||||||
|
}),
|
||||||
|
makeEpisode({
|
||||||
|
id: "old-downloaded-id",
|
||||||
|
pubDate: new Date(Date.now() - 70 * DAY),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
saveFeedsToFile([feed]);
|
||||||
|
await settleWrites();
|
||||||
|
// The IIFE's downloads.json read can land after the first settle; poll
|
||||||
|
// briefly in case the write chain drained before that read resolved.
|
||||||
|
await pollConfigFor(["old-downloaded-id", "recent-id"]);
|
||||||
|
|
||||||
|
// Assert: persisted episodes keep recent + downloaded, drop old-plain.
|
||||||
|
const persisted = await readPersistedEpisodeIds();
|
||||||
|
expect(persisted).toContain("recent-id");
|
||||||
|
expect(persisted).toContain("old-downloaded-id");
|
||||||
|
expect(persisted).not.toContain("old-plain-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Load path: legacy config cleanup rewrite ───────────────────────────────
|
||||||
|
|
||||||
|
test("loadFeedsFromFile prunes over-window episodes and rewrites config.json", async () => {
|
||||||
|
// Arrange: seed config.json directly with a feed whose episodes are ALL
|
||||||
|
// older than the window; no downloads.json present.
|
||||||
|
mkdirSync(join(configHome, "podtui"), { recursive: true });
|
||||||
|
await Bun.write(
|
||||||
|
configJsonPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
feeds: [
|
||||||
|
{
|
||||||
|
id: "feed-1",
|
||||||
|
podcast: {
|
||||||
|
id: "feed-1",
|
||||||
|
title: "Legacy Show",
|
||||||
|
description: "",
|
||||||
|
author: "tester",
|
||||||
|
feedUrl: "https://example.com/legacy.xml",
|
||||||
|
lastUpdated: new Date(Date.now() - 1 * DAY).toISOString(),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes: [
|
||||||
|
{
|
||||||
|
id: "old-a",
|
||||||
|
podcastId: "feed-1",
|
||||||
|
title: "Old A",
|
||||||
|
description: "",
|
||||||
|
audioUrl: "https://example.com/audio/old-a.mp3",
|
||||||
|
duration: 60,
|
||||||
|
pubDate: new Date(Date.now() - 70 * DAY).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "old-b",
|
||||||
|
podcastId: "feed-1",
|
||||||
|
title: "Old B",
|
||||||
|
description: "",
|
||||||
|
audioUrl: "https://example.com/audio/old-b.mp3",
|
||||||
|
duration: 60,
|
||||||
|
pubDate: new Date(Date.now() - 70 * DAY).toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
visibility: "public",
|
||||||
|
sourceId: "source-1",
|
||||||
|
lastUpdated: new Date(Date.now() - 1 * DAY).toISOString(),
|
||||||
|
isPinned: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act.
|
||||||
|
const feeds = await loadFeedsFromFile();
|
||||||
|
await settleWrites();
|
||||||
|
|
||||||
|
// Assert: returned feed has zero episodes AND config.json was rewritten
|
||||||
|
// (the cleanup save is fire-and-forget — poll for the rewritten file).
|
||||||
|
expect(feeds).toHaveLength(1);
|
||||||
|
expect(feeds[0].episodes).toHaveLength(0);
|
||||||
|
await pollConfigFor([]);
|
||||||
|
expect(await readPersistedEpisodeIds()).toEqual([]);
|
||||||
|
});
|
||||||
331
tests/feed-volatile-merge.test.ts
Normal file
331
tests/feed-volatile-merge.test.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* Configurable episode cache + volatile merge tests.
|
||||||
|
*
|
||||||
|
* The episode list cache (what the Feed and My Shows pages show) is bounded by
|
||||||
|
* the user's preference: a date window (default 60 days) or a count (default
|
||||||
|
* 25). The full parse cache holds ALL episodes; fetch-more pages beyond the
|
||||||
|
* bound from that cache (volatile — never written back). These tests pin:
|
||||||
|
* 1. mergeEpisodesBounded unions refreshed episodes with what's in memory
|
||||||
|
* (fetched copy wins on id collision) and prunes by the supplied keep
|
||||||
|
* predicate (count or date). Undated episodes are always kept.
|
||||||
|
* 2. The store bounds the visible list by the configured mode, but the
|
||||||
|
* full parse cache survives — fetch-more pages beyond the bound.
|
||||||
|
* 3. Refresh merge never shrinks the in-memory list except via the bound.
|
||||||
|
*
|
||||||
|
* Clock constraint: these tests run under vi.useFakeTimers, and a LARGE
|
||||||
|
* vi.advanceTimersByTime (past ~5 days of fake time) makes every subsequent
|
||||||
|
* network fetch hang in Bun 1.3.8's fake-timer implementation. The date
|
||||||
|
* boundary is pinned with relative pubDates, never by moving the clock.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
// Point the config dir at a throwaway directory BEFORE importing the stores
|
||||||
|
// (their module-level init reads it).
|
||||||
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
|
||||||
|
import { useFeedStore } from "../src/stores/feed";
|
||||||
|
import { mergeEpisodesBounded } from "../src/utils/episode-merge";
|
||||||
|
import { episodeInWindow } from "../src/utils/feeds-persistence";
|
||||||
|
import { useAppStore } from "../src/stores/app";
|
||||||
|
import type { Episode } from "../src/types/episode";
|
||||||
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
|
||||||
|
const HOUR = 3600 * 1000;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
|
||||||
|
interface ServedEpisode {
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||||
|
let servedEpisodes: ServedEpisode[] = [];
|
||||||
|
const addedFeedIds: string[] = [];
|
||||||
|
|
||||||
|
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||||
|
const items = episodes
|
||||||
|
.map(
|
||||||
|
(ep, i) => `<item>
|
||||||
|
<title>${ep.title}</title>
|
||||||
|
<pubDate>${ep.date}</pubDate>
|
||||||
|
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||||
|
</item>`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"><channel>
|
||||||
|
<title>Volatile Show</title>
|
||||||
|
<description>Volatile merge test feed</description>
|
||||||
|
${items}
|
||||||
|
</channel></rss>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makePodcast = (feedUrl: string): Podcast => ({
|
||||||
|
id: feedUrl,
|
||||||
|
title: "Volatile Show",
|
||||||
|
description: "Volatile merge test feed",
|
||||||
|
author: "tester",
|
||||||
|
feedUrl,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeEpisode = (id: string, title: string, pubDate: Date): Episode => ({
|
||||||
|
id,
|
||||||
|
podcastId: "pod",
|
||||||
|
title,
|
||||||
|
description: "",
|
||||||
|
audioUrl: `https://example.com/${id}.mp3`,
|
||||||
|
duration: 100,
|
||||||
|
pubDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.pathname.endsWith(".xml")) {
|
||||||
|
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||||
|
headers: { "Content-Type": "application/rss+xml" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
const store = useFeedStore();
|
||||||
|
for (const id of addedFeedIds) store.removeFeed(id);
|
||||||
|
server?.stop(true);
|
||||||
|
rmSync(configHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── mergeEpisodesBounded unit tests ──────────────────────────────────────
|
||||||
|
|
||||||
|
const NOW = new Date("2026-08-10T00:00:00Z");
|
||||||
|
|
||||||
|
test("mergeEpisodesBounded dedupes on id collision and keeps the fetched copy", () => {
|
||||||
|
const existing = [
|
||||||
|
makeEpisode("a", "Old Title", new Date("2026-08-01T00:00:00Z")),
|
||||||
|
makeEpisode("b", "Ep B", new Date("2026-08-02T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const fetched = [
|
||||||
|
makeEpisode("a", "New Title", new Date("2026-08-01T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const keepAll = () => true;
|
||||||
|
|
||||||
|
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
|
||||||
|
|
||||||
|
expect(merged).toHaveLength(2);
|
||||||
|
expect(merged.find((e) => e.id === "a")!.title).toBe("New Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergeEpisodesBounded unions disjoint lists sorted newest-first", () => {
|
||||||
|
const existing = [
|
||||||
|
makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const fetched = [
|
||||||
|
makeEpisode("newest", "Newest", new Date("2026-08-03T00:00:00Z")),
|
||||||
|
makeEpisode("mid", "Mid", new Date("2026-08-02T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const keepAll = () => true;
|
||||||
|
|
||||||
|
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
|
||||||
|
|
||||||
|
expect(merged.map((e) => e.id)).toEqual(["newest", "mid", "old"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergeEpisodesBounded with count keep drops oldest beyond the count", () => {
|
||||||
|
const existing = [
|
||||||
|
makeEpisode("day1", "Day 1", new Date("2026-08-01T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const fetched = [
|
||||||
|
makeEpisode("day3", "Day 3", new Date("2026-08-03T00:00:00Z")),
|
||||||
|
makeEpisode("day2", "Day 2", new Date("2026-08-02T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const keepCount2 = (_ep: Episode, i: number) => i < 2;
|
||||||
|
|
||||||
|
const merged = mergeEpisodesBounded(existing, fetched, keepCount2);
|
||||||
|
|
||||||
|
expect(merged.map((e) => e.id)).toEqual(["day3", "day2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergeEpisodesBounded with date keep drops out-of-window and keeps undated", () => {
|
||||||
|
const existing = [
|
||||||
|
makeEpisode("fresh", "Fresh", new Date("2026-08-09T00:00:00Z")),
|
||||||
|
makeEpisode("stale", "Stale", new Date("2026-06-01T00:00:00Z")),
|
||||||
|
makeEpisode("undated", "Undated", new Date(NaN)),
|
||||||
|
];
|
||||||
|
const fetched = [
|
||||||
|
makeEpisode("newStale", "New Stale", new Date("2026-05-01T00:00:00Z")),
|
||||||
|
makeEpisode("newFresh", "New Fresh", new Date("2026-08-08T00:00:00Z")),
|
||||||
|
];
|
||||||
|
// 30-day window from NOW (2026-08-10)
|
||||||
|
const keepDate = (ep: Episode) => episodeInWindow(ep, NOW, 30);
|
||||||
|
|
||||||
|
const merged = mergeEpisodesBounded(existing, fetched, keepDate);
|
||||||
|
|
||||||
|
expect(merged.map((e) => e.id)).toEqual(["undated", "fresh", "newFresh"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergeEpisodesBounded never mutates its inputs", () => {
|
||||||
|
const existing = [
|
||||||
|
makeEpisode("a", "A", new Date("2026-08-01T00:00:00Z")),
|
||||||
|
makeEpisode("b", "B", new Date("2026-08-02T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const fetched = [
|
||||||
|
makeEpisode("a", "A (fetched)", new Date("2026-08-01T00:00:00Z")),
|
||||||
|
makeEpisode("c", "C", new Date("2026-08-03T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const existingIds = existing.map((e) => e.id);
|
||||||
|
const existingTitles = existing.map((e) => e.title);
|
||||||
|
const keepAll = () => true;
|
||||||
|
|
||||||
|
mergeEpisodesBounded(existing, fetched, keepAll);
|
||||||
|
|
||||||
|
expect(existing.map((e) => e.id)).toEqual(existingIds);
|
||||||
|
expect(existing.map((e) => e.title)).toEqual(existingTitles);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── store integration (default date mode, 60-day window) ─────────────────
|
||||||
|
|
||||||
|
test("refresh merges new episodes without removing the volatile window", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
|
||||||
|
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
|
||||||
|
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
|
||||||
|
];
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/volatile.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
const id = feed!.id;
|
||||||
|
addedFeedIds.push(id);
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(3);
|
||||||
|
const beforeUpdated = store.getFeed(id)!.lastUpdated.getTime();
|
||||||
|
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
|
||||||
|
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
|
||||||
|
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
|
||||||
|
{ title: "Ep 5", date: "2026-08-05T00:00:00Z" },
|
||||||
|
{ title: "Ep 4", date: "2026-08-04T00:00:00Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
await store.refreshFeed(id);
|
||||||
|
|
||||||
|
const afterFirst = store.getFeed(id)!;
|
||||||
|
expect(afterFirst.episodes.length).toBe(5);
|
||||||
|
expect(afterFirst.lastUpdated.getTime()).toBeGreaterThan(beforeUpdated);
|
||||||
|
|
||||||
|
// Identical second refresh: no lastUpdated bump, object identity kept.
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
await store.refreshFeed(id);
|
||||||
|
|
||||||
|
const afterSecond = store.getFeed(id)!;
|
||||||
|
expect(afterSecond).toBe(afterFirst);
|
||||||
|
expect(afterSecond.lastUpdated.getTime()).toBe(afterFirst.lastUpdated.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("date mode: episodes outside the 60-day window never enter the list", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
const now = Date.now();
|
||||||
|
// 600 episodes at 2h spacing span ~50 days — all inside the 60-day default
|
||||||
|
// window, so all 600 are cached and loadable (no count ceiling).
|
||||||
|
servedEpisodes = Array.from({ length: 600 }, (_, i) => ({
|
||||||
|
title: `Ep ${600 - i}`,
|
||||||
|
date: new Date(now - i * 2 * HOUR).toISOString(),
|
||||||
|
}));
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/date-all.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
const id = feed!.id;
|
||||||
|
addedFeedIds.push(id);
|
||||||
|
|
||||||
|
// Subscribe window (20) with more cached.
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(20);
|
||||||
|
|
||||||
|
// Load everything — the cache holds all 600 (date mode keeps them all).
|
||||||
|
let iterations = 0;
|
||||||
|
while (store.hasMoreEpisodes(id) && iterations < 20) {
|
||||||
|
await store.loadMoreEpisodes(id);
|
||||||
|
iterations++;
|
||||||
|
}
|
||||||
|
expect(store.hasMoreEpisodes(id)).toBe(false);
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("date mode boundary: 25 days in, 70 days out", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
const now = Date.now();
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "In Window", date: new Date(now - 25 * DAY).toISOString() },
|
||||||
|
{ title: "Out Window", date: new Date(now - 70 * DAY).toISOString() },
|
||||||
|
];
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/date-boundary.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
const id = feed!.id;
|
||||||
|
addedFeedIds.push(id);
|
||||||
|
|
||||||
|
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
||||||
|
"In Window",
|
||||||
|
]);
|
||||||
|
// The full cache holds both, but the visible list only shows the in-window
|
||||||
|
// one — fetch-more surfaces the out-of-window one (volatile).
|
||||||
|
expect(store.hasMoreEpisodes(id)).toBe(true);
|
||||||
|
await store.loadMoreEpisodes(id);
|
||||||
|
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
||||||
|
"In Window",
|
||||||
|
"Out Window",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── count mode ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("count mode: only N most-recent episodes are visible, but fetch-more goes beyond", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
const app = useAppStore();
|
||||||
|
app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
// 50 episodes at 1h spacing — all recent, but count mode caps at 25.
|
||||||
|
servedEpisodes = Array.from({ length: 50 }, (_, i) => ({
|
||||||
|
title: `Ep ${50 - i}`,
|
||||||
|
date: new Date(now - i * HOUR).toISOString(),
|
||||||
|
}));
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/count.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
const id = feed!.id;
|
||||||
|
addedFeedIds.push(id);
|
||||||
|
|
||||||
|
// Subscribe window (20), but the cache holds all 50 — count mode only
|
||||||
|
// bounds the visible list (25), but the full parse cache is unbounded.
|
||||||
|
// The subscribe window returns min(20, 25) = 20.
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(20);
|
||||||
|
expect(store.hasMoreEpisodes(id)).toBe(true);
|
||||||
|
|
||||||
|
// Fetch more: the visible list grows beyond the count bound — these
|
||||||
|
// episodes are volatile (held in feed.episodes, not extending the cache).
|
||||||
|
while (store.hasMoreEpisodes(id)) {
|
||||||
|
await store.loadMoreEpisodes(id);
|
||||||
|
}
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(50);
|
||||||
|
|
||||||
|
// Reset to date mode for subsequent tests.
|
||||||
|
app.updatePreferences({ episodeCacheMode: "date" });
|
||||||
|
});
|
||||||
199
tests/global-activity-indicator.test.tsx
Normal file
199
tests/global-activity-indicator.test.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Global activity indicator — the shared leak-proof activity store
|
||||||
|
* (begin/end counter + track helper) and the global top-right overlay that
|
||||||
|
* surfaces feed refresh, fetch-more, subscribe fetch, search, and download
|
||||||
|
* activity. The download transfer is left in flight on purpose (the test
|
||||||
|
* server delays its response) so the "Downloading" state is observable in
|
||||||
|
* the captured frame.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
// Point the config/data dirs at throwaway directories BEFORE importing the
|
||||||
|
// stores (their module-level init reads them) and silence the audio backend.
|
||||||
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-activity-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
const dataHome = mkdtempSync(join(tmpdir(), "podtui-activity-data-"));
|
||||||
|
process.env.XDG_DATA_HOME = dataHome;
|
||||||
|
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||||
|
|
||||||
|
import { testRender } from "@opentui/solid";
|
||||||
|
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||||
|
import { GlobalActivityIndicator } from "../src/components/GlobalActivityIndicator";
|
||||||
|
import { useActivityStore } from "../src/stores/activity";
|
||||||
|
import { useDownloadStore } from "../src/stores/download";
|
||||||
|
import type { Episode } from "../src/types/episode";
|
||||||
|
|
||||||
|
// The LoadingIndicator glyph cycle.
|
||||||
|
const SPINNER_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
|
||||||
|
|
||||||
|
type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
|
||||||
|
const frameLines = (f: Frame): string[] =>
|
||||||
|
f.lines.map((l) => l.spans.map((s) => s.text).join(""));
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<void>();
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the indicator in isolation under the dark theme. The ThemeProvider
|
||||||
|
* gates children on async init (capabilities + palette detection, up to
|
||||||
|
* ~1.5s under tmux), so settle frames until the indicator is mounted. */
|
||||||
|
async function renderIndicator() {
|
||||||
|
const setup = await testRender(
|
||||||
|
() => (
|
||||||
|
<ThemeProvider mode="dark">
|
||||||
|
<GlobalActivityIndicator />
|
||||||
|
</ThemeProvider>
|
||||||
|
),
|
||||||
|
{ width: 60, height: 10, useThread: false },
|
||||||
|
);
|
||||||
|
await setup.renderOnce();
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
await setup.renderOnce();
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
return setup;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameText = (setup: { captureSpans: () => unknown }): string =>
|
||||||
|
frameLines(setup.captureSpans() as unknown as Frame).join("\n");
|
||||||
|
|
||||||
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||||
|
let audioUrl = "";
|
||||||
|
/** Response delay (ms) for the next audio request — keeps the transfer in
|
||||||
|
* flight while the "Downloading" state is asserted. */
|
||||||
|
let audioDelayMs = 0;
|
||||||
|
/** Episode ids this file started downloads for (shared singleton cleanup). */
|
||||||
|
const downloadedEpisodeIds: string[] = [];
|
||||||
|
|
||||||
|
const makeEpisode = (id: string, title: string): Episode => ({
|
||||||
|
id,
|
||||||
|
podcastId: "pod",
|
||||||
|
title,
|
||||||
|
description: "",
|
||||||
|
audioUrl,
|
||||||
|
duration: 0,
|
||||||
|
pubDate: new Date("2026-08-01T00:00:00Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch() {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<Response>();
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
resolve(
|
||||||
|
new Response("audio bytes", {
|
||||||
|
headers: { "Content-Type": "audio/mpeg" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
audioDelayMs,
|
||||||
|
);
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
audioUrl = `http://127.0.0.1:${server!.port}/audio.mp3`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const dl = useDownloadStore();
|
||||||
|
for (const id of downloadedEpisodeIds) {
|
||||||
|
dl.cancelDownload(id);
|
||||||
|
await dl.removeDownload(id);
|
||||||
|
}
|
||||||
|
server?.stop(true);
|
||||||
|
rmSync(configHome, { recursive: true, force: true });
|
||||||
|
rmSync(dataHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("beginActivity/end pairs compose: ending one keeps the other active", () => {
|
||||||
|
const activity = useActivityStore();
|
||||||
|
expect(activity.isActive()).toBe(false);
|
||||||
|
expect(activity.labels()).toEqual([]);
|
||||||
|
|
||||||
|
const endFirst = activity.beginActivity("Refreshing");
|
||||||
|
const endSecond = activity.beginActivity("Refreshing");
|
||||||
|
expect(activity.isActive()).toBe(true);
|
||||||
|
expect(activity.labels()).toEqual(["Refreshing", "Refreshing"]);
|
||||||
|
|
||||||
|
endFirst();
|
||||||
|
expect(activity.isActive()).toBe(true);
|
||||||
|
expect(activity.labels()).toEqual(["Refreshing"]);
|
||||||
|
|
||||||
|
endSecond();
|
||||||
|
expect(activity.isActive()).toBe(false);
|
||||||
|
expect(activity.labels()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("track re-throws rejection and returns isActive() to its prior value", async () => {
|
||||||
|
const activity = useActivityStore();
|
||||||
|
const prior = activity.isActive();
|
||||||
|
await expect(
|
||||||
|
activity.track(Promise.reject(new Error("boom")), "Refreshing"),
|
||||||
|
).rejects.toThrow("boom");
|
||||||
|
expect(activity.isActive()).toBe(prior);
|
||||||
|
expect(activity.labels()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("idle: renders nothing, no spinner", async () => {
|
||||||
|
const setup = await renderIndicator();
|
||||||
|
const text = frameText(setup);
|
||||||
|
expect(text).not.toMatch(SPINNER_RE);
|
||||||
|
expect(text.trim()).toBe("");
|
||||||
|
setup.renderer.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tracked activity: spinner appears while active, vanishes on end", async () => {
|
||||||
|
const activity = useActivityStore();
|
||||||
|
const setup = await renderIndicator();
|
||||||
|
expect(frameText(setup)).not.toMatch(SPINNER_RE);
|
||||||
|
|
||||||
|
const end = activity.beginActivity("Refreshing");
|
||||||
|
await setup.renderOnce();
|
||||||
|
const active = frameText(setup);
|
||||||
|
expect(active).toMatch(SPINNER_RE);
|
||||||
|
|
||||||
|
end();
|
||||||
|
await setup.renderOnce();
|
||||||
|
const done = frameText(setup);
|
||||||
|
expect(done).not.toMatch(SPINNER_RE);
|
||||||
|
expect(done.trim()).toBe("");
|
||||||
|
setup.renderer.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("active download: spinner appears and disappears", async () => {
|
||||||
|
const dl = useDownloadStore();
|
||||||
|
const setup = await renderIndicator();
|
||||||
|
|
||||||
|
// Keep the transfer in flight while asserting; the response only lands
|
||||||
|
// after audioDelayMs, so the download stays DOWNLOADING across renders.
|
||||||
|
audioDelayMs = 400;
|
||||||
|
const episode = makeEpisode("activity-dl-ep", "DL Ep");
|
||||||
|
downloadedEpisodeIds.push(episode.id);
|
||||||
|
dl.startDownload(episode, "activity-test-feed");
|
||||||
|
|
||||||
|
await setup.renderOnce();
|
||||||
|
const during = frameText(setup);
|
||||||
|
expect(during).toMatch(SPINNER_RE);
|
||||||
|
|
||||||
|
// Cancel: the abort settles the fetch and activeCount returns to 0.
|
||||||
|
dl.cancelDownload(episode.id);
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
if (dl.getActiveCount() + dl.getQueue().length === 0) break;
|
||||||
|
await sleep(25);
|
||||||
|
}
|
||||||
|
await dl.removeDownload(episode.id);
|
||||||
|
await setup.renderOnce();
|
||||||
|
const after = frameText(setup);
|
||||||
|
expect(dl.getActiveCount() + dl.getQueue().length).toBe(0);
|
||||||
|
expect(after).not.toMatch(SPINNER_RE);
|
||||||
|
|
||||||
|
audioDelayMs = 0;
|
||||||
|
setup.renderer.destroy();
|
||||||
|
});
|
||||||
@@ -66,6 +66,7 @@ type TestPaneProps = {
|
|||||||
current?: (() => unknown) | unknown;
|
current?: (() => unknown) | unknown;
|
||||||
preview?: unknown;
|
preview?: unknown;
|
||||||
focused?: unknown;
|
focused?: unknown;
|
||||||
|
currentBorder?: unknown;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
};
|
};
|
||||||
@@ -83,6 +84,7 @@ async function renderPaneRow(props: TestPaneProps): Promise<{
|
|||||||
preview={props.preview as any}
|
preview={props.preview as any}
|
||||||
currentLabel="List"
|
currentLabel="List"
|
||||||
focused={props.focused as any}
|
focused={props.focused as any}
|
||||||
|
currentBorder={props.currentBorder as any}
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
),
|
),
|
||||||
@@ -232,4 +234,17 @@ describe("PaneRow current-pane borders", () => {
|
|||||||
expect(borderColumns(spans)).toEqual([20, 69]);
|
expect(borderColumns(spans)).toEqual([20, 69]);
|
||||||
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("currentBorder=['left'] removes the right edge (left only)", async () => {
|
||||||
|
const { spans, destroy } = await renderPaneRow({
|
||||||
|
parent: null,
|
||||||
|
current: () => <text>ITEM</text>,
|
||||||
|
preview: null,
|
||||||
|
currentBorder: ["left"],
|
||||||
|
});
|
||||||
|
cleanups.push(destroy);
|
||||||
|
// Only the left border glyph at column 20 — no right edge at 69.
|
||||||
|
expect(borderColumns(spans)).toEqual([20]);
|
||||||
|
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user