12 Commits

Author SHA1 Message Date
df4701957b refactor(feed): port refresh batch to Effect for concurrency and timeout
Replace the hand-rolled worker pool (mapWithConcurrency) with an Effect
program (src/effects/feed-refresh.ts): Effect.forEach bounds in-flight
fetches, Effect.timeout bounds each feed via the Clock service, and
failures fold to null so a bad feed never fails the batch. Per-feed
apply-as-it-lands is preserved — the apply callback runs inside each
feed's own fiber, so there is no Promise.all barrier.

Store boundary unchanged: refreshAllFeeds runs the program through
Effect.runPromise, keeping runAutoDownload + flushPendingSave after the
batch and isLoadingFeeds around it.

Adds TestClock-driven tests (tests/feed-refresh-effect.test.ts) that pin
concurrency, per-feed apply, timeout, and failure isolation without real
20s waits. Pins effect@^3 (V4 is in beta).
2026-08-13 19:34:30 -04:00
4b44623891 fix(tests): stop feed-store mock.module leak that broke the full suite
discover-store-preview mocked src/stores/feed via mock.module, which bun
applies process-globally: with workers reused across test files, every file
that later shared a worker imported the stub (fetchEpisodes only) and failed
with 'addFeed is not a function' — ~35 tests, drifting run to run with worker
scheduling. Rewrote the test against the REAL feed store and a local gated
Bun.serve server (repo-dominant harness), importing the discover store via a
query-suffixed specifier so a sibling discover-store mock cannot leak in.
Full suite: 215 pass, 0 fail (baseline: 194).
2026-08-13 18:01:40 -04:00
4ef9ab7e59 perf(ui): render only a bounded window around the focused row 2026-08-13 17:46:30 -04:00
9df8eebf6c feat(discover): drill into show episode previews without subscribing 2026-08-13 17:46:30 -04:00
badbc6a037 fix(audio): recover playback when the mpv daemon dies or restarts 2026-08-13 17:46:30 -04:00
878d1e01ab feat(feed): signature-aware volatile merge with date-banded fetch-more 2026-08-13 17:46:27 -04:00
42c48e59fb feat(feed): stable episode ids derived from guid or enclosure URL 2026-08-13 17:46:26 -04:00
91d4acca90 fix: fetch more respects episode cache mode 2026-08-12 22:28:13 -04:00
20d5b57cb6 bump VERSION to 0.6.2
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5m9s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-12 22:12:40 -04:00
d7aec4e810 fix(memory): bound visualizer PCM cache and feed episode cache
The visualizer's PCM cache decoded the entire episode into RAM (22050 Hz
mono s16 ~160 MB/hr of audio) and held it until stop() — a 3-hour episode
pinned ~500 MB and long-form content hit 2.5 GB. The 4x decode also pulled
the whole remote file even when only minutes were listened to.

- audio-pcm-cache: sliding window around the playback position — the
  decode head caps at maxAheadSec (600s) ahead of the cursor, segments
  older than keepBehindSec (300s) are pruned, and the tail refills as
  playback advances. Steady state ~40 MB regardless of episode length;
  a backward seek past the window restarts a segment there (the existing
  seek-hole mechanism, no new failure mode).
- feed: cap the full-parse episode cache at 1000 episodes/feed so
  archive-heavy subscriptions can't pin their entire history in RAM;
  the visible list stays bounded by the user's cache preference and
  fetch-more keeps working within the ceiling.
- tests: pin the new head-cap and prune contracts (8/8 in
  audio-pcm-cache.test.ts; full suite 193 pass).

Also includes the in-flight cleanup/refactor pass (cover-art resolve
helper, page and comment tightening, ESLint config removal).
2026-08-12 21:02:19 -04:00
77531ce41d bump VERSION to 0.6.1
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m21s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-12 16:34:54 -04:00
26729fa5e6 feat(feed): make episode cache bound user-configurable (date window or count)
Add episodeCacheMode/count/days preferences (default: date, 60 days).
Apply the bound when reading instead of writing, so a preference change
takes effect without a refetch; the full parse cache stays intact so
fetch-more can page beyond the bound. Thread the window through
load/saveFeedsToFile and update tests and task docs.
2026-08-12 15:41:22 -04:00
59 changed files with 3660 additions and 1054 deletions

View File

@@ -1,11 +0,0 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
env: {
es2022: true,
node: true,
},
ignorePatterns: ["dist", "node_modules"],
}

2
.gitignore vendored
View File

@@ -35,3 +35,5 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
.harness/
.ralpi
notes.md
# pygienium run-state and check artifacts
.pygienium/

View File

@@ -15,7 +15,7 @@
- `bun tests/cavacore-smoke.ts` - Run specific native library smoke test
### Linting
- `bun run lint` - Run ESLint with TypeScript rules
- `bun run lint` - Run the TypeScript typecheck (`bun tsc --noEmit`)
## Code Style Guidelines

BIN
bun.lockb

Binary file not shown.

View File

@@ -18,15 +18,13 @@
},
"devDependencies": {
"@types/bun": "latest",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"eslint": "^9.39.2",
"typescript": "^5.9.3"
},
"dependencies": {
"@opentui/core": "^0.1.77",
"@opentui/solid": "^0.1.77",
"date-fns": "^4.1.0",
"effect": "^3",
"solid-js": "^1.9.9"
}
}

View File

@@ -74,6 +74,43 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
return undefined
}
/** FNV-1a 32-bit hash. Deterministic across processes and Bun versions
* (unlike Bun.hash) — used to derive stable episode ids from audio URLs so
* a feed's episode ids never change between refreshes. */
const fnv1a = (input: string): number => {
let hash = 0x811c9dc5
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return hash >>> 0
}
/**
* Stable per-episode identity. The old positional id (`feedUrl#index`) was
* invalidated by ANY feed change: a new episode or a pruned one shifted
* every episode's index, so progress/downloads saved under `feedUrl#5`
* attached to whatever episode now sat at index 5 — new episodes resumed
* minutes in. Identity derives from stable content instead:
* 1. `<guid>` — the canonical per-episode identifier (required by Apple
* Podcasts; nearly universal).
* 2. The enclosure URL, hashed to keep the id compact (hosts serve
* permanent per-episode URLs; guids can be absent in hand-rolled feeds).
* 3. Positional index as a last resort: no guid AND no audio URL means
* the episode cannot be played, so nothing persistent keys off it.
*/
const stableEpisodeId = (
feedUrl: string,
item: string,
audioUrl: string,
index: number,
): string => {
const guid = getTagValue(item, "guid")
if (guid) return `${feedUrl}#guid:${guid}`
if (audioUrl) return `${feedUrl}#url:${fnv1a(audioUrl).toString(36)}`
return `${feedUrl}#${index}`
}
/** 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
@@ -100,24 +137,20 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis
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
@@ -126,7 +159,7 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
const ep: Episode = {
id: `${feedUrl}#${index}`,
id: stableEpisodeId(feedUrl, item, audioUrl, index),
podcastId: feedUrl,
title: epTitle,
description: epDescription,
@@ -135,7 +168,6 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis
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

View File

@@ -0,0 +1,246 @@
/**
* Shared list-row and preview components for the Feed and My Shows pages.
*
* Both pages render the same episode rows (marker + title, optional subtitle
* line, date/duration/selection/download meta line), "[Fetch More]" rows, and
* hovered-episode / fetch-more preview panes; the pages differ only in the
* props they pass (subtitle line, hint text, manual-mode wording). Extracted
* so the previously 3-4-level-nested render blocks run as flat named
* components.
*
* Anything that can change at runtime arrives as a signal getter: Solid
* components do not re-render, so only props that are called inside the
* component's own JSX stay reactive (focus, selection, download state).
*/
import { Show } from "solid-js";
import { format } from "date-fns";
import type { RGBA } from "@opentui/core";
import { useTheme } from "@/context/ThemeContext";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { NF_ICONS } from "@/utils/nerd-fonts";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import type { Episode } from "@/types/episode";
// ── formatting helpers ──────────────────────────────────────────────────────
export const formatDate = (d: Date) => format(d, "MMM d, yyyy");
export const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
// ── EpisodeRow ──────────────────────────────────────────────────────────────
export function EpisodeRow(props: {
/** The episode this row renders. */
episode: Episode;
/** Optional second line under the title (podcast/show name). */
subtitle?: () => string | undefined;
/** For index signal (row position). */
index: () => number;
/** Focused row index in this list (-1 while the Fetch More row is
* focused, so no episode row draws the cursor). */
focused: () => number;
/** Whether the current pane has keyboard focus. */
active: () => boolean;
/** Whether this episode is selection-marked. */
selected: () => boolean;
downloadLabel: () => string;
downloadColor: () => RGBA;
marker: () => string;
onMouseDown: () => void;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const ref = useScrollIntoView(() => props.index() === props.focused());
const isFocused = () => props.index() === props.focused();
const bg = () =>
isFocused() && props.active()
? theme.primary
: isFocused()
? theme.border
: undefined;
const fg = () =>
isFocused() && props.active()
? theme.surface
: isFocused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={fg()}>
{isFocused() ? props.marker() : " "}
</text>
<text wrapMode="none" truncate fg={fg()}>
{props.episode.episodeNumber ? `#${props.episode.episodeNumber} ` : ""}
{props.episode.title}
</text>
</box>
{/* podcast name on its own row — readable at a glance; the 50%
current pane fits it in full for typical names, and truncate
keeps the row one line tall either way */}
<Show when={props.subtitle?.()}>
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={isFocused() ? theme.surface : theme.textSecondary}
>
{props.subtitle?.()}
</text>
</box>
</Show>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text flexShrink={0} fg={isFocused() ? theme.surface : theme.info}>
{formatDate(props.episode.pubDate)}
</text>
<text flexShrink={0} fg={isFocused() ? theme.surface : muted()}>
{formatDuration(props.episode.duration)}
</text>
<Show when={props.selected()}>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={props.downloadLabel()}>
<text flexShrink={0} fg={props.downloadColor()}>
{props.downloadLabel()}
</text>
</Show>
</box>
</box>
);
}
// ── FetchMoreRow ────────────────────────────────────────────────────────────
export function FetchMoreRow(props: {
/** Row index of the Fetch More button within the list. */
index: () => number;
/** Focused row index. */
focused: () => number;
/** True while the Fetch More row itself is focused. */
onMore: () => boolean;
/** Whether the current pane has keyboard focus. */
active: () => boolean;
isLoadingMore: () => boolean;
nerd: boolean;
marker: () => string;
onMouseDown: () => void;
}) {
const { theme } = useTheme();
const ref = useScrollIntoView(props.onMore);
const bg = () =>
props.index() === props.focused() && props.active()
? theme.primary
: props.index() === props.focused()
? theme.border
: undefined;
const fg = () =>
props.index() === props.focused() && props.active()
? theme.surface
: props.index() === props.focused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<text fg={fg()}>{props.onMore() ? props.marker() : " "}</text>
{props.nerd && (
<text fg={fg()}>{NF_ICONS.more}</text>
)}
<Show
when={!props.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={fg()}>[Fetch More]</text>
</Show>
</box>
);
}
// ── EpisodePreview ──────────────────────────────────────────────────────────
export function EpisodePreview(props: {
episode: () => Episode;
/** Optional line under the meta row (podcast/show name). */
subtitle?: () => string | undefined;
author: () => string | undefined;
downloadLabel: () => string;
downloadColor: () => RGBA;
/** Page-specific action-hint line. */
hint: () => string;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{props.episode().episodeNumber ? `#${props.episode().episodeNumber} ` : ""}
{props.episode().title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(props.episode().pubDate)}</text>
<text fg={muted()}>{formatDuration(props.episode().duration)}</text>
<Show when={props.downloadLabel()}>
<text fg={props.downloadColor()}>{props.downloadLabel()}</text>
</Show>
</box>
<Show when={props.subtitle?.()}>
<text fg={muted()}>{props.subtitle?.()}</text>
</Show>
<Show when={props.author()}>
<text fg={muted()}>by {props.author()}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{props.episode().description?.slice(0, 400) ?? "No description available."}
{(props.episode().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>{props.hint()}</text>
</box>
);
}
// ── FetchMorePreview ────────────────────────────────────────────────────────
export function FetchMorePreview(props: {
isLoadingMore: () => boolean;
fetchMoreMode: () => string;
/** Manual-mode explanation line ("across all feeds" vs "for this show"). */
manualText: () => string;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>[Fetch More]</strong>
</text>
<text fg={muted()}>
{props.isLoadingMore()
? "Loading the next batch of episodes…"
: props.fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: props.manualText()}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>
</box>
);
}

View File

@@ -169,7 +169,6 @@ export function Shell() {
nav.backspaceCommand();
return;
}
// printable char
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
evt.preventDefault();
nav.appendCommand(evt.name);

View File

@@ -67,7 +67,7 @@
"sort": [","],
"toggle-hidden": ["."],
"refresh": ["r"],
"subscribe": ["a"], // subscribe focused show/episode result in place (Search)
"subscribe": ["a"], // subscribe focused show in place (Discover/Search)
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
// ── Downloads & auto-download whitelist ───────────────────────────────────

View File

@@ -10,7 +10,8 @@ import {
generateSyntax,
generateSubtleSyntax,
} from "../utils/syntax-highlighter";
import { resolveTerminalTheme, loadThemes } from "../utils/theme";
import { resolveTerminalTheme } from "../utils/theme";
import { getCustomThemes } from "../utils/custom-themes";
import { detectModeFromBackground } from "../utils/system-theme";
import { createSimpleContext } from "./helper";
import {
@@ -175,7 +176,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
function init() {
resolveSystemTheme();
loadThemes()
getCustomThemes()
.then((custom) => {
setStore(
produce((draft) => {
@@ -187,7 +188,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore("active", "catppuccin");
})
.finally(() => {
// Only set ready if not waiting for system theme
if (store.active !== "system") {
setStore("ready", true);
}

View File

@@ -0,0 +1,85 @@
/**
* Feed-refresh batch as an Effect program.
*
* Replaces the hand-rolled worker pool (mapWithConcurrency) + per-feed
* fetch/apply plumbing in stores/feed.ts with Effect's structured
* concurrency:
* - `Effect.forEach(..., { concurrency })` bounds in-flight fetches to
* `concurrency` (starts exactly that many fibers; each completion pulls
* the next feed — identical semantics to the old shared-counter pool).
* - `Effect.timeout` bounds each feed's fetch to `timeoutMs`. It runs
* through the `Clock` service, so under `TestContext` the TestClock
* drives it deterministically (no real 20s wait in tests).
* - Failures are folded to a null result: a failed or timed-out feed is
* left untouched instead of failing the batch.
* - The apply callback runs inside each feed's own fiber, so a feed's
* refreshed episodes land AS ITS OWN FETCH COMPLETES — the
* per-feed-apply-as-it-lands contract, no Promise.all barrier.
*
* The store boundary (stores/feed.ts) supplies the real fetch and apply
* closures and runs the program with Effect.runPromise.
*/
import { Duration, Effect } from "effect"
import type { Episode } from "../types/episode"
import type { Feed } from "../types/feed"
/** Result of fetching one feed's RSS. `episodes: null` means the fetch
* failed or timed out — callers must leave that feed untouched. */
export interface RefreshFetchResult {
episodes: Episode[] | null
coverUrl: string | undefined
}
/** Result guaranteed to have parsed episodes (the apply path only). */
export interface RefreshSuccess {
episodes: Episode[]
coverUrl: string | undefined
}
export interface RefreshBatchOptions {
/** Max simultaneous in-flight fetches. */
concurrency: number
/** Per-feed fetch timeout in milliseconds. */
timeoutMs: number
}
/** Fold any failure (network error, timeout, rejection) to a null result so
* one bad feed can never fail the batch. */
const failedResult: RefreshFetchResult = { episodes: null, coverUrl: undefined }
/** Fetch one feed with a timeout, applying its result as its own fetch
* lands. A failed or timed-out fetch yields null — the feed is untouched. */
const refreshOne = (
feed: Feed,
fetchOne: (feed: Feed) => Promise<RefreshFetchResult>,
applyOne: (feed: Feed, result: RefreshSuccess) => void,
timeoutMs: number,
): Effect.Effect<void> =>
Effect.tryPromise(() => fetchOne(feed)).pipe(
Effect.timeout(Duration.millis(timeoutMs)),
Effect.catchAll(() => Effect.succeed(failedResult)),
Effect.flatMap((result) => {
if (result.episodes === null) return Effect.void
// Capture the narrowed array before the closure — TS drops the
// `episodes !== null` narrowing inside Effect.sync's callback.
const episodes = result.episodes
return Effect.sync(() => applyOne(feed, { episodes, coverUrl: result.coverUrl }))
}),
)
/** Refresh every feed with bounded concurrency. Each feed's refreshed
* episodes are applied as its own fetch lands (no barrier); a failed or
* timed-out feed is left untouched. The program never fails — failures
* are folded to per-feed no-ops. */
export const refreshFeedsBatch = (
feeds: readonly Feed[],
fetchOne: (feed: Feed) => Promise<RefreshFetchResult>,
applyOne: (feed: Feed, result: RefreshSuccess) => void,
options: RefreshBatchOptions,
): Effect.Effect<void> =>
Effect.forEach(
feeds,
(feed) => refreshOne(feed, fetchOne, applyOne, options.timeoutMs),
{ concurrency: options.concurrency, discard: true },
)

View File

@@ -20,6 +20,7 @@ import {
import {
createAudioBackend,
detectPlayers,
PlayerRestartedError,
type AudioBackend,
type BackendName,
type DetectedPlayer,
@@ -297,6 +298,34 @@ function stopPolling(): void {
// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the
// podcast cover to a temp file BEFORE playback starts, bounded to 3s.
/** Resolve cover art to a local path for mpv's --cover-art-files, per the
* call site's latency budget:
* "cache" — disk cache only (sync): resume paths must never wait on the
* network, so a miss plays artless and warms for next time.
* "bounded" — disk hit, else fetch capped at 1.2s: cold play needs the art
* at file LOAD, but a slow cover server must not stall audio.
* "await" — disk hit, else full (8s-bounded) fetch: boot restore preloads
* while feeds/progress load anyway, so the wait is free and the
* cover must be present when the file loads.
* fetchCoverArt already short-circuits on the disk cache, so "await" costs
* nothing on a warm cache. */
async function resolveCoverArt(
coverUrl: string | undefined,
mode: "cache" | "bounded" | "await",
): Promise<string | null> {
if (!coverUrl) return null;
if (mode === "cache") return cachedCoverPath(coverUrl);
if (mode === "bounded") {
const cached = cachedCoverPath(coverUrl);
if (cached) return cached;
return Promise.race([
fetchCoverArt(coverUrl),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1200)),
]);
}
return fetchCoverArt(coverUrl);
}
async function play(episode: Episode): Promise<void> {
const b = ensureBackend();
setError(null);
@@ -321,21 +350,15 @@ async function play(episode: Episode): Promise<void> {
// episode's own image (feeds added by URL may lack a channel cover).
const downloadStore = useDownloadStore();
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
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;
}
// miss, await the bounded fetch (covers fetch in ~300ms typically) —
// past the 1.2s cap, play bare and let the fetch warm the cache.
const coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? episode.imageUrl,
"bounded",
);
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id);
@@ -436,8 +459,10 @@ async function load(episode: Episode): Promise<void> {
// 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 coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? episode.imageUrl,
"await",
);
const backendSnap = backend;
backendSnap
.preload(url, {
@@ -478,8 +503,25 @@ async function pause(): Promise<void> {
}
}
/** mpv was killed/crashed: respawn it and restart playback from the saved
* position via the full play path (fresh loadfile, cover art, media
* registry). A bare unpause would target a dead — or freshly-idle —
* daemon and silently do nothing. */
async function recoverPlayback(): Promise<void> {
const ep = currentEpisode();
if (ep && ep.audioUrl) {
await play(ep);
} else {
setError("Player is not running");
}
}
async function resume(): Promise<void> {
if (!backend) return;
if (!backend.isAlive()) {
await recoverPlayback();
return;
}
try {
await backend.resume();
setIsPlaying(true);
@@ -491,6 +533,13 @@ async function resume(): Promise<void> {
media.setPlaybackState(true);
}
} catch (err) {
// Race: the daemon died between the liveness check above and the
// unpause — backend.resume() respawned it and threw
// PlayerRestartedError (the fresh daemon has no file loaded).
if (err instanceof PlayerRestartedError) {
await recoverPlayback();
return;
}
setError(err instanceof Error ? err.message : "Resume failed");
}
}
@@ -612,8 +661,10 @@ async function switchBackend(name: BackendName): Promise<void> {
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const url =
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
const coverUrl = feed?.podcast.coverUrl ?? ep.imageUrl;
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
const coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? ep.imageUrl,
"cache",
);
await backend.play(url, {
startPosition: pos,
volume: vol,

View File

@@ -1,7 +1,7 @@
import type { Feed } from "./types/feed"
import type { Episode } from "./types/episode"
const VERSION = "0.6.0";
const VERSION = "0.6.2";
interface CliArgs {
version: boolean;
@@ -42,7 +42,6 @@ if (cliArgs.version) {
// ── CLI handlers ──────────────────────────────────────────────────────
/** Find the most recent episode across all feeds */
function findLatestEpisode(
feeds: Feed[],
): { feed: Feed; episode: Episode } | null {

View File

@@ -5,18 +5,28 @@
* placeholder (1/5 slot kept).
* depth 1 (current) — podcast results for the drilled category. Parent
* pane = the categories list.
* preview — detail of the hovered item (category summary, or
* podcast detail + subscribe action).
* depth 2 (current) — episodes of the drilled show, fetched on demand
* WITHOUT subscribing. Parent pane = the results list.
* preview — detail of the hovered item (category summary,
* podcast detail, or episode detail).
*
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
* remains. `l`/Enter drills in (category → results) or subscribes (on a
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
* column. Moving through categories at depth 0 updates the store's selected
* category so the preview follows.
* remains. `l`/Enter drills in (category → results → episodes); `a`
* subscribes the focused show (enter/l never subscribe — they open the
* episode list); `h` pops a depth (noop at 0). j/k move only within the
* current column. Moving through categories at depth 0 updates the store's
* selected category so the preview follows.
*/
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { useAudio } from "@/hooks/useAudio";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { DownloadStatus } from "@/types/episode";
import type { Episode } from "@/types/episode";
import type { Podcast } from "@/types/podcast";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import {
@@ -32,6 +42,7 @@ import type { KeybindActionName } from "@/context/KeybindContext";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { EpisodeRow, EpisodePreview } from "@/components/EpisodeList";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
@@ -41,11 +52,16 @@ function DiscoverPage() {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const discoverStore = useDiscoverStore();
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audio = useAudio();
const audioNav = useAudioNavStore();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const marker = useSelectionMarker();
const stack = nav.depthStack;
const depth = nav.currentDepth;
const focus = (d: number = depth()) => nav.depthFocus(d);
@@ -60,14 +76,37 @@ function DiscoverPage() {
podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]);
// depth-2 frame ctx = the drilled podcast id (episode preview, no
// subscription). Episodes come from the discover store's session cache.
const drilledPodcastId = (): string => stack()[2]?.ctx ?? "";
const drilledPodcast = (): Podcast | undefined =>
podcasts().find((p) => p.id === drilledPodcastId());
const episodes = createMemo<Episode[]>(() => {
if (depth() < 2) return [];
return discoverStore.episodesForPodcast(drilledPodcastId());
});
const episodesLoading = () =>
depth() >= 2 && discoverStore.isLoadingEpisodesFor(drilledPodcastId());
const episodesError = () =>
depth() >= 2 ? discoverStore.previewError(drilledPodcastId()) : undefined;
const focusedEpIdx = () =>
episodes().length === 0 ? 0 : Math.min(focus(2), episodes().length - 1);
const focusedEpisode = () => episodes()[focusedEpIdx()];
const curLen = () =>
depth() === 0 ? categories().length : podcasts().length;
depth() === 0
? categories().length
: depth() === 1
? podcasts().length
: episodes().length;
const ensureFocus = () => {
if (categories().length > 0 && focus(0) >= categories().length)
nav.setDepthFocus(categories().length - 1, 0);
if (podcasts().length > 0 && focus(1) >= podcasts().length)
nav.setDepthFocus(podcasts().length - 1, 1);
if (episodes().length > 0 && focus(2) >= episodes().length)
nav.setDepthFocus(episodes().length - 1, 2);
};
onMount(ensureFocus);
@@ -80,13 +119,56 @@ function DiscoverPage() {
onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) return categories()[i]?.id;
return podcasts()[i]?.id;
if (depth() === 1) return podcasts()[i]?.id;
return episodes()[i]?.id;
});
});
// ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
/** The subscribed feed backing a podcast, if any (matched by directory id
* or feed URL — a Discover show may already be subscribed). */
const feedForPodcast = (p: Podcast) =>
feedStore.feeds().find(
(f) =>
f.podcast.id === p.id ||
(!!p.feedUrl && f.podcast.feedUrl === p.feedUrl),
);
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING:
return `[${downloadStore.getDownloadProgress(id)}%]`;
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
const downloadColor = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return theme.warning;
case DownloadStatus.DOWNLOADING:
return theme.primary;
case DownloadStatus.COMPLETED:
return theme.success;
case DownloadStatus.FAILED:
return theme.error;
default:
return muted();
}
};
const playEpisode = (ep: Episode) => {
audio.play(ep).catch(() => {});
audioNav.setSource(AudioSource.SEARCH, drilledPodcast()?.id);
};
// ── drill / open ───────────────────────────────────────────────────────────
function open() {
if (depth() === 0) {
@@ -97,9 +179,19 @@ function DiscoverPage() {
nav.setActivePane(DEPTH_CENTER_PANE);
return;
}
if (depth() >= 1) {
if (depth() === 1) {
const pod = focusedPodcast();
if (pod) discoverStore.toggleSubscription(pod.id);
if (!pod) return;
// Drill into the show's episode list WITHOUT subscribing — `l`,
// right, and Enter open the episodes; `a` is the subscribe key.
discoverStore.openEpisodes(pod).catch(() => {});
nav.pushDepth({ kind: "episodes", ctx: pod.id, focus: 0 } as DepthFrame);
nav.setActivePane(DEPTH_CENTER_PANE);
return;
}
if (depth() >= 2) {
const ep = focusedEpisode();
if (ep) playEpisode(ep);
}
}
@@ -115,12 +207,59 @@ function DiscoverPage() {
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
open: () => open(),
"toggle-select": () => {
if (depth() >= 1) {
if (depth() === 1) {
const pod = focusedPodcast();
if (pod) nav.toggleSelected(pod.id);
}
if (depth() >= 2) {
const ep = focusedEpisode();
if (ep) nav.toggleSelected(ep.id);
}
},
download: () => {
if (depth() !== 2) return;
const pod = drilledPodcast();
const ep = focusedEpisode();
if (!pod || !ep) return;
// Under its subscribed feed when already subscribed, otherwise as
// an "unsubscribed show" download (mirrors Search).
const feed = feedForPodcast(pod);
if (feed) downloadStore.startDownload(ep, feed.id);
else downloadStore.startUnsubscribedDownload(ep, pod);
},
"delete-download": () => {
if (depth() !== 2) return;
const ep = focusedEpisode();
if (!ep) return;
const id = ep.id;
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
downloadStore.cancelDownload(id);
downloadStore.removeDownload(id).catch(() => {});
},
// `a`/`x` — the dedicated subscribe/unsubscribe keys (enter/l now open
// the episode list, so subscribing moved off open).
subscribe: () => {
if (depth() === 1) {
const pod = focusedPodcast();
if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id);
return;
}
if (depth() >= 2) {
const pod = drilledPodcast();
if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id);
}
},
unsubscribe: () => {
if (depth() !== 1) return;
const pod = focusedPodcast();
if (pod?.isSubscribed) discoverStore.unsubscribe(pod.id);
},
refresh: () => {
if (depth() >= 2) {
const pod = drilledPodcast();
if (pod) discoverStore.refreshEpisodes(pod).catch(() => {});
return;
}
discoverStore.refresh().catch(() => {});
},
};
@@ -161,7 +300,9 @@ function DiscoverPage() {
const currentLabel = () =>
depth() === 0
? "Categories"
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
: depth() === 1
? `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`
: `${drilledPodcast()?.title ?? "Episodes"} · ${episodes().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// Sibling <Show> blocks per depth (the known-good opentui disposal
@@ -174,7 +315,7 @@ function DiscoverPage() {
<Show when={depth() === 0}>
<TabListPane muted />
</Show>
<Show when={depth() >= 1}>
<Show when={depth() === 1}>
<For each={categories()}>
{(cat, index) => {
const lf = () => nav.depthFocus(0);
@@ -203,6 +344,33 @@ function DiscoverPage() {
}}
</For>
</Show>
<Show when={depth() >= 2}>
<For each={podcasts()}>
{(podcast, index) => {
const lf = () => nav.depthFocus(1);
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), false)}
>
<text fg={focusFg(index(), lf(), false)}>
{index() === lf() ? marker() : " "}
</text>
<text wrapMode="none" truncate fg={focusFg(index(), lf(), false)}>
{podcast.title}
</text>
<Show when={podcast.isSubscribed}>
<text flexShrink={0} fg={muted()}>[+]</text>
</Show>
</box>
);
}}
</For>
</Show>
</>
);
@@ -243,7 +411,7 @@ function DiscoverPage() {
</For>
</Show>
{/* depth ≥1: results */}
<Show when={depth() >= 1}>
<Show when={depth() === 1}>
<Show
when={podcasts().length > 0}
fallback={
@@ -304,11 +472,59 @@ function DiscoverPage() {
</For>
<Show when={discoverStore.isLoading()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator label="Refreshing…" />
<LoadingIndicator />
</box>
</Show>
</Show>
</Show>
{/* depth ≥2: episodes of the drilled show (preview, no subscription) */}
<Show when={depth() >= 2}>
<Show when={episodesLoading()}>
<box padding={1}>
<LoadingIndicator label="Loading episodes…" />
</box>
</Show>
<Show when={episodesError() && !episodesLoading()}>
<box padding={1}>
<text fg={theme.error}>{episodesError()}</text>
<box height={1} />
<text fg={muted()}>r: retry · h: back</text>
</box>
</Show>
<Show
when={
!episodesLoading() && !episodesError() && episodes().length === 0
}
>
<box padding={1}>
<text fg={muted()}>No episodes found. :refresh</text>
</box>
</Show>
<Show
when={
!episodesLoading() && !episodesError() && episodes().length > 0
}
>
<For each={episodes()}>
{(ep, index) => (
<EpisodeRow
episode={ep}
index={index}
focused={focusedEpIdx}
active={isActive}
selected={() => nav.isSelected(ep.id)}
downloadLabel={() => downloadLabel(ep.id)}
downloadColor={() => downloadColor(ep.id)}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 2);
}}
/>
)}
</For>
</Show>
</Show>
</>
);
@@ -357,8 +573,8 @@ function DiscoverPage() {
</box>
)}
</Show>
) : (
// depth 1 preview: hovered podcast + subscribe
) : depth() === 1 ? (
// depth 1 preview: hovered podcast + episode-list hint
<Show
when={focusedPodcast()}
fallback={
@@ -376,10 +592,10 @@ function DiscoverPage() {
<text fg={muted()}>by {pod().author}</text>
</Show>
<Show when={pod().isSubscribed}>
<text fg={theme.success}> Subscribed</text>
<text fg={theme.success}> Subscribed · x: unsubscribe</text>
</Show>
<Show when={!pod().isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
<text fg={theme.primary}>a: subscribe</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
@@ -398,10 +614,67 @@ function DiscoverPage() {
</Show>
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
<box height={1} />
<text fg={muted()}>enter: subscribe · h: back · r: refresh</text>
<text fg={muted()}>enter/l: episodes · h: back · r: refresh</text>
</box>
)}
</Show>
) : (
// depth ≥2 preview: hovered episode (or loading/error/empty)
<>
<Show when={episodesLoading()}>
<box padding={1}>
<LoadingIndicator label="Loading episodes…" />
</box>
</Show>
<Show when={episodesError() && !episodesLoading()}>
<box padding={1}>
<text fg={theme.error}>{episodesError()}</text>
<box height={1} />
<text fg={muted()}>r: retry · h: back</text>
</box>
</Show>
<Show
when={
!episodesLoading() && !episodesError() && episodes().length === 0
}
>
<box padding={1}>
<text fg={muted()}>No episodes found.</text>
</box>
</Show>
<Show
when={
!episodesLoading() &&
!episodesError() &&
episodes().length > 0 &&
focusedEpisode()
}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(ep) => (
<EpisodePreview
episode={() => ep()}
author={() => drilledPodcast()?.author}
downloadLabel={() => downloadLabel(ep().id)}
downloadColor={() => downloadColor(ep().id)}
hint={() =>
`enter: play · d: download${
downloadStore.getDownloadStatus(ep().id) !==
DownloadStatus.NONE
? " · D: delete"
: ""
}${
drilledPodcast()?.isSubscribed ? "" : " · a: subscribe"
} · h: back`
}
/>
)}
</Show>
</>
);
return (

View File

@@ -22,7 +22,6 @@ import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
import { prefetchCoverArt } from "@/utils/cover-art";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import {
@@ -33,14 +32,19 @@ import {
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import { supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import {
EpisodeRow,
FetchMoreRow,
EpisodePreview,
FetchMorePreview,
} from "@/components/EpisodeList";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const FeedPaneCount = 1;
@@ -87,7 +91,6 @@ function FeedPage() {
const app = useAppStore();
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
const showFetchMore = () => feedStore.hasMoreAcrossAll();
// Total navigable rows: episodes + the optional Fetch More row.
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focus = () => nav.depthFocus(0);
const focusedRow = () =>
@@ -103,7 +106,30 @@ function FeedPage() {
const focusedItem = (): EpItem | undefined =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const curLen = () => rowCount();
const moreRef = useScrollIntoView(() => focusedOnMore());
// ── Render window ────────────────────────────────────────────────────────
// The union grows to thousands of episodes after repeated fetch-more
// presses; rendering every row per frame froze the UI. Render only a
// bounded slice around the focus (real indexes preserved) — the scrollbox
// still keeps the focused row in view. Spacers above/below the window
// restore the full content height so the scrollbar tracks the real list.
// Each EpisodeRow is 3 lines tall (title, subtitle, date).
const LIST_WINDOW = 30;
const ROW_HEIGHT = 3;
const listWindow = createMemo<[number, number]>(() => {
const len = episodes().length;
// Focusing the Fetch More button keeps the window anchored at the
// last episode — no jump when the focus crosses onto the button.
const f = focusedOnMore() ? len - 1 : focusedEpIdx();
return [
Math.max(0, f - LIST_WINDOW),
Math.min(len, f + LIST_WINDOW + 1),
];
});
const visibleEpisodes = createMemo(() => {
const [start, end] = listWindow();
return episodes().slice(start, end);
});
const ensureFocus = () => {
if (rowCount() > 0 && focus() >= rowCount())
@@ -129,12 +155,6 @@ function FeedPage() {
});
// ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
@@ -229,19 +249,6 @@ function FeedPage() {
// ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
// Row highlight within the list. `active=true` only for the current pane.
const focusBg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.primary
: i === listFocus
? theme.border
: undefined;
const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.surface
: i === listFocus
? theme.selectedListItemText ?? theme.text
: theme.text;
const currentLabel = () => `Feed · ${episodes().length}`;
@@ -262,141 +269,79 @@ function FeedPage() {
</text>
}
>
<LoadingIndicator label="Refreshing…" />
<LoadingIndicator />
</Show>
</box>
}
>
<For each={episodes()}>
{(item, index) => {
const fi = () => focusedEpIdx();
const ref = useScrollIntoView(() => index() === fi());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={focusBg(index(), fi(), isActive())}
{/* Spacers keep the scrollbox content at the FULL list height so
the scrollbar reflects the real list, not the render window. */}
<Show when={listWindow()[0] > 0}>
<box height={listWindow()[0] * ROW_HEIGHT} />
</Show>
<For each={visibleEpisodes()}>
{(item, index) => (
<EpisodeRow
episode={item.episode}
subtitle={() => item.feed.customName || item.feed.podcast.title}
index={() => listWindow()[0] + index()}
focused={focusedEpIdx}
active={isActive}
selected={() => nav.isSelected(item.episode.id)}
downloadLabel={() => downloadLabel(item.episode.id)}
downloadColor={() => downloadColor(item.episode.id)}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
fg={focusFg(index(), fi(), isActive())}
>
{index() === fi() ? marker() : " "}
</text>
<text
wrapMode="none"
truncate
fg={focusFg(index(), fi(), isActive())}
>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
</box>
{/* podcast name on its own row — readable at a glance; the
50% current pane fits it in full for typical names, and
truncate keeps the row one line tall either way */}
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={index() === fi() ? theme.surface : theme.textSecondary}
>
{item.feed.customName || item.feed.podcast.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
flexShrink={0}
fg={index() === fi() ? theme.surface : theme.info}
>
{formatDate(item.episode.pubDate)}
</text>
<text
flexShrink={0}
fg={index() === fi() ? theme.surface : muted()}
>
{formatDuration(item.episode.duration)}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text flexShrink={0} fg={downloadColor(item.episode.id)}>
{downloadLabel(item.episode.id)}
</text>
</Show>
</box>
</box>
);
nav.setDepthFocus(listWindow()[0] + index(), 0);
}}
/>
)}
</For>
<Show when={episodes().length - listWindow()[1] > 0}>
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
</Show>
<Show when={showFetchMore()}>
<box
ref={moreRef}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())}
<FetchMoreRow
index={() => episodes().length}
focused={focusedRow}
onMore={focusedOnMore}
active={isActive}
isLoadingMore={() => feedStore.isLoadingMore()}
nerd={nerd}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(episodes().length, 0);
}}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{focusedOnMore() ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{NF_ICONS.more}
</text>
)}
<Show
when={!feedStore.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
[Fetch More]
</text>
</Show>
</box>
/>
</Show>
<Show when={feedStore.isLoadingFeeds()}>
<box alignItems="center" paddingTop={1}>
<LoadingIndicator label="Refreshing…" />
<LoadingIndicator />
</box>
</Show>
</Show>
);
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
const episodeHint = (item: EpItem) =>
`enter: play · d: download${
downloadStore.getDownloadStatus(item.episode.id) !== DownloadStatus.NONE
? " · D: delete"
: ""
} · space: select · h back`;
const previewContent = () => (
<>
<Show when={focusedOnMore()}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>[Fetch More]</strong>
</text>
<text fg={muted()}>
{feedStore.isLoadingMore()
? "Loading the next batch of episodes…"
: fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: "Load the next batch of older episodes across all feeds (Enter)."}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>
</box>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
fetchMoreMode={fetchMoreMode}
manualText={() =>
"Load the next batch of older episodes across all feeds (Enter)."
}
/>
</Show>
<Show when={!focusedOnMore()}>
<Show
@@ -408,46 +353,16 @@ function FeedPage() {
}
>
{(item) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{item().episode.episodeNumber
? `#${item().episode.episodeNumber} `
: ""}
{item().episode.title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
<Show when={downloadLabel(item().episode.id)}>
<text fg={downloadColor(item().episode.id)}>
{downloadLabel(item().episode.id)}
</text>
</Show>
</box>
<text fg={muted()}>
{item().feed.customName || item().feed.podcast.title}
</text>
<Show when={item().feed.podcast.author}>
<text fg={muted()}>by {item().feed.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{item().episode.description?.slice(0, 400) ??
"No description available."}
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>
enter: play · d: download
{downloadStore.getDownloadStatus(item().episode.id) !==
DownloadStatus.NONE
? " · D: delete"
: ""}{" "}
· space: select · h back
</text>
</box>
<EpisodePreview
episode={() => item().episode}
subtitle={() =>
item().feed.customName || item().feed.podcast.title
}
author={() => item().feed.podcast.author}
downloadLabel={() => downloadLabel(item().episode.id)}
downloadColor={() => downloadColor(item().episode.id)}
hint={() => episodeHint(item())}
/>
)}
</Show>
</Show>

View File

@@ -15,11 +15,11 @@
*/
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
import type { RGBA } from "@opentui/core";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import {
@@ -31,16 +31,218 @@ import {
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import { supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode, DownloadedEpisode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import {
EpisodeRow,
FetchMoreRow,
EpisodePreview,
FetchMorePreview,
formatDate,
} from "@/components/EpisodeList";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
// ── render components ────────────────────────────────────────────────────────
// Depth-0 rows (subscribed shows, unsubscribed-show downloads) and their
// preview panes are My Shows-specific; episode rows/previews are shared with
// the Feed page (see EpisodeList.tsx).
/** A subscribed-show row (depth 0). */
function ShowRow(props: {
feed: Feed;
title: string;
index: () => number;
focused: () => number;
active: () => boolean;
marker: () => string;
wlScope: () => boolean;
wlInList: () => boolean;
onMouseDown: () => void;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const ref = useScrollIntoView(() => props.index() === props.focused());
const isFocused = () => props.index() === props.focused();
const bg = () =>
isFocused() && props.active()
? theme.primary
: isFocused()
? theme.border
: undefined;
const fg = () =>
isFocused() && props.active()
? theme.surface
: isFocused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<text flexShrink={0} fg={fg()}>
{isFocused() ? props.marker() : " "}
</text>
{/* Long titles truncate with middle-ellipsis instead of wrapping —
a wrapped title grows the row to 2+ lines and shifts every row
below (see EpisodeList for the same guard). The episode-count
and watchlist cells are flexShrink=0 so they never shrink or
wrap; the flexible title takes the remaining width. */}
<text wrapMode="none" truncate fg={fg()}>
{props.title}
</text>
<text flexShrink={0} fg={isFocused() ? theme.surface : muted()}>
({props.feed.episodes.length})
</text>
<Show when={props.wlScope()}>
<text
flexShrink={0}
fg={
isFocused()
? theme.surface
: props.wlInList()
? theme.warning
: muted()
}
>
{props.wlInList() ? "●" : "○"}
</text>
</Show>
</box>
);
}
/** An unsubscribed-show download row (depth 0, below the shows list). */
function UnsubscribedRow(props: {
d: DownloadedEpisode;
index: () => number;
focused: () => number;
active: () => boolean;
marker: () => string;
downloadLabel: () => string;
downloadColor: () => RGBA;
onMouseDown: () => void;
}) {
const { theme } = useTheme();
const ref = useScrollIntoView(() => props.index() === props.focused());
const isFocused = () => props.index() === props.focused();
const bg = () =>
isFocused() && props.active()
? theme.primary
: isFocused()
? theme.border
: undefined;
const fg = () =>
isFocused() && props.active()
? theme.surface
: isFocused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={fg()}>
{isFocused() ? props.marker() : " "}
</text>
<text wrapMode="none" truncate fg={fg()}>
{props.d.episodeTitle ?? props.d.episodeId}
</text>
<Show when={props.downloadLabel()}>
<text flexShrink={0} fg={props.downloadColor()}>
{props.downloadLabel()}
</text>
</Show>
</box>
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={isFocused() ? theme.surface : theme.textSecondary}
>
{props.d.podcastTitle ?? props.d.feedId}
</text>
</box>
</box>
);
}
/** Depth-0 preview: the hovered subscribed show. */
function ShowPreview(props: {
show: () => Feed;
title: () => string;
hint: () => string;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const show = props.show;
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{props.title()}</strong>
</text>
<Show when={show().podcast.author}>
<text fg={muted()}>by {show().podcast.author}</text>
</Show>
<text fg={theme.textSecondary}>{show().episodes.length} episodes</text>
<text fg={muted()}>
{show().podcast.description?.slice(0, 400) ?? "No description."}
</text>
<box height={1} />
<text fg={muted()}>{props.hint()}</text>
</box>
);
}
/** Depth-0 preview: the hovered unsubscribed-show download. */
function UnsubscribedPreview(props: {
d: () => DownloadedEpisode;
downloadLabel: () => string;
downloadColor: () => RGBA;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const d = props.d;
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{d().episodeTitle ?? d().episodeId}</strong>
</text>
<text fg={theme.textSecondary}>{d().podcastTitle ?? d().feedId}</text>
<box flexDirection="row" gap={2}>
<Show when={d().pubDate}>
<text fg={theme.info}>{formatDate(new Date(d().pubDate!))}</text>
</Show>
<Show when={props.downloadLabel()}>
<text fg={props.downloadColor()}>
{props.downloadLabel()}
</text>
</Show>
</box>
<text fg={muted()}>
Downloaded from episode search the show is not subscribed.
</text>
<box height={1} />
<text fg={muted()}>enter: play · D: delete download · h: back</text>
</box>
);
}
export const MyShowsPaneCount = 1;
export function MyShowsPage() {
@@ -67,7 +269,6 @@ export function MyShowsPage() {
// entry drops out the moment the user subscribes to its show.
const unsubs = () => downloadStore.getUnsubscribedDownloads();
// Total depth-0 rows: subscribed shows + unsubscribed-show downloads.
const depth0Count = () => shows().length + unsubs().length;
const focusedShowIdx = () =>
@@ -107,7 +308,6 @@ export function MyShowsPage() {
depth() >= 1 &&
!!drilledShowId() &&
feedStore.hasMoreEpisodes(drilledShowId());
// Total navigable rows at depth 1: episodes + the optional Fetch More row.
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focusedRow = () =>
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
@@ -121,7 +321,30 @@ export function MyShowsPage() {
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
const focusedEpisode = () =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const moreRef = useScrollIntoView(() => focusedOnMore());
// ── Render window ────────────────────────────────────────────────────────
// The drilled show's list grows deep after repeated fetch-more presses;
// rendering every row per frame froze the UI. Render only a bounded slice
// around the focus (real indexes preserved) — the scrollbox still keeps
// the focused row in view. Spacers above/below the window restore the
// full content height so the scrollbar tracks the real list.
// Each episode row is 2 lines tall (title, date) — no subtitle here.
const LIST_WINDOW = 30;
const ROW_HEIGHT = 2;
const listWindow = createMemo<[number, number]>(() => {
const len = episodes().length;
// Focusing the Fetch More button keeps the window anchored at the
// last episode — no jump when the focus crosses onto the button.
const f = focusedOnMore() ? len - 1 : focusedEpIdx();
return [
Math.max(0, f - LIST_WINDOW),
Math.min(len, f + LIST_WINDOW + 1),
];
});
const visibleEpisodes = createMemo(() => {
const [start, end] = listWindow();
return episodes().slice(start, end);
});
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
@@ -155,12 +378,6 @@ export function MyShowsPage() {
});
// ── helpers ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
@@ -323,14 +540,6 @@ export function MyShowsPage() {
// ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active
? theme.surface
: i === lf
? theme.selectedListItemText ?? theme.text
: theme.text;
const showTitle = (f: Feed) => f.customName || f.podcast.title;
const currentLabel = () =>
@@ -349,19 +558,30 @@ export function MyShowsPage() {
{(feed, index) => {
const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => index() === lf());
const focused = () => index() === lf();
const fg = () =>
focused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), false)}
backgroundColor={focused() ? theme.border : undefined}
>
<text fg={focusFg(index(), lf(), false)}>
{index() === lf() ? marker() : " "}
<text flexShrink={0} fg={fg()}>
{focused() ? marker() : " "}
</text>
{/* 20%-wide parent pane truncates hard — same
middle-ellipsis guard as the depth-0 rows. */}
<text wrapMode="none" truncate fg={fg()}>
{showTitle(feed)}
</text>
<text flexShrink={0} fg={muted()}>
({feed.episodes.length})
</text>
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
<text fg={muted()}>({feed.episodes.length})</text>
</box>
);
}}
@@ -385,51 +605,28 @@ export function MyShowsPage() {
}
>
<For each={shows()}>
{(feed, index) => {
const lf = () => focusedShowIdx();
const ref = useScrollIntoView(() => index() === lf());
const wlScope =
app.state().preferences.autoDownloadScope === "whitelist";
const wlInList = (
app.state().preferences.autoDownloadWhitelist ?? []
).includes(feed.id);
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
{(feed, index) => (
<ShowRow
feed={feed}
title={showTitle(feed)}
index={index}
focused={focusedShowIdx}
active={isActive}
marker={marker}
wlScope={() =>
app.state().preferences.autoDownloadScope === "whitelist"
}
wlInList={() =>
(app.state().preferences.autoDownloadWhitelist ?? []).includes(
feed.id,
)
}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>
{showTitle(feed)}
</text>
<text fg={index() === lf() ? theme.surface : muted()}>
({feed.episodes.length})
</text>
<Show when={wlScope}>
<text
fg={
index() === lf()
? theme.surface
: wlInList
? theme.warning
: muted()
}
>
{wlInList ? "●" : "○"}
</text>
</Show>
</box>
);
}}
/>
)}
</For>
<Show when={unsubs().length > 0}>
<box paddingLeft={1} paddingTop={1}>
@@ -438,62 +635,21 @@ export function MyShowsPage() {
</text>
</box>
<For each={unsubs()}>
{(d, index) => {
// Rows continue after the shows list.
const rowIdx = () => shows().length + index();
const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => rowIdx() === lf());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={focusBg(rowIdx(), lf(), isActive())}
{(d, index) => (
<UnsubscribedRow
d={d}
index={() => shows().length + index()}
focused={() => nav.depthFocus(0)}
active={isActive}
marker={marker}
downloadLabel={() => downloadLabel(d.episodeId)}
downloadColor={() => downloadColor(d.episodeId)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(rowIdx(), 0);
}}
>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
fg={focusFg(rowIdx(), lf(), isActive())}
>
{rowIdx() === lf() ? marker() : " "}
</text>
<text
wrapMode="none"
truncate
fg={focusFg(rowIdx(), lf(), isActive())}
>
{d.episodeTitle ?? d.episodeId}
</text>
<Show when={downloadLabel(d.episodeId)}>
<text
flexShrink={0}
fg={downloadColor(d.episodeId)}
>
{downloadLabel(d.episodeId)}
</text>
</Show>
</box>
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={
rowIdx() === lf()
? theme.surface
: theme.textSecondary
}
>
{d.podcastTitle ?? d.feedId}
</text>
</box>
</box>
);
nav.setDepthFocus(shows().length + index(), 0);
}}
/>
)}
</For>
</Show>
</Show>
@@ -508,99 +664,47 @@ export function MyShowsPage() {
</box>
}
>
<For each={episodes()}>
{(ep, index) => {
const lf = () => focusedEpIdx();
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
{/* Spacers keep the scrollbox content at the FULL list
height so the scrollbar reflects the real list, not the
render window. */}
<Show when={listWindow()[0] > 0}>
<box height={listWindow()[0] * ROW_HEIGHT} />
</Show>
<For each={visibleEpisodes()}>
{(ep, index) => (
<EpisodeRow
episode={ep}
index={() => listWindow()[0] + index()}
focused={focusedEpIdx}
active={isActive}
selected={() => nav.isSelected(ep.id)}
downloadLabel={() => downloadLabel(ep.id)}
downloadColor={() => downloadColor(ep.id)}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
nav.setDepthFocus(listWindow()[0] + index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
fg={focusFg(index(), lf(), isActive())}
>
{index() === lf() ? marker() : " "}
</text>
<text
wrapMode="none"
truncate
fg={focusFg(index(), lf(), isActive())}
>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
flexShrink={0}
fg={index() === lf() ? theme.surface : theme.info}
>
{formatDate(ep.pubDate)}
</text>
<text
flexShrink={0}
fg={index() === lf() ? theme.surface : muted()}
>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text flexShrink={0} fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
</text>
</Show>
</box>
</box>
);
}}
</For>
<Show when={showFetchMore()}>
<box
ref={moreRef}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(
episodes().length,
focusedRow(),
isActive(),
/>
)}
</For>
<Show when={episodes().length - listWindow()[1] > 0}>
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
</Show>
<Show when={showFetchMore()}>
<FetchMoreRow
index={() => episodes().length}
focused={focusedRow}
onMore={focusedOnMore}
active={isActive}
isLoadingMore={() => feedStore.isLoadingMore()}
nerd={nerd}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(episodes().length, 1);
}}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{focusedOnMore() ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{NF_ICONS.more}
</text>
)}
<Show
when={!feedStore.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
[Fetch More]
</text>
</Show>
</box>
/>
</Show>
</Show>
</Show>
@@ -608,6 +712,30 @@ export function MyShowsPage() {
);
// ── preview pane ───────────────────────────────────────────────────────────
const episodeHint = (epId: string) =>
`enter: play · d: download${
downloadStore.getDownloadStatus(epId) !== DownloadStatus.NONE
? " · D: delete"
: ""
}${
app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
drilledShowId(),
)
? " · w: un-whitelist"
: " · w: whitelist"
: ""
} · space: select · h: back`;
const showHint = (show: Feed) =>
`enter/l: open · h: back · x: unsubscribe${
app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(show.id)
? " · w: un-whitelist"
: " · w: whitelist"
: ""
}`;
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered unsubscribed-show download, else the
@@ -624,86 +752,34 @@ export function MyShowsPage() {
}
>
{(show) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{showTitle(show())}</strong>
</text>
<Show when={show().podcast.author}>
<text fg={muted()}>by {show().podcast.author}</text>
</Show>
<text fg={theme.textSecondary}>
{show().episodes.length} episodes
</text>
<text fg={muted()}>
{show().podcast.description?.slice(0, 400) ??
"No description."}
</text>
<box height={1} />
<text fg={muted()}>
enter/l: open · h: back · x: unsubscribe
{app.state().preferences.autoDownloadScope ===
"whitelist"
? (app.state().preferences.autoDownloadWhitelist ??
[]
).includes(show().id)
? " · w: un-whitelist"
: " · w: whitelist"
: ""}
</text>
</box>
<ShowPreview
show={() => show()}
title={() => showTitle(show())}
hint={() => showHint(show())}
/>
)}
</Show>
}
>
{(d) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{d().episodeTitle ?? d().episodeId}</strong>
</text>
<text fg={theme.textSecondary}>
{d().podcastTitle ?? d().feedId}
</text>
<box flexDirection="row" gap={2}>
<Show when={d().pubDate}>
<text fg={theme.info}>
{formatDate(new Date(d().pubDate!))}
</text>
</Show>
<Show when={downloadLabel(d().episodeId)}>
<text fg={downloadColor(d().episodeId)}>
{downloadLabel(d().episodeId)}
</text>
</Show>
</box>
<text fg={muted()}>
Downloaded from episode search the show is not
subscribed.
</text>
<box height={1} />
<text fg={muted()}>
enter: play · D: delete download · h: back
</text>
</box>
<UnsubscribedPreview
d={() => d()}
downloadLabel={() => downloadLabel(d().episodeId)}
downloadColor={() => downloadColor(d().episodeId)}
/>
)}
</Show>
) : (
// depth ≥1 preview: hovered episode (or the Fetch More row)
<>
<Show when={focusedOnMore()}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>[Fetch More]</strong>
</text>
<text fg={muted()}>
{feedStore.isLoadingMore()
? "Loading the next batch of episodes…"
: fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: "Load the next batch of older episodes for this show (Enter)."}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>
</box>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
fetchMoreMode={fetchMoreMode}
manualText={() =>
"Load the next batch of older episodes for this show (Enter)."
}
/>
</Show>
<Show when={!focusedOnMore()}>
<Show
@@ -715,47 +791,13 @@ export function MyShowsPage() {
}
>
{(ep) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
{ep().title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
<text fg={muted()}>{formatDuration(ep().duration)}</text>
<Show when={downloadLabel(ep().id)}>
<text fg={downloadColor(ep().id)}>
{downloadLabel(ep().id)}
</text>
</Show>
</box>
<Show when={selectedShow()?.podcast.author}>
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{ep().description?.slice(0, 400) ?? "No description available."}
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>
enter: play · d: download
{downloadStore.getDownloadStatus(ep().id) !==
DownloadStatus.NONE
? " · D: delete"
: ""}
{app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
drilledShowId(),
)
? " · w: un-whitelist"
: " · w: whitelist"
: ""}{" "}
· space: select · h: back
</text>
</box>
<EpisodePreview
episode={() => ep()}
author={() => selectedShow()?.podcast.author}
downloadLabel={() => downloadLabel(ep().id)}
downloadColor={() => downloadColor(ep().id)}
hint={() => episodeHint(ep().id)}
/>
)}
</Show>
</Show>

View File

@@ -77,30 +77,15 @@ function SearchPage() {
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
// ── input focusing ────────────────────────────────────────────────────────
// `inputFocused` is true while the query input is being typed in. The Shell
// router yields keys to the <input> while this is true; Escape (in Shell)
// sets it false so navigation resumes; `s` (search action) sets it true.
//
// The input's REAL focus is the source of truth for the flag:
// useInputFocusNav (the same hook the Settings forms use) flips
// `inputFocused` from the input's FOCUSED/BLURRED events, keeping the flag
// and the renderable in lockstep. That matters when the user clicks OFF the
// input: opentui's mouse dispatch auto-focuses the clicked target's nearest
// focusable ancestor (a pane scrollbox), blurring the input. The BLURRED
// event drops the flag, so the Shell router immediately resumes j/k/h
// instead of swallowing keys with no input to receive them — no more
// stuck "typing" state where Esc/j/k/s all do nothing.
//
// The depth stack still SEEDS the flag on transitions, since the query
// depth defaults to typing: re-entering depth 0 (h back from results, or a
// fresh mount) focuses the input; mounting at depth 1 (returning to the
// tab after a search) stays list-navigation — a stuck-on flag there would
// have the Shell yield j/k to a non-existent input. The depth STACK signal
// is also written by focus moves (setDepthFocus), so gate the seed on the
// depth VALUE via a memo: the effect must re-run only on an actual depth
// transition. Without the memo every j/k at the query depth re-focuses the
// input (undoing Escape), which keeps the recents list unreachable by
// keyboard.
// `inputFocused` tells the Shell router to yield keys to the query input.
// The input's REAL focus is the source of truth: useInputFocusNav flips
// the flag from the input's FOCUSED/BLURRED events, so clicking off the
// input drops it and the router resumes j/k/h — no stuck "typing" state.
// The depth stack only SEEDS it on transitions (re-entering depth 0
// focuses the input; mounting at depth 1 stays list-nav), gated on the
// depth VALUE via a memo because setDepthFocus also writes the stack
// signal — without the memo every j/k at query depth re-focuses the input
// and strands the recents list.
onMount(() => nav.setInputFocused(depth() === 0));
onCleanup(() => nav.setInputFocused(false));
const focusNavRef = useInputFocusNav();

View File

@@ -4,13 +4,15 @@
* driven by the Shell router via nav.action.
*
* 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
* field over subscribed shows; suggestions toggle
* 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";
@@ -30,7 +32,7 @@ import {
import { on } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
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 { SettingItem } from "./types";
@@ -48,6 +50,14 @@ const SCOPE_LABELS: Array<{ value: AutoDownloadScope; label: string }> = [
{ value: "none", label: "None" },
{ 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 {
return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope;
@@ -205,6 +215,79 @@ export function usePreferencesItems(): SettingItem[] {
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); Fetch More reveals the next 2 weeks per press.\nCount: the Feed list is the N most-recent episodes across ALL shows (not N per show); Fetch More reveals N more of the newest episodes each press — deep history only appears once you page to it.\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 (1365)\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",
label: "Fetch More",

View File

@@ -140,7 +140,6 @@ export function SettingsPage() {
function open() {
const d = depth();
if (d === 0) {
// drill into the focused section's items
const id = focusedSection().id;
nav.pushDepth({
kind: `settings:${id}`,
@@ -206,7 +205,6 @@ export function SettingsPage() {
function step(delta: number) {
const d = depth();
if (d === 2) {
// editor: j/k nudges the value
const it = editorItem();
if (it?.kind === "number" || it?.kind === "select")
it.cycle?.(delta as -1 | 1);
@@ -220,7 +218,6 @@ export function SettingsPage() {
pane: PaneId;
mode: NavMode;
}) => {
// ignore actions meant for non-center panes
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
const handler = PAGE_ACTIONS[data.action];

View File

@@ -10,7 +10,6 @@
import { createSignal } from "solid-js";
/** Create activity store */
function createActivityStore() {
const [count, setCount] = createSignal(0);
const [labels, setLabels] = createSignal<string[]>([]);
@@ -64,7 +63,6 @@ function createActivityStore() {
};
}
/** Singleton activity store */
let activityStoreInstance: ReturnType<typeof createActivityStore> | null = null;
export function useActivityStore() {

View File

@@ -45,6 +45,9 @@ const defaultPreferences: UserPreferences = {
autoJumpToPlayer: true,
fetchMoreMode: "auto",
refreshIntervalMinutes: 30,
episodeCacheMode: "date",
episodeCacheCount: 25,
episodeCacheDays: 60,
};
const defaultState: AppState = {

View File

@@ -9,14 +9,12 @@ import {
saveAudioNavToFile,
} from "../utils/app-persistence";
/** Source type for audio navigation */
export enum AudioSource {
FEED = "feed",
MY_SHOWS = "my_shows",
SEARCH = "search",
}
/** Audio navigation state */
export interface AudioNavState {
/** Current source type */
source: AudioSource;
@@ -28,14 +26,12 @@ export interface AudioNavState {
lastUpdated: Date;
}
/** Default navigation state */
const defaultNavState: AudioNavState = {
source: AudioSource.FEED,
currentIndex: 0,
lastUpdated: new Date(),
};
/** Create audio navigation store */
function createAudioNavStore() {
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
@@ -56,12 +52,10 @@ function createAudioNavStore() {
init();
return {
/** Get current navigation state */
get state(): AudioNavState {
return navState();
},
/** Update source type */
setSource: (source: AudioSource, podcastId?: string) => {
setNavState((prev) => ({
...prev,
@@ -72,7 +66,6 @@ function createAudioNavStore() {
persist();
},
/** Move to next episode */
next: (currentIndex: number) => {
setNavState((prev) => ({
...prev,
@@ -82,7 +75,6 @@ function createAudioNavStore() {
persist();
},
/** Move to previous episode */
prev: (currentIndex: number) => {
setNavState((prev) => ({
...prev,
@@ -92,23 +84,19 @@ function createAudioNavStore() {
persist();
},
/** Reset to default state */
reset: () => {
setNavState(defaultNavState);
persist();
},
/** Get current index */
getCurrentIndex: (): number => {
return navState().currentIndex;
},
/** Get current source */
getSource: (): AudioSource => {
return navState().source;
},
/** Get current podcast ID */
getPodcastId: (): string | undefined => {
return navState().podcastId;
},

View File

@@ -11,6 +11,7 @@
import { createSignal } from "solid-js";
import type { Podcast } from "../types/podcast";
import type { Episode } from "../types/episode";
import { useFeedStore } from "./feed";
export interface DiscoverCategory {
@@ -42,6 +43,10 @@ const FEATURED_JSON_URL =
/** Cache window for the remote featured list (24 hours) */
const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
/** Max episodes to load when previewing an unsubscribed show's episode list
* from Discover (drill-in, no subscription). Mirrors the refresh window. */
const PREVIEW_EPISODE_LIMIT = 50;
/** Shape of a single entry in the remote JSON */
interface FeaturedEntry {
id: string;
@@ -85,12 +90,24 @@ function syncSubscriptionState(
}));
}
/** Create discover store */
export function createDiscoverStore() {
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
const [isLoading, setIsLoading] = createSignal(false);
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
// Episodes fetched for an unsubscribed show's preview list (drill-in from
// a podcast result, no subscription). Cached per podcast id for the
// session; keyed by id so switching shows never clobbers another's list.
const [previewEpisodes, setPreviewEpisodes] = createSignal<
Record<string, Episode[]>
>({});
const [previewLoading, setPreviewLoading] = createSignal<Set<string>>(
new Set(),
);
const [previewErrors, setPreviewErrors] = createSignal<
Record<string, string>
>({});
// In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
let cachedAt = 0;
@@ -107,7 +124,6 @@ export function createDiscoverStore() {
const refresh = async () => {
setIsLoading(true);
try {
// Skip if cache is still fresh
const now = Date.now();
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
syncSubscriptions();
@@ -131,7 +147,6 @@ export function createDiscoverStore() {
cachedAt = now;
setPodcasts(fetched);
// Reflect current feed-store subscriptions
syncSubscriptions();
} catch {
// Network failure — keep whatever we have (stale or empty)
@@ -140,7 +155,6 @@ export function createDiscoverStore() {
}
};
/** Get filtered podcasts by category */
const filteredPodcasts = () => {
const category = selectedCategory();
if (category === "all") {
@@ -155,7 +169,6 @@ export function createDiscoverStore() {
});
};
/** Subscribe to a podcast */
const subscribe = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast) {
@@ -168,7 +181,6 @@ export function createDiscoverStore() {
);
};
/** Unsubscribe from a podcast */
const unsubscribe = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast) {
@@ -180,14 +192,64 @@ export function createDiscoverStore() {
);
};
/** Toggle subscription */
const toggleSubscription = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast?.isSubscribed) {
unsubscribe(podcastId);
} else {
subscribe(podcastId);
// ── episode preview (drill-in, no subscription) ──────────────────────────
/** Cached episode list for a previewed show (empty until first drill-in). */
const episodesForPodcast = (podcastId: string): Episode[] =>
previewEpisodes()[podcastId] ?? [];
const isLoadingEpisodesFor = (podcastId: string): boolean =>
previewLoading().has(podcastId);
const previewError = (podcastId: string): string | undefined =>
previewErrors()[podcastId];
/** Fetch a show's episode list WITHOUT subscribing (Discover preview).
* The list is cached per podcast id; a failed fetch records an error
* and keeps any previous cache (a retry via refreshEpisodes clears it). */
const openEpisodes = async (podcast: Podcast): Promise<void> => {
if (previewEpisodes()[podcast.id] || previewLoading().has(podcast.id))
return;
if (!podcast.feedUrl) {
setPreviewErrors((prev) => ({
...prev,
[podcast.id]: "No RSS feed listed for this show.",
}));
return;
}
setPreviewLoading((prev) => new Set(prev).add(podcast.id));
const feedStore = useFeedStore();
const { episodes } = await feedStore.fetchEpisodes(
podcast.feedUrl,
PREVIEW_EPISODE_LIMIT,
);
if (episodes) {
setPreviewEpisodes((prev) => ({ ...prev, [podcast.id]: episodes }));
} else {
setPreviewErrors((prev) => ({
...prev,
[podcast.id]: "Couldn't load episodes.",
}));
}
setPreviewLoading((prev) => {
const next = new Set(prev);
next.delete(podcast.id);
return next;
});
};
/** Re-fetch a previewed show's episode list (`r` on the episodes depth). */
const refreshEpisodes = async (podcast: Podcast): Promise<void> => {
setPreviewErrors((prev) => {
const next = { ...prev };
delete next[podcast.id];
return next;
});
setPreviewEpisodes((prev) => {
const next = { ...prev };
delete next[podcast.id];
return next;
});
await openEpisodes(podcast);
};
return {
@@ -202,12 +264,17 @@ export function createDiscoverStore() {
setSelectedCategory,
subscribe,
unsubscribe,
toggleSubscription,
refresh,
// Episode preview (drill-in, no subscription)
episodesForPodcast,
isLoadingEpisodesFor,
previewError,
openEpisodes,
refreshEpisodes,
};
}
/** Singleton discover store */
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
export function useDiscoverStore() {

View File

@@ -63,7 +63,62 @@ interface QueueItem {
episodeTitle: string;
}
/** Create download store */
// ── post-download decoration ─────────────────────────────────────────────────
/** Write the podcast cover beside the audio so mpv's --cover-art-auto=exact
* picks it up for Now Playing art when the local file plays (same basename,
* .jpg extension — verified against mpv 0.41). curl, NOT fetch: Bun's fetch
* hangs in compiled binaries, so the shipped app never wrote this file. */
function writeCoverArt(filePath: string, coverUrl: string): void {
const dot = filePath.lastIndexOf(".");
if (dot <= 0) return;
const coverPath = filePath.slice(0, dot) + ".jpg";
Bun.spawn([
"curl",
"-sS",
"--fail",
"-m",
"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. */
function tagLocalFile(
filePath: string,
episode: Episode,
podcastTitle: string,
): void {
const tmp = `${filePath}.tag.mp3`;
Bun.spawn([
"ffmpeg",
"-y",
"-i",
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, filePath);
})
.catch(() => {});
}
function createDownloadStore() {
const [downloads, setDownloads] = createSignal<
Map<string, DownloadedEpisode>
@@ -195,7 +250,6 @@ function createDownloadStore() {
}
}
/** Execute a single download */
async function executeDownload(item: QueueItem): Promise<void> {
const controller = new AbortController();
abortControllers.set(item.episodeId, controller);
@@ -236,70 +290,23 @@ function createDownloadStore() {
error: null,
});
// Write the podcast cover beside the audio so mpv's
// --cover-art-auto=exact picks it up for Now Playing art when the
// local file plays (same basename, .jpg extension — verified
// against mpv 0.41). curl, NOT fetch: Bun's fetch hangs in
// 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).
// Decorate the local file: cover art + ID3 tags (see the
// module-level helpers above) — the source streams carry neither.
// Cover 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) {
const dot = result.filePath.lastIndexOf(".");
if (dot > 0) {
const coverPath = result.filePath.slice(0, dot) + ".jpg";
Bun.spawn([
"curl",
"-sS",
"--fail",
"-m",
"8",
"--max-filesize",
"2097152",
"-o",
coverPath,
coverUrl,
])
.exited.catch(() => {});
const feed = feedStore.feeds().find((f) => f.id === item.feedId);
const coverUrl = feed?.podcast.coverUrl ?? episode?.imageUrl;
if (result.filePath && coverUrl) {
writeCoverArt(result.filePath, coverUrl);
}
}
// 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 ??
feed?.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(() => {});
tagLocalFile(result.filePath, episode, podcastTitle);
}
}
} else {
@@ -315,22 +322,18 @@ function createDownloadStore() {
processQueue();
}
/** Get download status for an episode */
const getDownloadStatus = (episodeId: string): DownloadStatus => {
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
};
/** Get download progress for an episode (0-100) */
const getDownloadProgress = (episodeId: string): number => {
return downloads().get(episodeId)?.progress ?? 0;
};
/** Get full download info for an episode */
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
return downloads().get(episodeId);
};
/** Get the local file path for a completed download */
const getDownloadedFilePath = (episodeId: string): string | null => {
const dl = downloads().get(episodeId);
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
@@ -347,7 +350,6 @@ function createDownloadStore() {
podcastFeedUrl?: string;
}
/** Start downloading an episode */
const startDownload = (
episode: Episode,
feedId: string,
@@ -411,7 +413,6 @@ function createDownloadStore() {
});
};
/** Cancel a download */
const cancelDownload = (episodeId: string): void => {
// Abort active download
const controller = abortControllers.get(episodeId);
@@ -432,7 +433,6 @@ function createDownloadStore() {
saveDownloads().catch(() => {});
};
/** Remove a completed download (delete file and metadata) */
const removeDownload = async (episodeId: string): Promise<void> => {
const dl = downloads().get(episodeId);
if (dl?.filePath) {
@@ -478,7 +478,6 @@ function createDownloadStore() {
}
};
/** Get all downloads as an array */
const getAllDownloads = (): DownloadedEpisode[] => {
return Array.from(downloads().values());
};
@@ -501,12 +500,10 @@ function createDownloadStore() {
});
};
/** Get the current queue */
const getQueue = (): QueueItem[] => {
return queue();
};
/** Get count of active downloads */
const getActiveCount = (): number => {
return activeCount();
};
@@ -531,7 +528,6 @@ function createDownloadStore() {
};
}
/** Singleton download store */
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
export function useDownloadStore() {

View File

@@ -4,6 +4,8 @@
*/
import { createSignal } from "solid-js";
import { Effect } from "effect";
import { refreshFeedsBatch } from "../effects/feed-refresh";
import { FeedVisibility } from "../types/feed";
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
import type { Podcast } from "../types/podcast";
@@ -13,8 +15,13 @@ import { DEFAULT_SOURCES } from "../types/source";
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
import { savePodcastIndexCredentials } from "../utils/source-credentials";
import { mergeEpisodes } from "../utils/episode-merge";
import {
episodeSignature,
mergeEpisodesBounded,
} from "../utils/episode-merge";
import {
DEFAULT_EPISODE_WINDOW_DAYS,
episodeInWindow,
loadFeedsFromFile,
saveFeedsToFile,
loadSourcesFromFile,
@@ -25,16 +32,16 @@ import { useDownloadStore } from "./download";
import { useAppStore } from "./app";
import { DownloadStatus } from "../types/episode";
/** Max episodes to load per page/chunk */
/** Max episodes to load per page/chunk (count mode only — date mode steps
* by FETCH_MORE_WINDOW_DAYS instead). */
const MAX_EPISODES_REFRESH = 50;
/** Max episodes to fetch on initial subscribe */
const MAX_EPISODES_SUBSCRIBE = 20;
/** Per-feed bound on both the cached parse results and the merged in-memory
* window; 500 covers years of a weekly show's history while capping a
* 20-subscription install at 10k episodes. */
export const MAX_EPISODES_IN_MEMORY = 500;
/** Fetch-more step in date mode: each press reveals the next two weeks of
* episodes past the oldest loaded one, instead of a fixed episode count. */
const FETCH_MORE_WINDOW_DAYS = 14;
/** Per-feed fetch timeout — a hung feed must not stall a refresh batch or
* the background refresh loop. */
@@ -53,6 +60,16 @@ const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
* feeds) can't stall the renderer. */
const PARSE_CHUNK_SIZE = 5;
/** Hard ceiling on the in-memory full-parse cache per feed (newest first).
* The cache exists so fetch-more can page deeper without a refetch; without
* a ceiling a 5,000-episode archive pins tens of MB of Episode objects in
* RAM for the whole session (the old cache held EVERY parsed episode of
* every feed, contributing hundreds of MB for archive-heavy
* subscriptions). 1000 covers any realistic show's entire history —
* beyond it, hasMoreEpisodes flips false and the visible list is bounded
* by the user's cache preference as usual. */
const MAX_CACHED_EPISODES_PER_FEED = 1000;
/** 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
@@ -93,15 +110,74 @@ const parseEpisodesIncremental = async (
return episodes;
};
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */
/** 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[]>();
/** 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>();
/** 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);
}
/** Timestamp for window math — undated episodes sort/compare as NEWEST
* (Infinity) so they can never be excluded by a date cutoff. */
const epTs = (ep: Episode): number => {
const t = ep.pubDate?.getTime();
return t === undefined || Number.isNaN(t) ? Infinity : t;
};
/** Date-mode fetch-more cutoff: the oldest loaded episode's pubDate minus the
* 2-week band. With nothing loaded (a show whose episodes all fall outside
* the cache window), the band anchors at the cache-window edge (now minus
* the configured days) — a dormant show can't drag in arbitrarily old
* episodes just because the button is pressed. */
const dateFetchMoreCutoff = (
cached: Episode[],
loaded: number,
windowDays: number,
): number => {
if (loaded > 0) {
const t = epTs(cached[loaded - 1]);
if (Number.isFinite(t)) {
return t - FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000;
}
}
// Nothing loaded: the band extends FETCH_MORE_WINDOW_DAYS before the
// cache-window edge (e.g. 60d → reveals the 6074d slice).
return (
Date.now() -
Math.max(1, windowDays) * 24 * 3600 * 1000 -
FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000
);
};
/** Save feeds to file (async, fire-and-forget). */
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) */
@@ -158,40 +234,23 @@ async function migratePlaintextCredentials(
* union semantics the merged list legitimately contains episodes BEYOND the
* fetched window, so unchanged-detection must compare the fetched window
* against the existing list's prefix — comparing full lists would bump
* `lastUpdated` on every refresh. */
function sameRefreshWindow(existing: Episode[], fetched: Episode[]): boolean {
* `lastUpdated` on every refresh. When ids drifted between refreshes (the
* one-time positional-id migration, or a feed that rotates enclosure URLs)
* the id sets differ for the SAME content, so a content-signature
* comparison decides: an unchanged feed stays unchanged. */
export function sameRefreshWindow(
existing: Episode[],
fetched: Episode[],
): boolean {
if (fetched.length === 0) return true;
const prefix = existing.slice(0, fetched.length);
const ids = new Set(prefix.map((e) => e.id));
return fetched.every((e) => ids.has(e.id));
if (fetched.every((e) => ids.has(e.id))) return true;
if (prefix.length !== fetched.length) return false;
const signatures = new Set(prefix.map(episodeSignature));
return fetched.every((e) => signatures.has(episodeSignature(e)));
}
/** 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 */
function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]);
const [sources, setSources] = createSignal<PodcastSource[]>([
@@ -205,6 +264,11 @@ function createFeedStore() {
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
/** Feed-page fetch-more presses in COUNT mode: the global list is capped
* at episodeCacheCount × (presses + 1) episodes, so one press reveals
* exactly N more of the NEWEST episodes across all shows — it can never
* dump deep history (see getAllEpisodesChronological). */
const [countFetchMorePresses, setCountFetchMorePresses] = createSignal(0);
// ── Debounced persistence ───────────────────────────────────────────────
/** Trailing-edge debounce window for config.json writes. */
@@ -239,7 +303,6 @@ function createFeedStore() {
saveFeeds(feeds());
};
/** Get filtered and sorted feeds */
const getFilteredFeeds = (): Feed[] => {
let result = [...feeds()];
const f = filter();
@@ -297,7 +360,6 @@ function createFeedStore() {
return result;
};
/** Get episodes in reverse chronological order across all feeds */
const getAllEpisodesChronological = (): Array<{
episode: Episode;
feed: Feed;
@@ -315,23 +377,38 @@ function createFeedStore() {
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
);
// COUNT mode: the Feed page is a GLOBAL top-K list — the newest
// `episodeCacheCount × (fetch-more presses + 1)` episodes across ALL
// shows, not N per show. A press reveals exactly N more recent
// episodes; deep history never surfaces in one jump. The cap stays
// even once every cache is exhausted (the button hides) — lifting it
// rendered the full deep union and froze the UI.
const prefs = useAppStore().state().preferences;
if (prefs.episodeCacheMode === "count") {
const limit =
Math.max(1, prefs.episodeCacheCount ?? 25) *
(countFetchMorePresses() + 1);
return allEpisodes.slice(0, limit);
}
return allEpisodes;
};
/** Sort episodes in reverse chronological order (newest first) */
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
return [...episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
};
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes.
* Returns NULL when the feed could not be fetched (network error, non-OK
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes.
* Also returns the channel-level artwork so callers can backfill a feed's
* coverUrl (subscribe + refresh). 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. */
/** Fetch latest episodes from an RSS feed URL, caching ALL parsed
* episodes in fullEpisodeCache. The visible episodes returned are
* bounded by the user's cache preference (count or date); the full
* cache survives so fetch-more can page beyond the bound without a
* 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 (
feedUrl: string,
limit: number,
@@ -356,14 +433,34 @@ function createFeedStore() {
await parseEpisodesIncremental(xml, feedUrl),
);
// Cache all parsed episodes for pagination
if (feedId) {
fullEpisodeCache.set(feedId, allEpisodes.slice(0, MAX_EPISODES_IN_MEMORY));
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
// Cache the FULL parse — the bound is applied when reading,
// not when writing, so a preference change takes effect
// without a refetch. Capped at MAX_CACHED_EPISODES_PER_FEED
// so an archive-heavy feed can't pin its entire history in
// RAM for the session (the visible window below is bounded
// by the user's preference regardless).
fullEpisodeCache.set(
feedId,
allEpisodes.slice(0, MAX_CACHED_EPISODES_PER_FEED),
);
}
// 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: allEpisodes.slice(0, limit),
episodes: visible,
coverUrl: parseChannelCoverUrl(xml),
};
} catch {
@@ -371,7 +468,6 @@ function createFeedStore() {
}
};
/** Check if a feed with this URL already exists */
const hasFeedByUrl = (feedUrl: string): boolean => {
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
};
@@ -470,19 +566,22 @@ function createFeedStore() {
* only when the content actually changed (see sameRefreshWindow). The
* fetched window is MERGED into the existing episodes (fetched copy wins
* on id collision) so a refresh never shrinks the in-memory list; the
* union is capped at MAX_EPISODES_IN_MEMORY. 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. */
* 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 = (
prev: Feed[],
feedId: string,
episodes: Episode[],
): Feed[] => {
let changed = false;
const prefs = useAppStore().state().preferences;
const keep = episodeKeepFn(prefs);
const updated = prev.map((f) => {
if (f.id !== feedId) return f;
const merged = mergeEpisodes(f.episodes, episodes, MAX_EPISODES_IN_MEMORY);
const merged = mergeEpisodesBounded(f.episodes, episodes, keep);
if (sameRefreshWindow(f.episodes, episodes)) return f;
changed = true;
return { ...f, episodes: merged, lastUpdated: new Date() };
@@ -522,27 +621,25 @@ function createFeedStore() {
})(), "Refreshing");
};
/** Refresh all feeds — bounded concurrency (at most FETCH_CONCURRENCY
* in-flight requests), and each feed's refreshed episodes are applied
* AS ITS OWN FETCH LANDS (no Promise.all barrier). Per-feed apply is
* safe because applyRefreshedEpisodes keeps unchanged feeds' object
* 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. */
/** Refresh all feeds via the Effect batch program (effects/feed-refresh):
* bounded concurrency (at most FETCH_CONCURRENCY in-flight requests)
* and each feed's refreshed episodes applied AS ITS OWN FETCH LANDS
* (no barrier — the apply runs inside the feed's own fiber). Per-feed
* apply is safe because applyRefreshedEpisodes keeps unchanged feeds'
* object 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. A failed or timed-out fetch (null episodes) leaves that
* feed untouched. */
const refreshAllFeeds = async () => {
setIsLoadingFeeds(true);
try {
await mapWithConcurrency(
await Effect.runPromise(
refreshFeedsBatch(
feeds(),
FETCH_CONCURRENCY,
async (feed) => {
const { episodes, coverUrl } = await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
feed.id,
);
// A failed fetch (null) leaves that feed untouched.
if (!episodes) return;
(feed) =>
fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feed.id),
(feed, { episodes, coverUrl }) => {
setFeeds((prev) => {
let updated = applyRefreshedEpisodes(prev, feed.id, episodes);
if (coverUrl) {
@@ -556,6 +653,8 @@ function createFeedStore() {
return updated;
});
},
{ concurrency: FETCH_CONCURRENCY, timeoutMs: FETCH_TIMEOUT_MS },
),
);
// Global auto-download: one idempotent pass after the batch.
runAutoDownload();
@@ -574,7 +673,11 @@ function createFeedStore() {
const { promise: feedsReady, resolve: resolveFeedsReady } =
Promise.withResolvers<void>();
(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);
resolveFeedsReady();
const loadedSources = await loadSourcesFromFile<PodcastSource>();
@@ -629,7 +732,6 @@ function createFeedStore() {
};
scheduleNextRefresh();
/** Remove a feed */
const removeFeed = (feedId: string) => {
fullEpisodeCache.delete(feedId);
episodeLoadCount.delete(feedId);
@@ -660,7 +762,6 @@ function createFeedStore() {
}
};
/** Update a feed */
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
setFeeds((prev) => {
const updated = prev.map((f) =>
@@ -671,7 +772,6 @@ function createFeedStore() {
});
};
/** Toggle feed pinned status */
const togglePinned = (feedId: string) => {
setFeeds((prev) => {
const updated = prev.map((f) =>
@@ -682,7 +782,6 @@ function createFeedStore() {
});
};
/** Add a source */
const addSource = (source: Omit<PodcastSource, "id">) => {
const newSource: PodcastSource = {
...source,
@@ -696,7 +795,6 @@ function createFeedStore() {
return newSource;
};
/** Update a source */
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
setSources((prev) => {
const updated = prev.map((source) =>
@@ -707,7 +805,6 @@ function createFeedStore() {
});
};
/** Remove a source */
const removeSource = (sourceId: string) => {
// Don't remove default sources
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
@@ -720,7 +817,6 @@ function createFeedStore() {
return true;
};
/** Toggle source enabled status */
const toggleSource = (sourceId: string) => {
setSources((prev) => {
const updated = prev.map((s) =>
@@ -731,7 +827,6 @@ function createFeedStore() {
});
};
/** Get feed by ID */
const getFeed = (feedId: string): Feed | undefined => {
return feeds().find((f) => f.id === feedId);
};
@@ -746,21 +841,41 @@ function createFeedStore() {
return undefined;
};
/** Get selected feed */
const getSelectedFeed = (): Feed | undefined => {
const id = selectedFeedId();
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 page deeper — but in DATE mode only
* when the next unloaded episode falls inside the next 2-week band: a
* sparse/dormant show whose band is empty reports false, so fetch-more
* never drags in arbitrarily old episodes just because the parse cache
* holds them. When the loaded window reaches the cache length (or the
* band is empty), this flips false. */
const hasMoreEpisodes = (feedId: string): boolean => {
const cached = fullEpisodeCache.get(feedId);
if (!cached) return false;
const loaded = episodeLoadCount.get(feedId) ?? 0;
return loaded < cached.length;
if (loaded >= cached.length) return false;
const prefs = useAppStore().state().preferences;
if (prefs.episodeCacheMode === "count") return true;
const cutoff = dateFetchMoreCutoff(
cached,
loaded,
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
);
return epTs(cached[loaded]) >= cutoff;
};
/** 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
* (loadMoreAllFeeds) can loop over multiple feeds in one go. */
const loadMoreEpisodesForFeed = async (feedId: string) => {
@@ -769,7 +884,8 @@ function createFeedStore() {
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) {
try {
const response = await fetch(feed.podcast.feedUrl, {
@@ -789,27 +905,54 @@ function createFeedStore() {
// untouched rather than throwing out of loadMoreEpisodes.
return;
}
// Cold-refetch parse output is unsorted; sort and cap it so the
// cache and the pagination window stay newest-first and bounded.
// 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);
cached = cached.slice(0, MAX_EPISODES_IN_MEMORY);
// Same ceiling as fetchEpisodes: the cache (and the paging
// window below) never exceeds MAX_CACHED_EPISODES_PER_FEED.
cached = cached.slice(0, MAX_CACHED_EPISODES_PER_FEED);
fullEpisodeCache.set(feedId, cached);
// Set current load count to match what's already displayed
episodeLoadCount.set(feedId, feed.episodes.length);
}
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
const newCount = Math.min(
currentCount + MAX_EPISODES_REFRESH,
cached.length,
const prefs = useAppStore().state().preferences;
// Date mode: each press reveals the next FETCH_MORE_WINDOW_DAYS band
// past the oldest loaded episode (or the cache-window edge when
// nothing is loaded) — a daily show gains ~2 weeks of episodes, a
// weekly show gains its next 2, never a fixed count. An empty band
// is a genuine stop (hasMoreEpisodes hides the button) — no minimum,
// so a sparse/dormant show can't grab arbitrarily old episodes.
// Count mode keeps the fixed MAX_EPISODES_REFRESH chunk.
let newCount: number;
if (prefs.episodeCacheMode === "date") {
const cutoff = dateFetchMoreCutoff(
cached,
currentCount,
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
);
newCount = currentCount;
while (
newCount < cached.length &&
epTs(cached[newCount]) >= cutoff
) {
newCount++;
}
} else {
newCount = currentCount + MAX_EPISODES_REFRESH;
}
newCount = Math.min(newCount, cached.length);
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);
const episodes = cached.slice(0, newCount);
@@ -841,27 +984,88 @@ function createFeedStore() {
}
};
/** True if any feed still has cached episodes beyond its loaded window. */
const hasMoreAcrossAll = (): boolean => {
return feeds().some((f) => hasMoreEpisodes(f.id));
};
/** Advance the loaded window by MAX_EPISODES_REFRESH for every feed that
* still has cached episodes — powers the Feed page's "[Fetch More]". */
/** Power the Feed page's "[Fetch More]".
* Date mode: advance each feed's window by its 2-week band (empty bands
* — sparse/dormant shows — are skipped).
* Count mode: the global list cap grows by one count (see
* getAllEpisodesChronological) and every feed's window deepens by one
* count so the growing cap has material; one press reveals exactly N
* more RECENT episodes, never a far-back dump.
* Both modes compute every feed's new window FIRST (yielding between
* feeds so the renderer keeps painting) and apply ONE setFeeds — the
* Feed list rebuilds once per press instead of once per feed (the
* per-feed storms froze the UI). */
const loadMoreAllFeeds = async () => {
if (isLoadingMore()) return;
setIsLoadingMore(true);
try {
const pending = feeds().filter((f) => hasMoreEpisodes(f.id));
for (const feed of pending) {
await loadMoreEpisodesForFeed(feed.id);
const prefs = useAppStore().state().preferences;
const count = Math.max(1, prefs.episodeCacheCount ?? 25);
if (prefs.episodeCacheMode === "count") {
setCountFetchMorePresses((p) => p + 1);
}
const windowDays =
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS;
const updates: Array<{ feedId: string; episodes: Episode[] }> = [];
for (const feed of feeds()) {
const cached = fullEpisodeCache.get(feed.id);
if (!cached) continue;
const currentCount =
episodeLoadCount.get(feed.id) ?? feed.episodes.length;
if (currentCount >= cached.length) continue;
let newCount: number;
if (prefs.episodeCacheMode === "count") {
newCount = Math.min(currentCount + count, cached.length);
} else {
// Date mode: skip feeds whose next band is empty — the
// button must not surface arbitrarily old episodes.
const cutoff = dateFetchMoreCutoff(
cached,
currentCount,
windowDays,
);
if (epTs(cached[currentCount]) < cutoff) continue;
newCount = currentCount;
while (
newCount < cached.length &&
epTs(cached[newCount]) >= cutoff
) {
newCount++;
}
}
if (newCount <= currentCount) continue;
episodeLoadCount.set(feed.id, newCount);
updates.push({
feedId: feed.id,
episodes: cached.slice(0, newCount),
});
// Yield so the renderer paints between feed computations.
await yieldToUI();
}
if (updates.length > 0) {
const byId = new Map(
updates.map((u) => [u.feedId, u.episodes]),
);
setFeeds((prev) =>
prev.map((f) =>
byId.has(f.id)
? { ...f, episodes: byId.get(f.id)! }
: f,
),
);
scheduleSaveFeeds();
}
} finally {
setIsLoadingMore(false);
}
};
/** Run the global auto-download pass (see runAutoDownload above). */
const runAutoDownloadNow = (): void => {
runAutoDownload();
};
@@ -890,6 +1094,11 @@ function createFeedStore() {
// Actions
setFilter,
setSelectedFeedId,
/** Fetch + parse an RSS feed WITHOUT subscribing or touching any feed
* record (Discover's episode preview). Pass no feedId to skip the
* full-parse cache; the visible window is bounded by the user's
* cache preference and `limit`. */
fetchEpisodes,
addFeed,
hasFeedByUrl,
removeFeed,
@@ -910,7 +1119,6 @@ function createFeedStore() {
};
}
/** Singleton feed store */
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
export function useFeedStore() {

View File

@@ -64,16 +64,10 @@ function createProgressStore() {
*/
whenReady: () => progressInit,
/**
* Get progress for a specific episode.
*/
get(episodeId: string): Progress | undefined {
return progressMap()[episodeId];
},
/**
* Get all progress entries.
*/
all(): Record<string, Progress> {
return progressMap();
},
@@ -102,18 +96,12 @@ function createProgressStore() {
persist();
},
/**
* Check if an episode is completed.
*/
isCompleted(episodeId: string): boolean {
const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return false;
return p.position / p.duration >= COMPLETION_THRESHOLD;
},
/**
* Get progress percentage (0-100) for an episode.
*/
getPercent(episodeId: string): number {
const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return 0;
@@ -151,9 +139,6 @@ function createProgressStore() {
persist();
},
/**
* Clear all progress data.
*/
clear(): void {
setProgressMap({});
persist();

View File

@@ -60,7 +60,6 @@ function saveScope(scope: SearchScope): void {
}
}
/** Create search store */
export function createSearchStore() {
const feedStore = useFeedStore();
const [query, setQuery] = createSignal("");
@@ -167,7 +166,6 @@ export function createSearchStore() {
}
};
/** Add query to history */
const addToHistory = (q: string) => {
setHistory((prev) => {
const updated = sanitizeHistory([q, ...prev]);
@@ -176,13 +174,11 @@ export function createSearchStore() {
});
};
/** Clear search history */
const clearHistory = () => {
setHistory([]);
saveSearchHistoryToFile([]);
};
/** Remove single history item */
const removeFromHistory = (q: string) => {
setHistory((prev) => {
const updated = prev.filter((h) => h !== q);
@@ -191,14 +187,12 @@ export function createSearchStore() {
});
};
/** Clear results */
const clearResults = () => {
setResults([]);
setQuery("");
setError(null);
};
/** Mark a podcast as subscribed in results */
const markSubscribed = (podcastId: string, feedUrl?: string) => {
setResults((prev) =>
prev.map((result) => {
@@ -262,7 +256,6 @@ export function createSearchStore() {
};
}
/** Singleton search store */
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
export function useSearchStore() {

View File

@@ -96,6 +96,11 @@ export type FetchMoreMode = "manual" | "auto";
/** Which shows the auto-download setting applies to (default: all). */
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 = {
showExplicit: boolean;
autoDownload: boolean;
@@ -111,6 +116,12 @@ export type UserPreferences = {
fetchMoreMode: FetchMoreMode;
/** Minutes between automatic background feed refreshes (default: 30). */
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 = {

View File

@@ -156,9 +156,6 @@ function init() {
setRegistrations((arr) => arr.filter((x) => x !== results));
});
},
/**
* Get all visible options.
*/
get options() {
return visibleOptions();
},
@@ -195,9 +192,6 @@ export function CommandProvider(props: ParentProps) {
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
}
/**
* Command palette dialog component.
*/
function CommandDialog(props: {
options: CommandOption[];
suggestedOptions: CommandOption[];

View File

@@ -98,9 +98,6 @@ function init() {
})
return {
/**
* Clear all dialogs from the stack.
*/
clear() {
for (const item of store.stack) {
if (item.onClose) item.onClose()
@@ -113,9 +110,6 @@ function init() {
emit("dialog.close", {})
},
/**
* Replace all dialogs with a new one.
*/
replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
@@ -130,9 +124,6 @@ function init() {
emit("dialog.open", { dialogId: "dialog" })
},
/**
* Push a new dialog onto the stack.
*/
push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
@@ -143,9 +134,6 @@ function init() {
emit("dialog.open", { dialogId: "dialog" })
},
/**
* Pop the top dialog from the stack.
*/
pop() {
if (store.stack.length === 0) return
const current = store.stack.at(-1)!

View File

@@ -49,6 +49,9 @@ const defaultPreferences: UserPreferences = {
autoJumpToPlayer: true,
fetchMoreMode: "auto",
refreshIntervalMinutes: 30,
episodeCacheMode: "date",
episodeCacheCount: 25,
episodeCacheDays: 60,
};
const defaultState: AppState = {
@@ -59,7 +62,6 @@ const defaultState: AppState = {
// ── App State (config.json) ─────────────────────────────────────────────────
/** Load app state from config.json */
export async function loadAppStateFromFile(): Promise<AppState> {
try {
const cfg = await loadConfig();
@@ -85,7 +87,6 @@ export async function loadAppStateFromFile(): Promise<AppState> {
}
}
/** Save app state to config.json */
export function saveAppStateToFile(state: AppState): void {
updateConfig({
settings: state.settings,
@@ -106,7 +107,6 @@ interface ProgressEntry {
playbackSpeed?: number;
}
/** Load progress map from JSON file */
export async function loadProgressFromFile(): Promise<
Record<string, ProgressEntry>
> {
@@ -142,7 +142,6 @@ export function saveProgressToFile(data: Record<string, unknown>): void {
const SEARCH_HISTORY_FILE = "search-history.json";
/** Load search history from JSON file */
export async function loadSearchHistoryFromFile(): Promise<string[]> {
try {
const file = Bun.file(getConfigFilePath(SEARCH_HISTORY_FILE));
@@ -156,7 +155,6 @@ export async function loadSearchHistoryFromFile(): Promise<string[]> {
}
}
/** Save search history to JSON file (overwrite, no backup) */
export function saveSearchHistoryToFile(history: string[]): void {
(async () => {
try {
@@ -175,7 +173,6 @@ export function saveSearchHistoryToFile(history: string[]): void {
const AUDIO_NAV_FILE = "audio-nav.json";
/** Load audio navigation state from JSON file */
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
try {
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));

View File

@@ -23,9 +23,16 @@
* pass over just that region) — earlier segments stay valid, mp3 decode of
* the same file is deterministic so abutting segments agree.
*
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min (~80 MB per 30 min),
* freed on stop(). 22050 Hz covers Nyquist 11 kHz, above the default 10 kHz
* high-cutoff of the visualizer's FFT config.
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min. The cache is a
* SLIDING WINDOW around the playback position — the decode pass stops
* once it is maxAheadSec ahead of the cursor and segments entirely older
* than keepBehindSec behind it are dropped (both re-filled/restarted on
* demand). Steady state is bounded by (maxAheadSec + keepBehindSec) of
* audio (~40 MB at the defaults) INDEPENDENT of episode length; the old
* whole-episode cache grew ~160 MB per hour of audio and hit 2.5 GB on
* long-form episodes. Fully freed on stop(). 22050 Hz covers Nyquist
* 11 kHz, above the default 10 kHz high-cutoff of the visualizer's FFT
* config.
*
* Downloads via ffmpeg's own http stack with reconnect flags, matching the
* old reader; local files skip them (ffmpeg rejects http-only options for
@@ -49,6 +56,24 @@ const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024;
*/
const CLOSE_IN_PLACE_GAP_SEC = 15;
/**
* Default decode-head budget: the ffmpeg pass pauses once it is this far
* ahead of the playback cursor. Bounds RAM (~26 MB of s16 at 22050 Hz) AND
* the network pull — the old cache decoded the whole episode at 4x, so a
* 3h show pinned ~500 MB (2.5 GB+ for long-form) and dragged the entire
* remote file even when only the first 10 minutes were listened to. At 4x
* pacing a refill costs ~150s of background decode, one ffmpeg spawn per
* ~10 min of playback.
*/
const DEFAULT_DECODE_AHEAD_SEC = 600;
/**
* Default retention behind the cursor: decoded audio entirely older than
* this is dropped. Keeps pause/resume and small backward seeks instant
* without letting the window grow with playback time.
*/
const DEFAULT_KEEP_BEHIND_SEC = 300;
/**
* Monotonically increasing generation counter.
* Each startDecode() increments this; the read loop checks it to know
@@ -73,6 +98,10 @@ export interface EpisodePcmCacheOptions {
url: string;
/** Sample rate (default: 22050) */
sampleRate?: number;
/** Decode-head budget in seconds ahead of the cursor (default: 600). */
maxAheadSec?: number;
/** Retention in seconds behind the cursor (default: 300). */
keepBehindSec?: number;
}
export class EpisodePcmCache {
@@ -84,10 +113,15 @@ export class EpisodePcmCache {
private activeSegment: Segment | null = null;
readonly url: string;
readonly sampleRate: number;
/** Sliding-window budgets (see maintainWindow). */
readonly maxAheadSec: number;
readonly keepBehindSec: number;
constructor(options: EpisodePcmCacheOptions) {
this.url = options.url;
this.sampleRate = options.sampleRate ?? PCM_SAMPLE_RATE;
this.maxAheadSec = options.maxAheadSec ?? DEFAULT_DECODE_AHEAD_SEC;
this.keepBehindSec = options.keepBehindSec ?? DEFAULT_KEEP_BEHIND_SEC;
}
/** Whether an ffmpeg decode pass is currently running. */
@@ -238,11 +272,20 @@ export class EpisodePcmCache {
* new segment at `sec` (seek into a hole / resume past cached audio).
*/
ensureDecodeAround(sec: number): void {
// Enforce the sliding-window budget first (head cap, prune, refill)
// so a resume or seek never leaves stale segments behind the cursor.
this.maintainWindow(sec);
// Data already on hand: nothing needed here; only keep the tail
// filling if the decode is idle and the episode is unfinished.
// filling if the decode is idle, the episode is unfinished, AND the
// head is inside its budget. A head-capped cache ("we're maxAheadSec
// ahead, enough decoded") is NOT a stalled decode — restarting it
// here would fight maintainWindow's cap on every resume call.
if (this.covers(sec)) {
if (this._decoding || this.decodeFinished) return;
this.startDecode(this.coverageEndSec > sec ? this.coverageEndSec : sec);
const end = this.coverageEndSec;
if (end >= sec + this.maxAheadSec) return;
this.startDecode(end > sec ? end : sec);
return;
}
@@ -265,6 +308,48 @@ export class EpisodePcmCache {
this.startDecode(Math.max(0, sec));
}
/**
* Sliding-window budget for the in-memory cache, driven by the live
* playback position. Runs on every read (the render loop is the only
* consumer that knows the cursor continuously) and on resume/seek:
* - capHead: the decode pass pauses once it is maxAheadSec ahead of the
* cursor (pauseDecode keeps the decoded data — a plain startDecode
* from the frontier refills it later).
* - prune: segments entirely keepBehindSec behind the cursor are
* dropped. A backward seek past the window restarts a segment there —
* the same mechanism as a seek into an undecoded hole, so no new
* failure mode.
* - topUp: when the cursor has outrun the head, restart the tail decode
* from the frontier (one ffmpeg spawn per maxAheadSec of playback).
* Together these bound memory to (maxAheadSec + keepBehindSec) of audio
* regardless of episode length.
*/
private maintainWindow(atSec: number): void {
const pos = Math.max(0, atSec);
if (this._decoding && this.coverageEndSec >= pos + this.maxAheadSec) {
this.pauseDecode();
}
const keepFromSec = pos - this.keepBehindSec;
if (
this.segments.some(
(seg) => seg.baseSec + seg.written / this.sampleRate < keepFromSec,
)
) {
this.segments = this.segments.filter(
(seg) => seg.baseSec + seg.written / this.sampleRate >= keepFromSec,
);
}
if (!this._decoding && !this.decodeFinished) {
const end = this.coverageEndSec;
if (end < pos + this.maxAheadSec) {
this.startDecode(Math.max(end, pos));
}
}
}
/**
* Read the PCM window ENDING at `atSec` of playback into `out`
* (Int16 magnitudes widened to f64, the scale cavacore expects).
@@ -275,6 +360,7 @@ export class EpisodePcmCache {
*/
readWindow(out: Float64Array, atSec: number): number {
if (out.length === 0) return 0;
this.maintainWindow(atSec);
const endIdx = Math.round(atSec * this.sampleRate);
const startIdx = endIdx - out.length + 1;
for (const seg of this.segments) {

View File

@@ -24,6 +24,10 @@
* fills its demuxer cache ahead of time; the first real play just flips
* `pause` to false — the ~2s network open is paid at boot, not on the
* user's first Play.
* - Crash/kill recovery: a dead daemon (process exit or broken IPC socket)
* is detected on the next command; play() respawns a fresh daemon and
* reloads. resume() cannot unpause a freshly-idle daemon — it throws
* PlayerRestartedError so the caller reloads the episode via play().
*/
import { platform } from "os";
@@ -274,12 +278,28 @@ class MpvConnection {
}
this.handleTeardown();
}
/** True while the Unix socket is open — a live, reachable daemon. */
isConnected(): boolean {
return this.sock !== null;
}
}
// ── mpv Backend ──────────────────────────────────────────────────────
// One resident daemon for the app's lifetime, controlled over a single
// persistent JSON IPC connection with property observation.
/** Thrown by resume() when the daemon restarted (killed/crashed) and the
* previously-loaded file is gone — the fresh daemon is idle, so the
* caller must reload the episode via the full play path instead of
* unpausing (which would silently do nothing). */
export class PlayerRestartedError extends Error {
constructor() {
super("mpv restarted; episode must be reloaded");
this.name = "PlayerRestartedError";
}
}
/** Property observation ids (correlate property-change events). */
const OBS_TIME_POS = 1;
const OBS_PAUSE = 2;
@@ -318,14 +338,35 @@ export class MpvBackend implements AudioBackend {
// ── Daemon lifecycle ─────────────────────────────────────────────
private async ensureDaemon(): Promise<void> {
if (this.proc && !this._exited && this.conn) return;
// Healthy = process alive AND its IPC socket open. A socket teardown
// with a living process (rare) is just as unusable as a dead one —
// every command would fail "not-connected" forever.
if (this.proc && !this._exited && this.conn?.isConnected()) return;
if (this.startPromise) return this.startPromise;
this.startPromise = this.spawnDaemon().finally(() => {
this.startPromise = this.recoverDaemon().finally(() => {
this.startPromise = null;
});
return this.startPromise;
}
/** Bring up a usable daemon. If the old process still lives with a dead
* IPC connection, kill it so the fresh spawn owns the socket path and
* no orphan lingers — and await its exit so its exit handler can't run
* after spawnDaemon() and clobber the new daemon's `_exited` flag. */
private async recoverDaemon(): Promise<void> {
const stale = this.proc;
if (stale && !this._exited) {
try {
stale.kill();
} catch {
/* already gone */
}
}
if (stale) await stale.exited.catch(() => {});
this.conn = null;
await this.spawnDaemon();
}
private async spawnDaemon(): Promise<void> {
// Clean up stale socket
try {
@@ -360,9 +401,15 @@ export class MpvBackend implements AudioBackend {
this._exited = false;
this.proc.exited
.then(() => {
// Daemon died (crash or external kill): every per-file state
// is gone with it. _loadedUrl null forces the next play()
// down the full reload path; _position/_volume/_speed are
// kept so a recovery reload can carry them over.
this._exited = true;
this._intentPlaying = false;
this._loadedUrl = null;
this._loadedPaused = false;
this._ended = false;
this._paused = null;
})
.catch(() => {});
@@ -590,6 +637,13 @@ export class MpvBackend implements AudioBackend {
}
async resume(): Promise<void> {
// The daemon may have died while we were paused (crash/kill): bring
// a fresh one up. It starts idle — no file to unpause — so throw
// PlayerRestartedError and let the caller reload the episode.
await this.ensureDaemon();
if (!this._loadedUrl) {
throw new PlayerRestartedError();
}
if (this._ended && this._loadedUrl) {
// Play pressed on a finished episode: replay from the top.
this._ended = false;
@@ -609,7 +663,12 @@ export class MpvBackend implements AudioBackend {
this._loadedPaused = false;
}
this._ended = false;
await this.send(["set_property", "pause", false]);
const resp = await this.send(["set_property", "pause", false]);
// Never claim success when the unpause didn't land: a dead/restarted
// daemon would otherwise leave the UI "playing" with no audio.
if (resp.error && resp.error !== "success") {
throw new Error(`mpv resume failed: ${resp.error}`);
}
this._intentPlaying = true;
}

View File

@@ -52,7 +52,6 @@ const DEFAULTS: Required<CavaCoreConfig> = {
scalingMode: 0,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type CavaLib = {
symbols: Record<string, (...args: any[]) => any>;
close(): void;
@@ -102,7 +101,6 @@ export class CavaCore {
this.lib = lib;
}
/** Number of frequency bars configured. */
get bars(): number {
return this._bars;
}

View File

@@ -1,4 +1,4 @@
import type { Episode } from "../types/episode"
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
@@ -8,19 +8,56 @@ const ts = (ep: Episode): number => {
return t === undefined || Number.isNaN(t) ? Infinity : t
}
/** PubDate stamp for identity matching — undated episodes collapse to a
* single token so their twins match by title alone. */
const stamp = (ep: Episode): string => {
const t = ep.pubDate?.getTime()
return t === undefined || Number.isNaN(t) ? "undated" : String(t)
}
/**
* Content signature identifying the SAME episode across id changes. Episode
* ids are stable (guid / enclosure-URL derived), but a feed can still change
* an episode's id between refreshes: the one-time migration from the old
* positional-id scheme, or a host that rotates signed enclosure URLs. title +
* pubDate is the most stable combination that survives both — a feed
* re-issuing an episode with the same title and date IS that episode.
*/
export const episodeSignature = (ep: Episode): string =>
`${ep.title}\u0000${stamp(ep)}`
/**
* Union of two episode lists keyed by id — on collision the fetched copy
* wins (fresh metadata). Result is sorted newest-first by pubDate and capped
* at `cap` entries (oldest dropped). Never mutates either input.
* wins (fresh metadata). An existing episode whose id differs from every
* fetched id but whose content signature matches a fetched episode is a
* stale-id twin (id migration / rotating enclosure URLs) and is dropped,
* otherwise the union would double every episode on the first refresh after
* the id scheme changed. Existing episodes with NO fetched twin survive
* (volatile in-memory window). 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 mergeEpisodes(
export function mergeEpisodesBounded(
existing: Episode[],
fetched: Episode[],
cap: number,
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.slice(0, cap)
const bySignature = new Map<string, Episode>()
for (const ep of fetched) {
byId.set(ep.id, ep)
bySignature.set(episodeSignature(ep), ep)
}
const merged = [...byId.values()]
for (const ep of existing) {
if (byId.has(ep.id)) continue
if (bySignature.has(episodeSignature(ep))) continue
merged.push(ep)
}
const sorted = merged.sort((a, b) => ts(b) - ts(a))
return sorted.filter((ep, i) => keep(ep, i))
}

View File

@@ -43,7 +43,6 @@ function createEventBus(): EventBusInstance {
}
handlers.get(event)!.add(handler as EventHandler);
// Return unsubscribe function
return () => {
this.off(event, handler);
};

View File

@@ -10,22 +10,35 @@ import type { Episode } from "../types/episode";
import type { Feed } from "../types/feed";
import type { PodcastSource } from "../types/source";
/** Retention window for persisted episodes: older episodes are dropped when
* feeds are written to config.json unless they are completed downloads. */
export const PERSISTED_WINDOW_DAYS = 30;
/** 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 may be persisted: it is a completed download, or its
* pubDate is missing/invalid (fail-safe: never drop an undatable episode),
* or it falls inside the retention window. */
/** 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 {
if (downloadedIds.has(ep.id)) return true;
const t = ep.pubDate?.getTime();
if (!t || Number.isNaN(t)) return true;
return t >= now.getTime() - PERSISTED_WINDOW_DAYS * 24 * 3600 * 1000;
return downloadedIds.has(ep.id) || episodeInWindow(ep, now, days);
}
/** Episode ids of completed downloads, read from downloads.json. In-flight
@@ -70,12 +83,13 @@ function reviveDates(feed: Feed): Feed {
})),
};
}
/** Load feeds from config.json, pruning episodes outside the retention
* window (completed downloads always kept). When anything was pruned, the
* 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(): Promise<Feed[]> {
export async function loadFeedsFromFile(
windowDays?: number,
): Promise<Feed[]> {
try {
const cfg = await loadConfig();
if (!Array.isArray(cfg.feeds)) return [];
@@ -85,14 +99,14 @@ export async function loadFeedsFromFile(): Promise<Feed[]> {
let prunedAny = false;
const pruned = feeds.map((f) => {
const kept = f.episodes.filter((ep) =>
episodeIsPersistable(ep, downloadedIds, now),
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);
saveFeedsToFile(pruned, windowDays);
}
return pruned;
} catch {
@@ -104,14 +118,14 @@ export async function loadFeedsFromFile(): Promise<Feed[]> {
* (completed downloads always kept). Fire-and-forget: the prune reads
* 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[]): void {
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()),
episodeIsPersistable(ep, downloadedIds, new Date(), windowDays),
),
}));
updateConfig({ feeds: pruned });
@@ -120,8 +134,6 @@ export function saveFeedsToFile(feeds: Feed[]): void {
}
})().catch(() => {});
}
/** Load sources from config.json */
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
try {
const cfg = await loadConfig();
@@ -131,8 +143,6 @@ export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
return null;
}
}
/** Save sources to config.json */
export function saveSourcesToFile<T>(sources: T[]): void {
updateConfig({ sources: sources as unknown as PodcastSource[] });
}

View File

@@ -5,9 +5,6 @@
* and multi-line comments, which is useful for configuration files.
*/
/**
* Remove JSONC comments from a string
*/
function stripComments(jsonString: string): string {
const comments = [
{ pattern: /\/\/.*$/gm, replacement: "" },
@@ -23,9 +20,6 @@ function stripComments(jsonString: string): string {
return result;
}
/**
* Parse JSONC string into a JavaScript object
*/
export function parseJSONC(jsonString: string): unknown {
const stripped = stripComments(jsonString);
return JSON.parse(stripped);

View File

@@ -95,7 +95,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
}
}
/** Load keybinds from JSONC file */
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
try {
const filePath = getConfigFilePath(KEYBINDS_FILE);

View File

@@ -9,23 +9,14 @@
import { emit } from "./event-bus"
/**
* Emit a theme reload event.
*/
function emitThemeReload(): void {
emit("theme.reload", {})
}
/**
* Emit a theme changed event.
*/
export function emitThemeChanged(theme: string, mode: "dark" | "light"): void {
emit("theme.changed", { theme, mode })
}
/**
* Emit a theme mode changed event.
*/
export function emitThemeModeChanged(mode: "dark" | "light"): void {
emit("theme.mode.changed", { mode })
}

View File

@@ -1,28 +1,13 @@
/**
* Theme CSS Variable Manager
* Handles dynamic theme switching by updating CSS custom properties
* Terminal Theme Resolver
* Resolves the active theme (built-in, custom, or system-derived) to colors.
*/
import type { TerminalColors } from "@opentui/core";
import type { ThemeJson } from "../types/theme-schema";
import { THEME_JSON } from "../constants/themes";
import { getCustomThemes } from "./custom-themes";
import { resolveTheme as resolveThemeJson } from "./theme-resolver";
import { generateSystemTheme } from "./system-theme";
/**
* Apply CSS variable data-theme attribute
*/
export function setThemeAttribute(themeName: string) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.setAttribute("data-theme", themeName);
}
export async function loadThemes() {
return await getCustomThemes();
}
export function resolveTerminalTheme(
themes: Record<string, ThemeJson>,
name: string,
@@ -32,9 +17,5 @@ export function resolveTerminalTheme(
if (name === "system" && system) {
return resolveThemeJson(generateSystemTheme(system, mode), mode);
}
const theme = themes[name] ?? themes.catppuccin;
if (!theme) {
return resolveThemeJson(THEME_JSON.catppuccin, mode);
}
return resolveThemeJson(theme, mode);
return resolveThemeJson(themes[name] ?? themes.catppuccin, mode);
}

View File

@@ -22,11 +22,9 @@ background (read this before touching code):
deliverables:
- `src/utils/feeds-persistence.ts`:
- New exported constant `PERSISTED_WINDOW_DAYS = 30`.
- New exported pure function `episodeIsPersistable(ep: Episode, downloadedIds: Set<string>, 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() - PERSISTED_WINDOW_DAYS * 24 * 3600 * 1000`, OR
- `downloadedIds.has(ep.id)`.
- 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).

View File

@@ -1,4 +1,4 @@
# 02. Merge refreshes against the volatile in-memory episode window with bounded per-feed caches
# 02. Merge refreshes against the volatile in-memory episode window with a date-windowed cache
meta:
id: bounded-feed-lifecycle-02
@@ -9,7 +9,7 @@ meta:
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 so memory stops growing unbounded (`fullEpisodeCache` currently holds every parsed episode of every feed ever fetched).
- 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):
@@ -17,52 +17,60 @@ background (read this before touching code):
- `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: old episodes stay browsable until the app exits, fetched refreshes never shrink the list.
- 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:
- New `src/utils/episode-merge.ts` (pure, store-free, unit-testable):
- `mergeEpisodes(existing: Episode[], fetched: Episode[], cap: number): Episode[]` — union by `ep.id`; on id collision the `fetched` copy wins (fresh metadata); result sorted by `pubDate` descending; truncated to `cap` entries (the OLDEST are dropped — after sorting, a plain `.slice(0, cap)`).
- `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`:
- New constant `MAX_EPISODES_IN_MEMORY = 500` (comment: per-feed bound on both the cached parse results and the merged in-memory window; 500 covers years of a weekly show's history while capping a 20-subscription install at 10k episodes).
- `fetchEpisodes`: cap what goes into `fullEpisodeCache``fullEpisodeCache.set(feedId, allEpisodes.slice(0, MAX_EPISODES_IN_MEMORY))` (the array is already sorted newest-first via `sortEpisodesReverseChronological`). The LIMIT window returned to callers is unchanged.
- 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 = mergeEpisodes(f.episodes, episodes, MAX_EPISODES_IN_MEMORY)`.
- 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`: cap the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before capping); everything else (window growth by `MAX_EPISODES_REFRESH`, `hasMoreEpisodes` comparing `episodeLoadCount < cached.length`) works unchanged against the capped cache.
- `tests/feed-volatile-merge.test.ts` (new) — see tests section.
- `loadMoreEpisodesForFeed`: window-filter the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before filtering). Fetch-more stepping is mode-dependent: DATE mode advances the loaded window by a `FETCH_MORE_WINDOW_DAYS` (14) band past the oldest loaded episode — a daily show gains ~2 weeks of episodes per press, not a fixed count — with a +1 minimum so a sparse band can't wedge the button into a no-op; COUNT mode keeps the fixed `MAX_EPISODES_REFRESH` (50) chunk. `hasMoreEpisodes` still compares `episodeLoadCount < cached.length`.
- `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. Write `src/utils/episode-merge.ts` with `mergeEpisodes`.
3. Integrate in `feed.ts`: replace `sameEpisodes` usage with `sameRefreshWindow` + `mergeEpisodes` in `applyRefreshedEpisodes`; cap `fullEpisodeCache` writes in `fetchEpisodes` and `loadMoreEpisodesForFeed`; add `MAX_EPISODES_IN_MEMORY`.
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 (ArrangeActAssert) for `mergeEpisodes`:
- Pure unit (ArrangeActAssert) 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.
- cap trimming drops the oldest: `cap=2`, three episodes spanning three days → the two newest survive.
- 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).
- Bounded cache: serve 600 items (generate programmatically), refresh, then `hasMoreEpisodes` grows only to the cap: loop `loadMoreEpisodes` until it returns false and assert total loaded ≤ `MAX_EPISODES_IN_MEMORY` (import the constant from the store module if exported, else assert `=== 500`).
- Boundary: a 25-day-old episode loads; a 70-day-old episode is neither visible nor cached initially, but fetch-more surfaces it (volatile).
- Date stepping: 30 episodes at 3-day spacing — each fetch-more press reveals the next 2-week band (24 → 28 → 30), NOT a fixed 50-chunk.
- Count-mode global step: two feeds with staggered dates — one Feed-page press adds the configured N most-recent UNLOADED episodes across ALL shows (N total, not N per show), via the k-way frontier merge in `loadMoreAllFeedsByCount`.
- 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.
- 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 never exceed `MAX_EPISODES_IN_MEMORY`; `loadMore` stops (hasMore → false) at the cap.
- After a simulated restart (fresh store boot from a pruned config), fetch-more re-parses the feed and can surface over-30-day episodes in volatile memory.
- 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:
@@ -70,10 +78,10 @@ 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 deep pages stay; quit and relaunch — deep (over-30-day) pages are gone from the list but fetch-more brings them back.
- 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 cap belongs at the two write sites named in deliverables, not in a wrapper.
- `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.

View File

@@ -21,7 +21,7 @@ Dependencies
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.
- In-memory retention is capped per feed; episodes aged out of the persisted window remain browsable within the session and are re-fetchable via fetch-more after a restart.
- 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.

View File

@@ -12,6 +12,10 @@
* it by unpausing — the boot-restore fast path with no second load.
* 6. EOF: the episode ends → isPlaying() goes false on its own; pressing
* resume() afterwards replays from the top.
* 7. Daemon death: a killed/crashed mpv is detected (isAlive drops);
* resume() refuses to unpause the fresh idle daemon (throws
* PlayerRestartedError) and play() recovers by respawning a fresh
* daemon and loading the file.
*
* All playback runs silent (volume 0). Requires a real mpv on PATH;
* tests skip where it is missing.
@@ -19,7 +23,10 @@
import { test, expect } from "bun:test";
import { tmpdir } from "os";
import { join } from "path";
import { MpvBackend } from "../src/utils/audio-player";
import {
MpvBackend,
PlayerRestartedError,
} from "../src/utils/audio-player";
const SAMPLE_RATE = 22050;
const FREQ = 440;
@@ -165,6 +172,46 @@ test.skipIf(!hasMpv)(
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"daemon killed mid-play: resume() rejects on the fresh idle daemon; play() recovers a new one",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 0 });
await waitFor("playing", () => backend.isPlaying());
await waitFor(
"position advances",
async () => (await backend.getPosition()) > 0.5,
);
// Simulate a crash: SIGKILL the daemon out from under us.
const proc = (backend as unknown as { proc: { pid: number } }).proc;
expect(proc).toBeTruthy();
process.kill(proc.pid, "SIGKILL");
await waitFor("death observed", () => !backend.isAlive());
// resume() must NOT silently no-op on the dead daemon: it
// respawns, finds the fresh daemon idle (no file loaded), and
// throws — the hook falls back to the full play path.
await expect(backend.resume()).rejects.toThrow(PlayerRestartedError);
// play() (the hook's recovery) reuses the respawned daemon and
// plays the file — audio must actually advance again.
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 0 });
expect(backend.isAlive()).toBe(true);
await waitFor(
"recovered playback advances",
async () =>
(await backend.getPosition()) > 0.5 && backend.isPlaying(),
);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"EOF marks playback ended; resume() then replays from the top",
async () => {

View File

@@ -278,3 +278,87 @@ test.skipIf(!hasFfmpeg)(
},
{ timeout: 20000 },
);
test.skipIf(!hasFfmpeg)(
"decode head caps at maxAheadSec ahead of the cursor — the cache is a window, not a whole-episode dump",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({
url: wav,
maxAheadSec: 4,
keepBehindSec: 2,
});
try {
cache.startDecode(0);
// The 8s initial burst delivers the front of the file instantly.
await waitForCoverage(cache, 5);
// Park the cursor at 0 and drive the cap (the render loop reads
// every frame; the cap applies on the first read past the head).
const out = new Float64Array(512);
for (let i = 0; i < 30 && cache.decoding; i++) {
cache.readWindow(out, 0);
await Bun.sleep(20);
}
// Paused at the head budget (4s) + one 8s burst of slack — NOT
// decoded to the 30s EOF.
expect(cache.decoding).toBe(false);
expect(cache.coverageEndSec).toBeGreaterThanOrEqual(4);
expect(cache.coverageEndSec).toBeLessThanOrEqual(4 + 8 + 1);
expect(cache.decodeFinished).toBe(false);
// A parked cursor keeps the cap: more reads must not restart
// the pass or grow the cache.
const cappedAt = cache.coverageEndSec;
for (let i = 0; i < 10; i++) {
cache.readWindow(out, 0);
await Bun.sleep(20);
}
expect(cache.decoding).toBe(false);
expect(cache.coverageEndSec).toBeLessThanOrEqual(cappedAt + 1);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);
test.skipIf(!hasFfmpeg)(
"the window prunes segments behind the cursor as playback advances",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({
url: wav,
maxAheadSec: 4,
keepBehindSec: 2,
});
try {
// Two segments: the back half [10, ~18] and, after the seek,
// the front [2, ~10].
cache.startDecode(10);
await waitForCoverage(cache, 11);
cache.ensureDecodeAround(2);
await waitForCoverage(cache, 2.2);
expect(cache.covers(2.5)).toBe(true);
expect(cache.covers(10.5)).toBe(true);
// Cursor advances past the front segment's end + keepBehind:
// the front must fall out of the window, the back must survive.
const out = new Float64Array(512);
for (let i = 0; i < 40 && cache.covers(2.5); i++) {
cache.readWindow(out, 13);
await Bun.sleep(25);
}
expect(cache.covers(2.5)).toBe(false);
expect(cache.covers(10.5)).toBe(true);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);

View File

@@ -0,0 +1,337 @@
/**
* discover-episode-preview.test.tsx — Discover: `l`/right/enter on a podcast
* result must OPEN the show's episode list, NOT subscribe.
*
* Regression: `open` on a Discover podcast result (bound to `l`/right via
* `swipe-next`, and to enter) used to toggle subscription — pressing `l` on a
* show you wanted to browse subscribed it instead. `l`/right/enter now drill
* into a fetched-on-demand episode list (depth 2, no subscription), and `a`
* (the app-wide `subscribe` action) is the dedicated subscribe key.
*
* Mounts the real app (sandboxed, silent audio, mocked discover store) and
* drives the Discover tab with the test renderer's mock keys: drill category
* → podcast, `l` opens the episode list WITHOUT subscribing (feed store
* untouched, subscribe not called); `h` pops back; `a` subscribes the
* focused show; `l` then re-opens the episodes.
*
* App modules are loaded dynamically (never statically) because the sandbox
* config/data dirs must be set BEFORE they evaluate — their module-level init
* reads those env vars at import time.
*/
import { test, expect, afterAll, beforeAll, mock } from "bun:test";
import { testRender } from "@opentui/solid";
import { createSignal } from "solid-js";
import { mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import type { AudioControls } from "../src/hooks/useAudio";
import type { Episode } from "../src/types/episode";
import type { Podcast } from "../src/types/podcast";
import type { DepthFrame, NavigationState } from "../src/context/navigation-store";
// Recording audio stub: `play` pushes what was streamed. Registered FIRST so
// a leaked partial useAudio mock from another file in this worker can't break
// the app mount (see tests/search-focus.test.tsx for the same hazard).
const played: Episode[] = [];
const stubAudio: AudioControls = {
isPlaying: () => false,
position: () => 0,
duration: () => 0,
volume: () => 1,
speed: () => 1,
backendName: () => "none",
error: () => null,
currentEpisode: () => null,
availablePlayers: () => [],
play: async (episode: Episode) => {
played.push(episode);
},
load: async () => {},
pause: async () => {},
resume: async () => {},
togglePlayback: async () => {},
stop: async () => {},
seek: async () => {},
seekRelative: async () => {},
setVolume: async () => {},
setSpeed: async () => {},
switchBackend: async () => {},
prev: async () => {},
next: async () => {},
};
mock.module("../src/hooks/useAudio", () => ({
useAudio: () => stubAudio,
}));
// Deterministic discover store: `openEpisodes` seeds the episode list
// synchronously (no network), `subscribe`/`unsubscribe` flip the show's flag
// and are recorded so the test can assert l/enter never subscribed.
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
const [isLoading, setIsLoading] = createSignal(false);
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
const [preview, setPreview] = createSignal<Record<string, Episode[]>>({});
const [previewLoading, setPreviewLoading] = createSignal<Set<string>>(
new Set(),
);
const [previewErrors, setPreviewErrors] = createSignal<Record<string, string>>(
{},
);
const subscribeCalls: string[] = [];
const openCalls: string[] = [];
const flip = (id: string, subscribed: boolean) =>
setPodcasts((prev) =>
prev.map((p) => (p.id === id ? { ...p, isSubscribed: subscribed } : p)),
);
const mockDiscoverStore = {
selectedCategory,
isLoading,
podcasts,
categories: [
{ id: "all", name: "All", icon: "" },
{ id: "technology", name: "Technology", icon: "" },
],
filteredPodcasts: () => {
const cat = selectedCategory();
if (cat === "all") return podcasts();
return podcasts().filter((p) =>
(p.categories ?? []).some((c) =>
c.toLowerCase().includes(cat.toLowerCase()),
),
);
},
setSelectedCategory,
subscribe: (id: string) => {
subscribeCalls.push(id);
flip(id, true);
},
unsubscribe: (id: string) => {
flip(id, false);
},
refresh: async () => {},
episodesForPodcast: (id: string) => preview()[id] ?? [],
isLoadingEpisodesFor: (id: string) => previewLoading().has(id),
previewError: (id: string) => previewErrors()[id],
openEpisodes: async (pod: Podcast) => {
openCalls.push(pod.id);
setPreview((prev) => ({
...prev,
[pod.id]: [makeEpisode(1), makeEpisode(2)],
}));
},
refreshEpisodes: async () => {},
};
mock.module("../src/stores/discover", () => ({
DISCOVER_CATEGORIES: mockDiscoverStore.categories,
useDiscoverStore: () => mockDiscoverStore,
}));
// Sandbox BEFORE any app module evaluates — config-dir/persistence read these
// env vars at import time, so the app modules are loaded dynamically.
const SANDBOX = join(process.cwd(), ".harness", "test-discover-preview");
mkdirSync(join(SANDBOX, "config-home"), { recursive: true });
mkdirSync(join(SANDBOX, "data-home"), { recursive: true });
process.env.XDG_CONFIG_HOME = join(SANDBOX, "config-home");
process.env.XDG_DATA_HOME = join(SANDBOX, "data-home");
process.env.PODTUI_AUDIO_BACKEND = "none";
const { App } = await import("../src/App");
const { ThemeProvider } = await import("../src/context/ThemeContext");
const toast = await import("../src/ui/toast");
const { KeybindProvider, useKeybinds } = await import(
"../src/context/KeybindContext"
);
const { NavigationProvider, useNavigation } = await import(
"../src/context/NavigationContext"
);
const { DialogProvider } = await import("../src/ui/dialog");
const { CommandProvider } = await import("../src/ui/command");
const { TABS } = await import("../src/utils/navigation");
const { useFeedStore } = await import("../src/stores/feed");
function makePodcast(): Podcast {
return {
id: "featured-show",
title: "Featured Show",
description: "A featured show.",
feedUrl: "https://example.test/featured.xml",
author: "tester",
categories: ["Technology"],
lastUpdated: new Date(),
isSubscribed: false,
};
}
function makeEpisode(n: number): Episode {
return {
id: `featured-ep-${n}`,
podcastId: "featured-show",
title: `Featured Episode ${n}`,
description: "",
audioUrl: "https://example.test/ep.mp3",
duration: 0,
pubDate: new Date(`2026-08-0${n}T00:00:00Z`),
};
}
type MockInput = { pressKey: (key: string) => void; pressEnter: () => void };
type Mounted = {
renderer: { destroy: () => void };
renderOnce: () => Promise<void>;
mockInput: MockInput;
nav: () => NavigationState;
keybindsReady: () => boolean;
};
async function mountApp(): Promise<Mounted> {
let navRef: NavigationState | null = null;
let keybindsRef: { ready: boolean } | null = null;
const StateProbe = () => {
navRef = useNavigation();
keybindsRef = useKeybinds();
return null;
};
const HarnessRoot = () => (
<toast.ToastProvider>
<ThemeProvider mode="dark">
<KeybindProvider>
<NavigationProvider>
<StateProbe />
<DialogProvider>
<CommandProvider>
<App />
<toast.Toast />
</CommandProvider>
</DialogProvider>
</NavigationProvider>
</KeybindProvider>
</ThemeProvider>
</toast.ToastProvider>
);
const setup = await testRender(() => <HarnessRoot />, {
width: 100,
height: 30,
useThread: false,
});
// The test renderer intercepts stdout; the app is a TUI that writes frames
// asynchronously, so silence that interception (same as search-focus).
(
setup.renderer as unknown as {
disableStdoutInterception?: () => void;
}
).disableStdoutInterception?.();
await setup.renderOnce();
await sleep(60);
return {
renderer: setup.renderer,
renderOnce: setup.renderOnce,
mockInput: setup.mockInput,
nav: () => navRef!,
keybindsReady: () => keybindsRef?.ready ?? false,
};
}
function sleep(ms: number): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>();
setTimeout(resolve, ms);
return promise;
}
async function settleReady(m: Mounted): Promise<void> {
for (let i = 0; i < 80; i++) {
await m.renderOnce();
await sleep(60);
if (m.keybindsReady()) return;
}
throw new Error("keybinds never became ready");
}
async function waitFor(
m: Mounted,
cond: () => boolean,
what: string,
timeoutMs = 5000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (cond()) return;
await m.renderOnce();
await sleep(25);
}
throw new Error(`timed out waiting for: ${what}`);
}
beforeAll(() => {
setPodcasts([makePodcast()]);
});
afterAll(() => {
rmSync(SANDBOX, { recursive: true, force: true });
});
test("l on a podcast opens its episode list without subscribing; a subscribes", async () => {
const m = await mountApp();
try {
await settleReady(m);
// Open the Discover tab (digit press retried until the router attaches).
for (let i = 0; i < 20 && m.nav().activeTab() !== TABS.DISCOVER; i++) {
m.mockInput.pressKey("3");
await m.renderOnce();
await sleep(40);
}
expect(m.nav().activeTab()).toBe(TABS.DISCOVER);
m.mockInput.pressEnter(); // open the tab's content (category depth)
await waitFor(
m,
() => m.nav().currentDepth() === 0 && !m.nav().atRootTab(),
"discover content mounted",
);
// l on the focused category drills to the podcast results (depth 1).
m.mockInput.pressKey("l");
await waitFor(m, () => m.nav().currentDepth() === 1, "results depth");
expect(m.nav().topFrame()?.kind).toBe("results");
// l on the focused podcast opens its episode list (depth 2) — the
// show must NOT be subscribed, the feed store untouched.
m.mockInput.pressKey("l");
await waitFor(m, () => m.nav().currentDepth() === 2, "episodes depth");
expect(m.nav().topFrame()?.kind).toBe("episodes");
expect(m.nav().topFrame()?.ctx).toBe("featured-show");
expect(openCalls).toEqual(["featured-show"]);
expect(subscribeCalls).toHaveLength(0);
expect(played).toHaveLength(0);
expect(
useFeedStore()
.feeds()
.some((f) => f.podcast.id === "featured-show"),
).toBe(false);
// The seeded episode list is what the page renders at depth 2.
expect(mockDiscoverStore.episodesForPodcast("featured-show")).toHaveLength(
2,
);
// h pops back to the results (depth 1).
m.mockInput.pressKey("h");
await waitFor(m, () => m.nav().currentDepth() === 1, "back to results");
// a subscribes the focused show (the dedicated subscribe key).
m.mockInput.pressKey("a");
await waitFor(
m,
() => mockDiscoverStore.podcasts()[0]?.isSubscribed === true,
"a subscribes the show",
);
expect(subscribeCalls).toEqual(["featured-show"]);
// l still opens the episode list for a subscribed show (no toggle).
m.mockInput.pressKey("l");
await waitFor(m, () => m.nav().currentDepth() === 2, "episodes re-opened");
expect(subscribeCalls).toEqual(["featured-show"]);
expect(mockDiscoverStore.episodesForPodcast("featured-show")).toHaveLength(
2,
);
} finally {
m.renderer.destroy();
}
});

View File

@@ -0,0 +1,184 @@
/**
* discover-store-preview.test.ts — the Discover episode-preview store API.
*
* `openEpisodes` fetches a show's RSS feed WITHOUT subscribing (drill-in from
* a podcast result), caches it per podcast id for the session, records a
* per-show error on failure, and never refetches while cached or in flight.
* `refreshEpisodes` clears the cache/error and refetches.
*
* The REAL feed store runs against a local RSS server. No `mock.module`:
* bun test reuses workers across files and module mocks leak into the shared
* registry, so a feed-store mock here (whose stub lacks addFeed/refreshFeed/
* isLoadingFeeds) breaks every later file that shares a worker — the suite's
* documented failure mode. The repo's defense is importing the REAL modules
* via a query-suffixed specifier, which `mock.module` does not intercept.
*/
import { test, expect, beforeAll, afterAll } from "bun:test";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import type { Podcast } from "../src/types/podcast";
// Point the config dir at a throwaway directory BEFORE importing the stores
// (their module-level init reads it).
const configHome = mkdtempSync(join(tmpdir(), "podtui-discprev-"));
process.env.XDG_CONFIG_HOME = configHome;
// Query-suffixed module identity: loads the REAL discover store even when a
// sibling file's `mock.module("../src/stores/discover")` leaked into this
// worker. Its internal `./feed` import resolves the real feed store, which
// no file mocks anymore.
// @ts-expect-error — bun-only query suffix: distinct module identity that
// loads the real file instead of a leaked mock.module from another test file.
const { useDiscoverStore } = await import("../src/stores/discover?discover-store-preview");
interface ServedEpisode {
title: string;
date: string;
}
/** Pathnames the local server has served, in order (fetch tracking). */
const requests: string[] = [];
/** Per-path episode lists served by the local server. */
const served: Record<string, ServedEpisode[]> = {};
/** When set, responses for this path wait on the release callback. */
let gatePath: string | null = null;
let releaseGate: (() => void) | null = null;
/** XML for one show's episode list (ids derive from enclosure URLs). */
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>Test Show</title>
<description>Discover preview test feed</description>
${items}
</channel></rss>`;
}
let server: Bun.Server<undefined> | null = null;
let origin = "";
beforeAll(() => {
server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url);
requests.push(url.pathname);
if (gatePath && url.pathname === gatePath) {
await new Promise<void>((resolve) => {
releaseGate = resolve;
});
}
// A permanently-failing feed (simulates a show that went down).
if (url.pathname === "/fail.xml") {
return new Response("feed unavailable", { status: 503 });
}
const eps = served[url.pathname];
if (!eps) return new Response("not found", { status: 404 });
return new Response(feedXml(eps, url.origin), {
headers: { "Content-Type": "application/rss+xml" },
});
},
});
origin = `http://127.0.0.1:${server.port}`;
});
afterAll(() => {
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
});
function makePodcast(overrides: Partial<Podcast> = {}): Podcast {
return {
id: "show-1",
title: "Show 1",
description: "",
feedUrl: "https://example.test/feed.xml",
lastUpdated: new Date(),
isSubscribed: false,
...overrides,
};
}
test("openEpisodes fetches, caches, and never refetches on cache hit or in flight", async () => {
const store = useDiscoverStore();
const pod = makePodcast({ feedUrl: `${origin}/show1.xml` });
served["/show1.xml"] = [{ title: "Ep 1", date: "2026-08-10T00:00:00Z" }];
expect(store.episodesForPodcast(pod.id)).toHaveLength(0);
await store.openEpisodes(pod);
expect(requests).toEqual(["/show1.xml"]);
expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
expect(store.episodesForPodcast(pod.id)[0].title).toBe("Ep 1");
expect(store.isLoadingEpisodesFor(pod.id)).toBe(false);
expect(store.previewError(pod.id)).toBeUndefined();
// Cache hit: second open must not refetch.
await store.openEpisodes(pod);
expect(requests).toEqual(["/show1.xml"]);
// In-flight guard: a concurrent open during loading must not refetch.
// The server holds this show's response until the gate is released.
const pod2 = makePodcast({ id: "show-2", feedUrl: `${origin}/slow.xml` });
served["/slow.xml"] = [{ title: "Ep 2", date: "2026-08-09T00:00:00Z" }];
gatePath = "/slow.xml";
const pending = store.openEpisodes(pod2);
// Loading is set synchronously before the fetch resolves.
expect(store.isLoadingEpisodesFor(pod2.id)).toBe(true);
await store.openEpisodes(pod2); // must early-return, not queue a second fetch
// The request is held by the server gate; wait until it was actually
// received so the assertion isn't racing the network.
const deadline = Date.now() + 1000;
while (requests.length < 2 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 5));
}
expect(requests).toEqual(["/show1.xml", "/slow.xml"]);
releaseGate?.();
gatePath = null;
await pending;
expect(store.episodesForPodcast(pod2.id)[0].title).toBe("Ep 2");
expect(store.isLoadingEpisodesFor(pod2.id)).toBe(false);
});
test("openEpisodes records an error for feedless shows and failed fetches", async () => {
const store = useDiscoverStore();
const feedless = makePodcast({ id: "show-3", feedUrl: undefined });
await store.openEpisodes(feedless);
expect(requests).not.toContain(feedless.id);
expect(store.previewError(feedless.id)).toBe("No RSS feed listed for this show.");
expect(store.episodesForPodcast(feedless.id)).toHaveLength(0);
// Failed fetch (server 503) → error recorded, nothing cached.
const failing = makePodcast({ id: "show-4", feedUrl: `${origin}/fail.xml` });
await store.openEpisodes(failing);
expect(store.previewError(failing.id)).toBe("Couldn't load episodes.");
expect(store.episodesForPodcast(failing.id)).toHaveLength(0);
expect(store.isLoadingEpisodesFor(failing.id)).toBe(false);
});
test("refreshEpisodes clears the cache and error, then refetches", async () => {
const store = useDiscoverStore();
const pod = makePodcast({ id: "show-5", feedUrl: `${origin}/show5.xml` });
served["/show5.xml"] = [{ title: "Ep 1", date: "2026-08-10T00:00:00Z" }];
await store.openEpisodes(pod);
expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
const callsBefore = requests.length;
await store.refreshEpisodes(pod);
expect(requests.length).toBe(callsBefore + 1);
expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
expect(store.previewError(pod.id)).toBeUndefined();
});

View File

@@ -16,6 +16,11 @@
* property the same way the OS does and asserts useAudio reconciles in
* both directions. Skipped when mpv isn't installed.
*
* Also covers daemon crash recovery: killing mpv out from under the app
* must drop the UI out of "playing" (finalizeTrackEnd), and the next Play
* press must respawn a fresh daemon and resume audio from the saved
* position — the play button may never silently no-op on a dead player.
*
* Real-timer note: the reconcile path runs on useAudio's real 150ms poll
* interval against a real mpv process, with no injectable clock — the
* deliberate-exception case from the no-real-timers rule (same as
@@ -190,6 +195,27 @@ async function waitFor(
}
}
/** SIGKILL the backend's mpv daemon — a crash/kill out from under the app.
* The mpv command line carries the IPC socket path, so pgrep finds it by
* that (the socket name is unique to this test process). */
async function killMpvDaemon(): Promise<void> {
const socket = mpvSocket();
if (!socket) throw new Error("backend mpv socket not found");
const pids = (await Bun.$`pgrep -f ${socket}`.quiet().text())
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0)
.map(Number);
expect(pids.length).toBeGreaterThan(0);
for (const pid of pids) {
try {
process.kill(pid, "SIGKILL");
} catch {
/* already gone */
}
}
}
const episode = {
id: "ep1",
podcastId: "pod1",
@@ -235,6 +261,42 @@ test.skipIf(!hasMpv)(
{ timeout: 30000 },
);
test.skipIf(!hasMpv)(
"mpv killed mid-play: UI drops out of playing; pressing play recovers a fresh daemon",
async () => {
const audio = useAudio();
await audio.play(episode);
// Instant assertion: play() sets isPlaying synchronously when it
// succeeded. (In a shared worker that leaked a store mock from
// another test file, play() fails and this catches it at 0ms
// instead of burning the waitFor timeout below.)
expect(audio.isPlaying()).toBe(true);
// Let the clock advance past the 5s progress-save floor so recovery
// has a saved position to resume from (positions <5s are not stored).
await waitFor(() => audio.position() > 6);
const crashPos = audio.position();
// Crash the player out from under the app.
await killMpvDaemon();
await waitFor(() => !audio.isPlaying());
expect(audio.isPlaying()).toBe(false);
// The episode stays current — recovery can restart it.
expect(audio.currentEpisode()?.id).toBe("ep1");
// Press play: must respawn mpv and resume from the saved position —
// not silently flip the UI to "playing" with no process behind it.
await audio.togglePlayback();
expect(audio.isPlaying()).toBe(true);
expect(audio.position()).toBeGreaterThanOrEqual(crashPos - 0.5);
// Audio actually advances again — proof a fresh daemon is playing.
await waitFor(() => audio.position() > crashPos + 0.5);
await audio.stop();
expect(audio.isPlaying()).toBe(false);
},
{ timeout: 30000 },
);
// ── Teardown ──────────────────────────────────────────────────────────────
afterAll(async () => {

View File

@@ -62,7 +62,7 @@ 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). */
/** XML for the current served episode list (episode ids derive from enclosure URLs). */
function feedXml(episodes: ServedEpisode[], origin: string): string {
const items = episodes
.map(

View File

@@ -3,11 +3,10 @@
* row in a drilled show's episode list (My Shows depth 1) and the Feed
* page's row.
*
* addFeed caches the FULL parsed feed while exposing only the first
* MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes` reports when the
* cache holds more than the loaded window; `loadMoreEpisodes` advances that
* window in MAX_EPISODES_REFRESH (50) chunks until it is exhausted. This
* pins:
* Runs in COUNT cache mode: `loadMoreEpisodes` advances the loaded window in
* fixed MAX_EPISODES_REFRESH (50) chunks until the cache is exhausted.
* (Date-mode fetch-more steps by a two-week window instead — that contract
* is pinned in feed-volatile-merge.test.ts.) This pins:
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
* needed) and hasMoreEpisodes flips false once the window reaches the end.
@@ -25,8 +24,11 @@ const configHome = mkdtempSync(join(tmpdir(), "podtui-pagination-"));
process.env.XDG_CONFIG_HOME = configHome;
import { useFeedStore } from "../src/stores/feed";
import { useAppStore } from "../src/stores/app";
import type { Podcast } from "../src/types/podcast";
const HOUR = 3600 * 1000;
interface ServedEpisode {
title: string;
date: string;
@@ -40,7 +42,7 @@ let servedEpisodes: ServedEpisode[] = [];
// store (execution order between files is not guaranteed).
const addedFeedIds: string[] = [];
/** XML for the current served episode list (episode ids = feedUrl#index). */
/** XML for the current served episode list (episode ids derive from enclosure URLs). */
function feedXml(episodes: ServedEpisode[], origin: string): string {
const items = episodes
.map(
@@ -69,7 +71,15 @@ const makePodcast = (feedUrl: string): Podcast => ({
isSubscribed: true,
});
beforeAll(() => {
beforeAll(async () => {
// The app store loads its persisted prefs asynchronously at import; wait
// for that so our count-mode override isn't clobbered by the load.
await useAppStore().whenReady();
// Chunk-based stepping is count-mode behavior (see header comment).
useAppStore().updatePreferences({
episodeCacheMode: "count",
episodeCacheCount: 25,
});
server = Bun.serve({
port: 0,
fetch(req) {
@@ -86,6 +96,7 @@ beforeAll(() => {
afterAll(() => {
// Leave the shared singleton as we found it (see addedFeedIds note).
useAppStore().updatePreferences({ episodeCacheMode: "date" });
const store = useFeedStore();
for (const id of addedFeedIds) store.removeFeed(id);
server?.stop(true);
@@ -94,10 +105,12 @@ afterAll(() => {
test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", async () => {
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) => ({
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 feed = await store.addFeed(makePodcast(feedUrl), "test-source");
@@ -123,9 +136,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 () => {
const store = useFeedStore();
// 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) => ({
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 feed = await store.addFeed(makePodcast(feedUrl), "test-source");

View File

@@ -0,0 +1,230 @@
/**
* Feed-refresh Effect program tests (src/effects/feed-refresh.ts).
*
* These test the Effect program in isolation — no store singleton, no
* network, no fake timers. The fetch/apply closures are injected, and the
* `Clock` service comes from TestContext's TestClock, so timeouts are driven
* deterministically with TestClock.adjust instead of real 20s waits.
*
* Contracts pinned here (mirrored at the store level by
* feed-nonblocking.test.ts / feed-refresh.test.ts against a real Bun.serve):
* 1. Bounded concurrency — never more than `concurrency` fetches in
* flight, and the pool pulls the next feed as one completes.
* 2. Per-feed apply as its own fetch lands (no barrier).
* 3. A timed-out fetch leaves that feed untouched and does not stall the
* batch (TestClock.adjust fires the timeout deterministically).
* 4. A rejecting fetch leaves that feed untouched and does not fail the
* batch.
*/
import { test, expect } from "bun:test"
import { Duration, Effect, Fiber, TestClock, TestContext } from "effect"
import {
refreshFeedsBatch,
type RefreshFetchResult,
} from "../src/effects/feed-refresh"
import type { Feed } from "../src/types/feed"
import type { Podcast } from "../src/types/podcast"
import type { Episode } from "../src/types/episode"
const makePodcast = (id: string): Podcast => ({
id,
title: `Show ${id}`,
description: `Show ${id} description`,
feedUrl: `http://example.com/${id}.xml`,
lastUpdated: new Date(0),
isSubscribed: true,
})
const makeFeed = (id: string): Feed => ({
id,
podcast: makePodcast(id),
episodes: [],
visibility: "public" as Feed["visibility"],
sourceId: "test",
lastUpdated: new Date(0),
isPinned: false,
})
const makeEpisode = (id: string): Episode => ({
id,
podcastId: "pod",
title: `Ep ${id}`,
description: "",
audioUrl: `https://example.com/${id}.mp3`,
duration: 60,
pubDate: new Date(0),
})
/** Resolve an episode result without dragging in the full RSS shape. */
const ok = (episodeIds: string[]): RefreshFetchResult => ({
episodes: episodeIds.map(makeEpisode),
coverUrl: undefined,
})
/** One macrotask turn — lets microtask-scheduled Effect fibers run. */
const tick = (): Promise<void> => {
const { promise, resolve } = Promise.withResolvers<void>()
setImmediate(resolve)
return promise
}
/** A resolvable fetch gate: the pool parks on `promise` until the test
* resolves it. (Promise.withResolvers's return type is not in tsconfig's
* ES2015.Promise lib, hence the explicit shape.) */
interface Gate {
promise: Promise<RefreshFetchResult>
resolve: (value: RefreshFetchResult) => void
}
/** Poll `cond` across up to `iterations` event-loop turns. */
async function pollUntil(
cond: () => boolean,
iterations = 500,
): Promise<boolean> {
for (let i = 0; i < iterations; i++) {
if (cond()) return true
await tick()
}
return cond()
}
test("bounds in-flight fetches to the configured concurrency", async () => {
const feeds = Array.from({ length: 10 }, (_, i) => makeFeed(`feed-${i}`))
let inFlight = 0
let maxInFlight = 0
const gates: Gate[] = []
const applied: string[] = []
const program = refreshFeedsBatch(
feeds,
(feed) => {
inFlight++
if (inFlight > maxInFlight) maxInFlight = inFlight
const gate = Promise.withResolvers<RefreshFetchResult>()
gates.push(gate)
return gate.promise.finally(() => {
inFlight--
})
},
(feed) => {
applied.push(feed.id)
},
{ concurrency: 4, timeoutMs: 60_000 },
)
// Run the batch in flight (NOT awaited) and observe the pool from
// outside via the gate side effects.
const done = Effect.runPromise(program)
// The pool starts exactly `concurrency` fetches up front.
const sawStart = await pollUntil(() => gates.length >= 4)
expect(sawStart).toBe(true)
expect(maxInFlight).toBe(4)
expect(gates.length).toBe(4)
// Resolve one gate: the pool pulls the next feed, still bounded at 4.
gates[0].resolve(ok(["a"]))
const sawPull = await pollUntil(() => gates.length >= 5)
expect(sawPull).toBe(true)
expect(maxInFlight).toBeLessThanOrEqual(4)
// Release everything, re-draining as the pool pulls new gates, until
// every feed has been fetched and applied.
while (applied.length < 10) {
for (const gate of gates.splice(0)) gate.resolve(ok(["x"]))
await tick()
}
await done
expect(maxInFlight).toBeLessThanOrEqual(4)
expect(applied).toHaveLength(10)
})
test("applies each feed as its own fetch lands (no barrier)", async () => {
const a = makeFeed("a")
const b = makeFeed("b")
const applied: string[] = []
let bCalled = false
const gateB = Promise.withResolvers<RefreshFetchResult>()
const program = refreshFeedsBatch(
[a, b],
(feed) => {
if (feed.id === "a") return Promise.resolve(ok(["a-1"]))
bCalled = true
return gateB.promise
},
(feed) => {
applied.push(feed.id)
},
{ concurrency: 4, timeoutMs: 60_000 },
)
// Run the batch in flight; A's fetch resolves and applies while B's is
// still parked at the gate.
const done = Effect.runPromise(program)
const aApplied = await pollUntil(() => applied.includes("a"))
expect(aApplied).toBe(true)
expect(bCalled).toBe(true)
expect(applied).toEqual(["a"])
expect(applied).not.toContain("b")
gateB.resolve(ok(["b-1"]))
await done
expect(applied).toEqual(["a", "b"])
})
test("a timed-out fetch leaves that feed untouched, without stalling the batch", async () => {
const fast = makeFeed("fast")
const hung = makeFeed("hung")
const applied: string[] = []
// A promise that never settles — the fetch hangs past the timeout.
const never = new Promise<RefreshFetchResult>(() => {})
const program = refreshFeedsBatch(
[fast, hung],
(feed) =>
feed.id === "fast"
? Promise.resolve(ok(["f-1"]))
: never,
(feed) => {
applied.push(feed.id)
},
{ concurrency: 4, timeoutMs: 5_000 },
)
const timed = Effect.gen(function* () {
const fiber = yield* Effect.fork(program)
// Advance the TestClock past the timeout: the hung fetch's
// Effect.timeout fires deterministically — no real 5s wait.
yield* TestClock.adjust(Duration.millis(5_000))
yield* Fiber.join(fiber)
})
await Effect.runPromise(
timed.pipe(Effect.provide(TestContext.TestContext)),
)
// The fast feed applied; the hung one was dropped, and the batch
// completed anyway.
expect(applied).toEqual(["fast"])
})
test("a rejecting fetch leaves that feed untouched and does not fail the batch", async () => {
const bad = makeFeed("bad")
const good = makeFeed("good")
const applied: string[] = []
const program = refreshFeedsBatch(
[bad, good],
(feed) =>
feed.id === "bad"
? Promise.reject(new Error("feed exploded"))
: Promise.resolve(ok(["g-1"])),
(feed) => {
applied.push(feed.id)
},
{ concurrency: 4, timeoutMs: 60_000 },
)
await Effect.runPromise(program)
expect(applied).toEqual(["good"])
})

View File

@@ -41,11 +41,12 @@ let delayMs = 0;
let feedUrl = "";
let feedId = "";
/** 3 episodes × 3 rows = 9 list rows: the spinner sits right below them. */
/** 3 episodes × 3 rows = 9 list rows: the spinner sits right below them.
* Dated inside the lifecycle window (13 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.UTC(2026, 0, 1 + i)).toISOString()}</pubDate>
<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"?>

View File

@@ -29,6 +29,8 @@ process.env.XDG_CONFIG_HOME = configHome;
import { useFeedStore } from "../src/stores/feed";
import type { Podcast } from "../src/types/podcast";
const HOUR = 3600 * 1000;
interface ServedEpisode {
title: string;
date: string;
@@ -40,7 +42,7 @@ let feedAId = "";
/** When set, the server 503s this path — simulates a feed going down. */
let failPath: string | null = null;
/** XML for the current served episode list (episode ids = feedUrl#index). */
/** XML for the current served episode list (episode ids derive from enclosure URLs). */
function feedXml(episodes: ServedEpisode[], origin: string): string {
const items = episodes
.map(
@@ -77,6 +79,18 @@ beforeAll(() => {
if (failPath && url.pathname === failPath) {
return new Response("feed unavailable", { status: 503 });
}
// A dedicated single-episode feed for the failed-refresh test:
// it must not depend on (or shrink) the shared servedEpisodes
// list, which other tests' feeds read on refreshAllFeeds.
if (url.pathname === "/flaky.xml") {
return new Response(
feedXml(
[{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }],
url.origin,
),
{ headers: { "Content-Type": "application/rss+xml" } },
);
}
if (url.pathname.endsWith(".xml")) {
return new Response(feedXml(servedEpisodes, url.origin), {
headers: { "Content-Type": "application/rss+xml" },
@@ -137,8 +151,9 @@ test("refresh with a genuinely new episode bumps lastUpdated", async () => {
test("a failed refresh does not wipe the feed's episodes", async () => {
const store = useFeedStore();
const savedEpisodes = servedEpisodes;
servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }];
// /flaky.xml serves its own fixed single-episode feed (see server route)
// so the shared servedEpisodes list stays untouched for feedA, which
// refreshAllFeeds below also refreshes.
const feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull();
@@ -159,11 +174,6 @@ test("a failed refresh does not wipe the feed's episodes", async () => {
failPath = null;
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 () => {
@@ -193,10 +203,12 @@ test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () =>
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.
// 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.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}/chunky.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");

View File

@@ -2,7 +2,7 @@
* Bounded-feed-lifecycle persistence tests — task 01 (retention window).
*
* Pins the persistence contract:
* 1. saveFeedsToFile never writes an episode older than PERSISTED_WINDOW_DAYS
* 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.
@@ -27,7 +27,7 @@ const configHome = mkdtempSync(join(tmpdir(), "podtui-retention-"));
process.env.XDG_CONFIG_HOME = configHome;
import {
PERSISTED_WINDOW_DAYS,
DEFAULT_EPISODE_WINDOW_DAYS,
episodeIsPersistable,
loadFeedsFromFile,
saveFeedsToFile,
@@ -134,18 +134,18 @@ afterAll(() => {
// ── Unit: episodeIsPersistable ──────────────────────────────────────────────
test("episodeIsPersistable drops a 40-day-old episode that is not downloaded", () => {
test("episodeIsPersistable drops a 70-day-old episode that is not downloaded", () => {
const ep = makeEpisode({
id: "old-plain-id",
pubDate: new Date(Date.now() - 40 * DAY),
pubDate: new Date(Date.now() - 70 * DAY),
});
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(false);
});
test("episodeIsPersistable keeps a 40-day-old episode whose id is a completed download", () => {
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() - 40 * DAY),
pubDate: new Date(Date.now() - 70 * DAY),
});
expect(
episodeIsPersistable(ep, new Set(["old-downloaded-id"]), new Date()),
@@ -165,8 +165,8 @@ test("episodeIsPersistable keeps an episode with an invalid pubDate", () => {
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(true);
});
test("PERSISTED_WINDOW_DAYS is 30", () => {
expect(PERSISTED_WINDOW_DAYS).toBe(30);
test("DEFAULT_EPISODE_WINDOW_DAYS is 60", () => {
expect(DEFAULT_EPISODE_WINDOW_DAYS).toBe(60);
});
// ── Save path: retention window applied with completed-download exemption ──
@@ -199,11 +199,11 @@ test("saveFeedsToFile prunes over-window episodes but keeps completed downloads"
}),
makeEpisode({
id: "old-plain-id",
pubDate: new Date(Date.now() - 40 * DAY),
pubDate: new Date(Date.now() - 70 * DAY),
}),
makeEpisode({
id: "old-downloaded-id",
pubDate: new Date(Date.now() - 40 * DAY),
pubDate: new Date(Date.now() - 70 * DAY),
}),
]);
saveFeedsToFile([feed]);
@@ -249,7 +249,7 @@ test("loadFeedsFromFile prunes over-window episodes and rewrites config.json", a
description: "",
audioUrl: "https://example.com/audio/old-a.mp3",
duration: 60,
pubDate: new Date(Date.now() - 40 * DAY).toISOString(),
pubDate: new Date(Date.now() - 70 * DAY).toISOString(),
},
{
id: "old-b",
@@ -258,7 +258,7 @@ test("loadFeedsFromFile prunes over-window episodes and rewrites config.json", a
description: "",
audioUrl: "https://example.com/audio/old-b.mp3",
duration: 60,
pubDate: new Date(Date.now() - 40 * DAY).toISOString(),
pubDate: new Date(Date.now() - 70 * DAY).toISOString(),
},
],
visibility: "public",

View File

@@ -1,19 +1,21 @@
/**
* Volatile in-memory episode merge + bounded cache tests.
* Configurable episode cache + volatile merge tests.
*
* Two behaviors from the bounded-feed-lifecycle work:
* 1. mergeEpisodes unions refreshed episodes with what's already in memory
* (the fetched copy wins on id collision), so a refresh never shrinks
* the session's visible window; the union is capped per feed at
* MAX_EPISODES_IN_MEMORY.
* 2. The per-feed parse cache is capped at MAX_EPISODES_IN_MEMORY, so
* loadMoreEpisodes can never surface more than the cap and
* hasMoreEpisodes flips false there.
* 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.
*
* Unchanged-refresh detection compares the fetched window against the
* corresponding PREFIX of the merged list (sameRefreshWindow) — comparing
* full lists would bump lastUpdated on every refresh because the merged list
* legitimately holds episodes beyond the fetched window.
* 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";
@@ -26,11 +28,16 @@ import { join } from "path";
const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-"));
process.env.XDG_CONFIG_HOME = configHome;
import { MAX_EPISODES_IN_MEMORY, useFeedStore } from "../src/stores/feed";
import { mergeEpisodes } from "../src/utils/episode-merge";
import { sameRefreshWindow, 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;
@@ -38,13 +45,8 @@ interface ServedEpisode {
let server: ReturnType<typeof Bun.serve> | null = null;
let servedEpisodes: ServedEpisode[] = [];
// Bun runs test files in ONE process, so the store singleton is shared with
// the other feed test files. Track the feeds we add and remove them in
// afterAll so whichever file runs next sees a pristine store (execution
// order between files is not guaranteed).
const addedFeedIds: string[] = [];
/** XML for the current served episode list (episode ids = feedUrl#index). */
function feedXml(episodes: ServedEpisode[], origin: string): string {
const items = episodes
.map(
@@ -104,16 +106,17 @@ beforeEach(() => {
afterAll(() => {
vi.useRealTimers();
// Leave the shared singleton as we found it (see addedFeedIds note).
const store = useFeedStore();
for (const id of addedFeedIds) store.removeFeed(id);
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
});
// ── mergeEpisodes unit tests ─────────────────────────────────────────────
// ── mergeEpisodesBounded unit tests ──────────────────────────────────────
test("mergeEpisodes dedupes on id collision and keeps the fetched copy", () => {
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")),
@@ -121,14 +124,76 @@ test("mergeEpisodes dedupes on id collision and keeps the fetched copy", () => {
const fetched = [
makeEpisode("a", "New Title", new Date("2026-08-01T00:00:00Z")),
];
const keepAll = () => true;
const merged = mergeEpisodes(existing, fetched, 10);
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
expect(merged).toHaveLength(2);
expect(merged.find((e) => e.id === "a")!.title).toBe("New Title");
});
test("mergeEpisodes unions disjoint lists sorted newest-first", () => {
test("mergeEpisodesBounded drops stale-id twins (id migration / rotating enclosure URLs)", () => {
// The same two episodes with different ids on both sides — exactly what a
// refresh sees after the positional-id → stable-id migration (or a host
// that rotates signed audio URLs). Without content matching the union
// would double every episode.
const d1 = new Date("2026-08-01T00:00:00Z");
const d2 = new Date("2026-08-02T00:00:00Z");
const existing = [
makeEpisode("feed#0", "Ep 1", d1),
makeEpisode("feed#1", "Ep 2", d2),
];
const fetched = [
makeEpisode("feed#guid:g1", "Ep 1", d1),
makeEpisode("feed#guid:g2", "Ep 2", d2),
];
const keepAll = () => true;
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
expect(merged.map((e) => e.id)).toEqual(["feed#guid:g2", "feed#guid:g1"]);
expect(merged).toHaveLength(2);
});
test("mergeEpisodesBounded keeps an existing episode with no fetched twin (volatile window)", () => {
// Fetched covers Ep 1 only (by content twin). Ep 2 exists only in memory
// — the volatile window — and must survive the refresh.
const d1 = new Date("2026-08-01T00:00:00Z");
const d2 = new Date("2026-08-02T00:00:00Z");
const existing = [
makeEpisode("feed#0", "Ep 1", d1),
makeEpisode("feed#1", "Ep 2", d2),
];
const fetched = [makeEpisode("feed#guid:g1", "Ep 1", d1)];
const keepAll = () => true;
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
expect(merged).toHaveLength(2);
expect(merged.map((e) => e.title).sort()).toEqual(["Ep 1", "Ep 2"]);
});
test("sameRefreshWindow treats id drift with identical content as unchanged", () => {
// Same episode, id changed between refreshes (migration / URL rotation):
// the refresh must NOT bump lastUpdated or re-render.
const d = new Date("2026-08-01T00:00:00Z");
const existing = [makeEpisode("feed#0", "Ep 1", d)];
const fetched = [makeEpisode("feed#guid:g1", "Ep 1", d)];
expect(sameRefreshWindow(existing, fetched)).toBe(true);
});
test("sameRefreshWindow flags a genuinely new episode even when ids drift", () => {
const d1 = new Date("2026-08-01T00:00:00Z");
const d2 = new Date("2026-08-02T00:00:00Z");
const existing = [makeEpisode("feed#0", "Ep 1", d1)];
const fetched = [
makeEpisode("feed#guid:g2", "Ep 2", d2),
makeEpisode("feed#guid:g1", "Ep 1", d1),
];
expect(sameRefreshWindow(existing, fetched)).toBe(false);
});
test("mergeEpisodesBounded unions disjoint lists sorted newest-first", () => {
const existing = [
makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")),
];
@@ -136,13 +201,14 @@ test("mergeEpisodes unions disjoint lists sorted newest-first", () => {
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 = mergeEpisodes(existing, fetched, 10);
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
expect(merged.map((e) => e.id)).toEqual(["newest", "mid", "old"]);
});
test("mergeEpisodes drops the oldest episodes past the cap", () => {
test("mergeEpisodesBounded with count keep drops oldest beyond the count", () => {
const existing = [
makeEpisode("day1", "Day 1", new Date("2026-08-01T00:00:00Z")),
];
@@ -150,13 +216,32 @@ test("mergeEpisodes drops the oldest episodes past the cap", () => {
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 = mergeEpisodes(existing, fetched, 2);
const merged = mergeEpisodesBounded(existing, fetched, keepCount2);
expect(merged.map((e) => e.id)).toEqual(["day3", "day2"]);
});
test("mergeEpisodes never mutates its inputs", () => {
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")),
@@ -167,18 +252,15 @@ test("mergeEpisodes never mutates its inputs", () => {
];
const existingIds = existing.map((e) => e.id);
const existingTitles = existing.map((e) => e.title);
const fetchedIds = fetched.map((e) => e.id);
const fetchedTitles = fetched.map((e) => e.title);
const keepAll = () => true;
mergeEpisodes(existing, fetched, 10);
mergeEpisodesBounded(existing, fetched, keepAll);
expect(existing.map((e) => e.id)).toEqual(existingIds);
expect(existing.map((e) => e.title)).toEqual(existingTitles);
expect(fetched.map((e) => e.id)).toEqual(fetchedIds);
expect(fetched.map((e) => e.title)).toEqual(fetchedTitles);
});
// ── store integration ────────────────────────────────────────────────────
// ── store integration (default date mode, 60-day window) ─────────────────
test("refresh merges new episodes without removing the volatile window", async () => {
const store = useFeedStore();
@@ -195,8 +277,6 @@ test("refresh merges new episodes without removing the volatile window", async (
expect(store.getFeed(id)!.episodes.length).toBe(3);
const beforeUpdated = store.getFeed(id)!.lastUpdated.getTime();
// The feed now serves the same 3 episodes plus 2 newer ones (new ids at
// item indices 3 and 4).
servedEpisodes = [
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
@@ -221,32 +301,238 @@ test("refresh merges new episodes without removing the volatile window", async (
expect(afterSecond.lastUpdated.getTime()).toBe(afterFirst.lastUpdated.getTime());
});
test("cached episodes are capped at MAX_EPISODES_IN_MEMORY", async () => {
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(Date.UTC(2026, 0, 1 + i)).toISOString(),
date: new Date(now - i * 2 * HOUR).toISOString(),
}));
const feedUrl = `http://127.0.0.1:${server!.port}/huge.xml`;
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 (MAX_EPISODES_SUBSCRIBE = 20) with 480 more cached.
// Subscribe window (20) with more cached.
expect(store.getFeed(id)!.episodes.length).toBe(20);
// Load in MAX_EPISODES_REFRESH chunks until the cache is exhausted.
let maxLoaded = 0;
// 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);
maxLoaded = Math.max(maxLoaded, store.getFeed(id)!.episodes.length);
iterations++;
}
expect(iterations).toBeLessThan(20);
expect(store.hasMoreEpisodes(id)).toBe(false);
expect(store.getFeed(id)!.episodes.length).toBe(MAX_EPISODES_IN_MEMORY);
expect(maxLoaded).toBeLessThanOrEqual(MAX_EPISODES_IN_MEMORY);
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 70d episode is ~45 days past the 2-week band beyond the oldest
// loaded episode (25d → 39d band): a sparse show must NOT drag it in.
expect(store.hasMoreEpisodes(id)).toBe(false);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"In Window",
]);
});
test("date mode: a dormant show (nothing in the window or next band) never fetch-mores", async () => {
const store = useFeedStore();
const now = Date.now();
// Newest episode 100 days old, next 200 days old — both far outside the
// 60-day cache window and the 14-day band past its edge.
servedEpisodes = [
{ title: "Old A", date: new Date(now - 100 * DAY).toISOString() },
{ title: "Old B", date: new Date(now - 200 * DAY).toISOString() },
];
const feedUrl = `http://127.0.0.1:${server!.port}/dormant.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(0);
expect(store.hasMoreEpisodes(id)).toBe(false);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.length).toBe(0);
});
test("date mode: episodes just outside the window load via the band anchored at the window edge", async () => {
const store = useFeedStore();
const now = Date.now();
// Both episodes are outside the 60-day window (61d / 65d) but inside the
// 14-day band past its edge (60d → 74d) — fetch-more reveals them.
servedEpisodes = [
{ title: "Just Out A", date: new Date(now - 61 * DAY).toISOString() },
{ title: "Just Out B", date: new Date(now - 65 * DAY).toISOString() },
];
const feedUrl = `http://127.0.0.1:${server!.port}/just-out.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(0);
expect(store.hasMoreEpisodes(id)).toBe(true);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"Just Out A",
"Just Out B",
]);
});
// ── count mode ────────────────────────────────────────────────────────────
test("date mode: fetch-more steps by a two-week window, not a count", async () => {
const store = useFeedStore();
const now = Date.now();
// 30 episodes at 3-day spacing span 87 days. The 60-day cache window
// holds the first 21 (subscribe shows 20); fetch-more then reveals the
// next 2-week band per press — 3-day cadence → ~4 episodes per band —
// NOT a fixed 50-episode chunk (which would load all 30 at once).
servedEpisodes = Array.from({ length: 30 }, (_, i) => ({
title: `Ep ${30 - i}`,
date: new Date(now - i * 3 * DAY).toISOString(),
}));
const feedUrl = `http://127.0.0.1:${server!.port}/date-step.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(20);
// Press 1: oldest loaded is 57d old → cutoff 71d → i=20..23 (6069d).
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.length).toBe(24);
expect(store.hasMoreEpisodes(id)).toBe(true);
// Press 2: oldest loaded is 69d old → cutoff 83d → i=24..27 (7281d).
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.length).toBe(28);
expect(store.hasMoreEpisodes(id)).toBe(true);
// Press 3: oldest loaded is 81d old → cutoff 95d → i=28..29 (8487d).
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.length).toBe(30);
expect(store.hasMoreEpisodes(id)).toBe(false);
});
test("count mode: only N most-recent episodes are visible, but fetch-more goes beyond", async () => {
const store = useFeedStore();
const app = useAppStore();
// The app store loads persisted prefs asynchronously at import — wait so
// the override below isn't clobbered by the load.
await app.whenReady();
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" });
});
test("count mode: Feed list is a GLOBAL top-N that grows N per press, never a far-back dump", async () => {
const store = useFeedStore();
const app = useAppStore();
// Wait out the async pref load (see the single-show count test).
await app.whenReady();
app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 });
const now = Date.now();
// Feed A: 200 episodes at 1-day spacing (ages 0199d). Feed B: 200 at
// 1-day spacing shifted 200 days older (ages 200399d) — every A episode
// is newer than every B episode, so the global top-K is deterministic.
const serve = (prefix: string, shiftDays: number) =>
Array.from({ length: 200 }, (_, i) => ({
title: `${prefix} Ep ${200 - i}`,
date: new Date(now - (shiftDays + i) * DAY).toISOString(),
}));
servedEpisodes = serve("A", 0);
const aUrl = `http://127.0.0.1:${server!.port}/global-a.xml`;
const a = await store.addFeed(makePodcast(aUrl), "test-source");
expect(a).not.toBeNull();
const aId = a!.id;
addedFeedIds.push(aId);
servedEpisodes = serve("B", 200);
const bUrl = `http://127.0.0.1:${server!.port}/global-b.xml`;
const b = await store.addFeed(makePodcast(bUrl), "test-source");
expect(b).not.toBeNull();
const bId = b!.id;
addedFeedIds.push(bId);
// The Feed page's global list is capped at the configured count (25),
// NOT 20 per show (the union would be 40).
expect(store.getAllEpisodesChronological().length).toBe(25);
// Press 1: cap grows to 50 AND every feed's window deepens by 25 — the
// list reveals exactly the next 25 most-recent episodes (A's 25 more),
// not 25 from every show.
await store.loadMoreAllFeeds();
expect(store.getAllEpisodesChronological().length).toBe(50);
expect(store.getFeed(aId)!.episodes.length).toBe(45);
expect(store.getFeed(bId)!.episodes.length).toBe(45);
expect(store.hasMoreAcrossAll()).toBe(true);
// Press 2: cap grows to 75.
await store.loadMoreAllFeeds();
expect(store.getAllEpisodesChronological().length).toBe(75);
// Keep pressing until every cache is exhausted. The global cap stays
// (never lifts — rendering the full deep union froze the UI), so the
// Feed list stays at count×(presses+1) = 25×9 = 225 while the per-show
// windows hold everything.
let guard = 0;
while (store.hasMoreAcrossAll() && guard++ < 30) {
await store.loadMoreAllFeeds();
}
expect(guard).toBeLessThan(30);
expect(store.hasMoreAcrossAll()).toBe(false);
expect(store.getFeed(aId)!.episodes.length).toBe(200);
expect(store.getFeed(bId)!.episodes.length).toBe(200);
expect(store.getAllEpisodesChronological().length).toBe(225);
// Reset to date mode for subsequent tests.
app.updatePreferences({ episodeCacheMode: "date" });
});

View File

@@ -31,7 +31,7 @@ process.env.XDG_CONFIG_HOME = CONFIG;
process.env.XDG_DATA_HOME = DATA;
process.env.PODTUI_AUDIO_BACKEND = "none";
// ── Local RSS feed server (episode ids = feedUrl#index) ────────────────────
// ── Local RSS feed server (episode ids derive from enclosure URLs) ─────────
let server: ReturnType<typeof Bun.serve> | null = null;
function feedXml(origin: string): string {
const items = ["Episode One", "Episode Two"]

View File

@@ -0,0 +1,76 @@
/**
* Episode id stability regression tests.
*
* The parser used to key episodes by their position in the feed
* (`feedUrl#index`). Any feed change — a new episode published, an old one
* pruned — shifted every episode's id, so saved progress/downloads attached
* to whichever episode happened to occupy that index afterward ("start a new
* episode and it resumes minutes in"). Ids must instead be stable per
* episode: <guid> when present, else the enclosure URL, else the index.
*/
import { describe, expect, test } from "bun:test"
import { getRSSItems, parseRSSItem } from "../src/api/rss-parser"
const FEED = "https://example.com/feed.xml"
const item = (
title: string,
date: string,
audioUrl: string,
guid?: string,
): string =>
`<item>
<title>${title}</title>
<pubDate>${date}</pubDate>
${guid ? `<guid>${guid}</guid>` : ""}
<enclosure url="${audioUrl}" length="12345" type="audio/mpeg"/>
</item>`
const parse = (xml: string): ReturnType<typeof parseRSSItem>[] =>
getRSSItems(xml).map((it, i) => parseRSSItem(it, FEED, i))
describe("stable episode ids", () => {
test("guid-based ids survive a new episode being prepended", () => {
// Two episodes, newest first. Positional ids would be feedUrl#0 / #1.
const before = parse(
`<rss><channel>${item("Ep 2", "2026-08-02", "https://cdn.example.com/e2.mp3", "ep-2")}${item("Ep 1", "2026-08-01", "https://cdn.example.com/e1.mp3", "ep-1")}</channel></rss>`,
)
// A third, newer episode appears at the top — every index shifts.
const after = parse(
`<rss><channel>${item("Ep 3", "2026-08-03", "https://cdn.example.com/e3.mp3", "ep-3")}${item("Ep 2", "2026-08-02", "https://cdn.example.com/e2.mp3", "ep-2")}${item("Ep 1", "2026-08-01", "https://cdn.example.com/e1.mp3", "ep-1")}</channel></rss>`,
)
// The known episodes keep their ids — only the newcomer differs.
expect(after[1].id).toBe(before[0].id) // Ep 2
expect(after[2].id).toBe(before[1].id) // Ep 1
expect(after[0].id).not.toBe(before[0].id) // Ep 3 is new
})
test("enclosure-URL fallback ids survive reordering (no guid)", () => {
const a = item("A", "2026-08-02", "https://cdn.example.com/a.mp3")
const b = item("B", "2026-08-01", "https://cdn.example.com/b.mp3")
const first = parse(`<rss><channel>${a}${b}</channel></rss>`)
const reordered = parse(`<rss><channel>${b}${a}</channel></rss>`)
// The same episodes at different indexes still carry their own ids.
expect(reordered[0].id).toBe(first[1].id) // B moved to index 0
expect(reordered[1].id).toBe(first[0].id) // A moved to index 1
})
test("ids are namespaced per feed", () => {
const xml = `<rss><channel>${item("Ep", "2026-08-01", "https://cdn.example.com/e.mp3", "same-guid")}</channel></rss>`
const parsed = getRSSItems(xml)
const a = parseRSSItem(parsed[0], "https://a.example/feed.xml", 0)
const b = parseRSSItem(parsed[0], "https://b.example/feed.xml", 0)
expect(a.id).not.toBe(b.id)
})
test("id is deterministic across parses of the same item", () => {
const xml = `<rss><channel>${item("Ep", "2026-08-01", "https://cdn.example.com/e.mp3")}</channel></rss>`
const one = parse(xml)
const two = parse(xml)
expect(one[0].id).toBe(two[0].id)
})
})

View File

@@ -0,0 +1,189 @@
/**
* Show-row height regression — My Shows depth-0 rows must stay exactly one
* line tall: marker + show title + episode count (+ watchlist dot). The
* flexible title carries `wrapMode="none"` + `truncate` (middle-ellipsis:
* head and tail of the title stay visible) and the fixed-width cells carry
* `flexShrink={0}`, so Yoga can never shrink them and wrap the row — a
* wrapped row grows to 2+ lines and the episode count + watchlist dot shift
* below the title while scrolling (the original bug). Same guard for the
* 20%-wide parent-pane shows list at depth ≥1.
*
* Rendered at 70 columns so the 35-col current pane / 14-col parent pane are
* narrow enough to force truncation on the long title; at the default
* 100-col/50-col pane the same rows show everything in full.
*/
import { describe, test, expect, afterAll } from "bun:test";
import type { JSX } from "solid-js";
import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { PaneRow } from "../src/components/PaneRow";
type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
function frameText(spans: Frame): string[] {
return spans.lines.map((l) => l.spans.map((s) => s.text).join(""));
}
const LONG_TITLE =
"Out of Whiskey and Reaching for the Rotgut (Members Only #338)";
// The exact depth-0 row shape MyShowsPage renders: marker + title + count +
// watchlist dot. Static text, no store hooks — pure layout probe.
const ShowRowFixed = () => (
<box flexDirection="row" gap={1} paddingRight={1}>
<text flexShrink={0}></text>
<text wrapMode="none" truncate>
{LONG_TITLE}
</text>
<text flexShrink={0}>(123)</text>
<text flexShrink={0}></text>
</box>
);
// Pre-fix shape: no wrapMode/truncate/flexShrink props — the long title
// wraps at the shrunken width and pushes the count + dot onto wrapped lines.
const ShowRowNaive = () => (
<box flexDirection="row" gap={1} paddingRight={1}>
<text></text>
<text>{LONG_TITLE}</text>
<text>(123)</text>
<text></text>
</box>
);
// The depth-1 parent-pane shows-list row (marker + title + count).
const ParentRowFixed = () => (
<box flexDirection="row" gap={1} paddingRight={1}>
<text flexShrink={0}></text>
<text wrapMode="none" truncate>
{LONG_TITLE}
</text>
<text flexShrink={0}>(123)</text>
</box>
);
const ParentRowNaive = () => (
<box flexDirection="row" gap={1} paddingRight={1}>
<text></text>
<text>{LONG_TITLE}</text>
<text>(123)</text>
</box>
);
async function renderRow(
row: () => JSX.Element,
pane: "current" | "parent",
width = 70,
): Promise<{ lines: string[]; destroy: () => Promise<void> }> {
const setup = await testRender(
() => (
<ThemeProvider mode="dark">
<PaneRow
parent={pane === "parent" ? row : null}
current={pane === "current" ? row : null}
preview={null}
currentLabel="List"
/>
</ThemeProvider>
),
{ width, height: 10, useThread: false },
);
// ThemeProvider mounts children only once the theme resolves; poll for
// the header row so the captured frame is a mounted PaneRow.
let lines: string[] | null = null;
for (let i = 0; i < 40 && !lines; i++) {
await setup.renderOnce();
const frame = setup.captureSpans() as unknown as Frame;
const ls = frameText(frame);
if (ls.some((l) => l.includes("List"))) lines = ls;
else await new Promise((r) => setTimeout(r, 100));
}
if (!lines) throw new Error("PaneRow did not render before timeout");
return {
lines,
destroy: async () => {
setup.renderer.destroy();
},
};
}
const cleanups: (() => void | Promise<void>)[] = [];
afterAll(async () => {
for (const c of cleanups) {
try {
await c();
} catch {
// renderer already torn down — ignore
}
}
});
describe("show row height in the current pane (70-wide → 35-col pane)", () => {
test("long title stays on one line — middle-ellipsis keeps head AND tail, count + dot stay aligned", async () => {
const { lines, destroy } = await renderRow(ShowRowFixed, "current");
cleanups.push(destroy);
// Middle-ellipsis: the title head and its tail both survive, on a
// single line (end-truncation would drop the tail).
expect(lines.filter((l) => l.includes("Out of Wh"))).toHaveLength(1);
expect(lines.filter((l) => l.includes("#338)"))).toHaveLength(1);
// The count and watchlist dot sit on that same line — nothing wrapped.
const aligned = lines.filter(
(l) =>
l.includes("Out of Wh") &&
l.includes("#338)") &&
l.includes("(123)") &&
l.includes("●"),
);
expect(aligned).toHaveLength(1);
// Row occupies exactly 1 content line below the header.
const content = lines.filter(
(l) => l.includes("Out of Wh") || l.includes("(123)"),
);
expect(content).toHaveLength(1);
});
test("naive row (pre-fix props) wraps the title — the regression the test guards", async () => {
const { lines, destroy } = await renderRow(ShowRowNaive, "current");
cleanups.push(destroy);
// The title's wrapped fragments span 3 frame lines instead of 1 —
// every row below shifts while scrolling.
const titleFragments = lines.filter(
(l) =>
l.includes("Out of Whiskey") ||
l.includes("Rotgut") ||
l.includes("#338)"),
);
expect(titleFragments).toHaveLength(3);
});
});
describe("show row in the parent pane (70-wide → 14-col pane)", () => {
test("long title stays on one line with count aligned", async () => {
const { lines, destroy } = await renderRow(ParentRowFixed, "parent");
cleanups.push(destroy);
// The 14-col slot truncates the title to a head stub (too narrow for
// head + tail), but the row stays one line and the count pins to it.
expect(lines.filter((l) => l.includes("O..."))).toHaveLength(1);
expect(
lines.filter((l) => l.includes("O...") && l.includes("(123)")),
).toHaveLength(1);
});
test("naive parent row wraps the title — the regression the test guards", async () => {
const { lines, destroy } = await renderRow(ParentRowNaive, "parent");
cleanups.push(destroy);
const titleFragments = lines.filter(
(l) =>
l.includes("Out of") ||
l.includes("Whiskey") ||
l.includes("Rotgut") ||
l.includes("#3"),
);
expect(titleFragments.length).toBeGreaterThan(1);
});
});