Compare commits
8 Commits
v0.6.2
...
df4701957b
| Author | SHA1 | Date | |
|---|---|---|---|
| df4701957b | |||
| 4b44623891 | |||
| 4ef9ab7e59 | |||
| 9df8eebf6c | |||
| badbc6a037 | |||
| 878d1e01ab | |||
| 42c48e59fb | |||
| 91d4acca90 |
@@ -24,6 +24,7 @@
|
|||||||
"@opentui/core": "^0.1.77",
|
"@opentui/core": "^0.1.77",
|
||||||
"@opentui/solid": "^0.1.77",
|
"@opentui/solid": "^0.1.77",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"effect": "^3",
|
||||||
"solid-js": "^1.9.9"
|
"solid-js": "^1.9.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,43 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
|
|||||||
return 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
|
/** Extract the `<item>` blocks from an RSS document. Matches items directly
|
||||||
* on the full XML string — scoping to <channel> first is a redundant 5MB
|
* 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
|
* regex pass that doubles parse cost with no practical benefit (well-formed
|
||||||
@@ -122,7 +159,7 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis
|
|||||||
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
|
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
|
||||||
|
|
||||||
const ep: Episode = {
|
const ep: Episode = {
|
||||||
id: `${feedUrl}#${index}`,
|
id: stableEpisodeId(feedUrl, item, audioUrl, index),
|
||||||
podcastId: feedUrl,
|
podcastId: feedUrl,
|
||||||
title: epTitle,
|
title: epTitle,
|
||||||
description: epDescription,
|
description: epDescription,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
"sort": [","],
|
"sort": [","],
|
||||||
"toggle-hidden": ["."],
|
"toggle-hidden": ["."],
|
||||||
"refresh": ["r"],
|
"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
|
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
|
||||||
|
|
||||||
// ── Downloads & auto-download whitelist ───────────────────────────────────
|
// ── Downloads & auto-download whitelist ───────────────────────────────────
|
||||||
|
|||||||
85
src/effects/feed-refresh.ts
Normal file
85
src/effects/feed-refresh.ts
Normal 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 },
|
||||||
|
)
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createAudioBackend,
|
createAudioBackend,
|
||||||
detectPlayers,
|
detectPlayers,
|
||||||
|
PlayerRestartedError,
|
||||||
type AudioBackend,
|
type AudioBackend,
|
||||||
type BackendName,
|
type BackendName,
|
||||||
type DetectedPlayer,
|
type DetectedPlayer,
|
||||||
@@ -502,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> {
|
async function resume(): Promise<void> {
|
||||||
if (!backend) return;
|
if (!backend) return;
|
||||||
|
if (!backend.isAlive()) {
|
||||||
|
await recoverPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await backend.resume();
|
await backend.resume();
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
@@ -515,6 +533,13 @@ async function resume(): Promise<void> {
|
|||||||
media.setPlaybackState(true);
|
media.setPlaybackState(true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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");
|
setError(err instanceof Error ? err.message : "Resume failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,28 @@
|
|||||||
* placeholder (1/5 slot kept).
|
* placeholder (1/5 slot kept).
|
||||||
* depth 1 (current) — podcast results for the drilled category. Parent
|
* depth 1 (current) — podcast results for the drilled category. Parent
|
||||||
* pane = the categories list.
|
* pane = the categories list.
|
||||||
* preview — detail of the hovered item (category summary, or
|
* depth 2 (current) — episodes of the drilled show, fetched on demand
|
||||||
* podcast detail + subscribe action).
|
* 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
|
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
||||||
* remains. `l`/Enter drills in (category → results) or subscribes (on a
|
* remains. `l`/Enter drills in (category → results → episodes); `a`
|
||||||
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
|
* subscribes the focused show (enter/l never subscribe — they open the
|
||||||
* column. Moving through categories at depth 0 updates the store's selected
|
* episode list); `h` pops a depth (noop at 0). j/k move only within the
|
||||||
* category so the preview follows.
|
* 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 { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
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 { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +42,7 @@ import type { KeybindActionName } from "@/context/KeybindContext";
|
|||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
import { EpisodeRow, EpisodePreview } from "@/components/EpisodeList";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
@@ -41,11 +52,16 @@ function DiscoverPage() {
|
|||||||
// Static: detection never changes mid-session.
|
// Static: detection never changes mid-session.
|
||||||
const nerd = supportsNerdFonts();
|
const nerd = supportsNerdFonts();
|
||||||
const discoverStore = useDiscoverStore();
|
const discoverStore = useDiscoverStore();
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const audio = useAudio();
|
||||||
|
const audioNav = useAudioNavStore();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const marker = useSelectionMarker();
|
const marker = useSelectionMarker();
|
||||||
|
|
||||||
|
const stack = nav.depthStack;
|
||||||
const depth = nav.currentDepth;
|
const depth = nav.currentDepth;
|
||||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
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);
|
podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
|
||||||
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]);
|
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 = () =>
|
const curLen = () =>
|
||||||
depth() === 0 ? categories().length : podcasts().length;
|
depth() === 0
|
||||||
|
? categories().length
|
||||||
|
: depth() === 1
|
||||||
|
? podcasts().length
|
||||||
|
: episodes().length;
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
if (categories().length > 0 && focus(0) >= categories().length)
|
if (categories().length > 0 && focus(0) >= categories().length)
|
||||||
nav.setDepthFocus(categories().length - 1, 0);
|
nav.setDepthFocus(categories().length - 1, 0);
|
||||||
if (podcasts().length > 0 && focus(1) >= podcasts().length)
|
if (podcasts().length > 0 && focus(1) >= podcasts().length)
|
||||||
nav.setDepthFocus(podcasts().length - 1, 1);
|
nav.setDepthFocus(podcasts().length - 1, 1);
|
||||||
|
if (episodes().length > 0 && focus(2) >= episodes().length)
|
||||||
|
nav.setDepthFocus(episodes().length - 1, 2);
|
||||||
};
|
};
|
||||||
onMount(ensureFocus);
|
onMount(ensureFocus);
|
||||||
|
|
||||||
@@ -80,13 +119,56 @@ function DiscoverPage() {
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||||
if (depth() === 0) return categories()[i]?.id;
|
if (depth() === 0) return categories()[i]?.id;
|
||||||
return podcasts()[i]?.id;
|
if (depth() === 1) return podcasts()[i]?.id;
|
||||||
|
return episodes()[i]?.id;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
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 ───────────────────────────────────────────────────────────
|
// ── drill / open ───────────────────────────────────────────────────────────
|
||||||
function open() {
|
function open() {
|
||||||
if (depth() === 0) {
|
if (depth() === 0) {
|
||||||
@@ -97,9 +179,19 @@ function DiscoverPage() {
|
|||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (depth() >= 1) {
|
if (depth() === 1) {
|
||||||
const pod = focusedPodcast();
|
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()),
|
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||||
open: () => open(),
|
open: () => open(),
|
||||||
"toggle-select": () => {
|
"toggle-select": () => {
|
||||||
if (depth() >= 1) {
|
if (depth() === 1) {
|
||||||
const pod = focusedPodcast();
|
const pod = focusedPodcast();
|
||||||
if (pod) nav.toggleSelected(pod.id);
|
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: () => {
|
refresh: () => {
|
||||||
|
if (depth() >= 2) {
|
||||||
|
const pod = drilledPodcast();
|
||||||
|
if (pod) discoverStore.refreshEpisodes(pod).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
discoverStore.refresh().catch(() => {});
|
discoverStore.refresh().catch(() => {});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -161,7 +300,9 @@ function DiscoverPage() {
|
|||||||
const currentLabel = () =>
|
const currentLabel = () =>
|
||||||
depth() === 0
|
depth() === 0
|
||||||
? "Categories"
|
? "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) ─────────────
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||||
// Sibling <Show> blocks per depth (the known-good opentui disposal
|
// Sibling <Show> blocks per depth (the known-good opentui disposal
|
||||||
@@ -174,7 +315,7 @@ function DiscoverPage() {
|
|||||||
<Show when={depth() === 0}>
|
<Show when={depth() === 0}>
|
||||||
<TabListPane muted />
|
<TabListPane muted />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={depth() >= 1}>
|
<Show when={depth() === 1}>
|
||||||
<For each={categories()}>
|
<For each={categories()}>
|
||||||
{(cat, index) => {
|
{(cat, index) => {
|
||||||
const lf = () => nav.depthFocus(0);
|
const lf = () => nav.depthFocus(0);
|
||||||
@@ -203,6 +344,33 @@ function DiscoverPage() {
|
|||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</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>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
{/* depth ≥1: results */}
|
{/* depth ≥1: results */}
|
||||||
<Show when={depth() >= 1}>
|
<Show when={depth() === 1}>
|
||||||
<Show
|
<Show
|
||||||
when={podcasts().length > 0}
|
when={podcasts().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -304,11 +472,59 @@ function DiscoverPage() {
|
|||||||
</For>
|
</For>
|
||||||
<Show when={discoverStore.isLoading()}>
|
<Show when={discoverStore.isLoading()}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
<LoadingIndicator label="Refreshing…" />
|
<LoadingIndicator />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</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>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
) : (
|
) : depth() === 1 ? (
|
||||||
// depth ≥1 preview: hovered podcast + subscribe
|
// depth 1 preview: hovered podcast + episode-list hint
|
||||||
<Show
|
<Show
|
||||||
when={focusedPodcast()}
|
when={focusedPodcast()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -376,10 +592,10 @@ function DiscoverPage() {
|
|||||||
<text fg={muted()}>by {pod().author}</text>
|
<text fg={muted()}>by {pod().author}</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={pod().isSubscribed}>
|
<Show when={pod().isSubscribed}>
|
||||||
<text fg={theme.success}>✓ Subscribed</text>
|
<text fg={theme.success}>✓ Subscribed · x: unsubscribe</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!pod().isSubscribed}>
|
<Show when={!pod().isSubscribed}>
|
||||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
<text fg={theme.primary}>a: subscribe</text>
|
||||||
</Show>
|
</Show>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={theme.textSecondary}>
|
<text fg={theme.textSecondary}>
|
||||||
@@ -398,10 +614,67 @@ function DiscoverPage() {
|
|||||||
</Show>
|
</Show>
|
||||||
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
|
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
|
||||||
<box height={1} />
|
<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>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</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 (
|
return (
|
||||||
|
|||||||
@@ -107,6 +107,30 @@ function FeedPage() {
|
|||||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||||
const curLen = () => rowCount();
|
const curLen = () => rowCount();
|
||||||
|
|
||||||
|
// ── 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 = () => {
|
const ensureFocus = () => {
|
||||||
if (rowCount() > 0 && focus() >= rowCount())
|
if (rowCount() > 0 && focus() >= rowCount())
|
||||||
nav.setDepthFocus(rowCount() - 1, 0);
|
nav.setDepthFocus(rowCount() - 1, 0);
|
||||||
@@ -245,17 +269,22 @@ function FeedPage() {
|
|||||||
</text>
|
</text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LoadingIndicator label="Refreshing…" />
|
<LoadingIndicator />
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={episodes()}>
|
{/* 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) => (
|
{(item, index) => (
|
||||||
<EpisodeRow
|
<EpisodeRow
|
||||||
episode={item.episode}
|
episode={item.episode}
|
||||||
subtitle={() => item.feed.customName || item.feed.podcast.title}
|
subtitle={() => item.feed.customName || item.feed.podcast.title}
|
||||||
index={index}
|
index={() => listWindow()[0] + index()}
|
||||||
focused={focusedEpIdx}
|
focused={focusedEpIdx}
|
||||||
active={isActive}
|
active={isActive}
|
||||||
selected={() => nav.isSelected(item.episode.id)}
|
selected={() => nav.isSelected(item.episode.id)}
|
||||||
@@ -264,11 +293,14 @@ function FeedPage() {
|
|||||||
marker={marker}
|
marker={marker}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 0);
|
nav.setDepthFocus(listWindow()[0] + index(), 0);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={episodes().length - listWindow()[1] > 0}>
|
||||||
|
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
|
||||||
|
</Show>
|
||||||
<Show when={showFetchMore()}>
|
<Show when={showFetchMore()}>
|
||||||
<FetchMoreRow
|
<FetchMoreRow
|
||||||
index={() => episodes().length}
|
index={() => episodes().length}
|
||||||
@@ -286,7 +318,7 @@ function FeedPage() {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show when={feedStore.isLoadingFeeds()}>
|
<Show when={feedStore.isLoadingFeeds()}>
|
||||||
<box alignItems="center" paddingTop={1}>
|
<box alignItems="center" paddingTop={1}>
|
||||||
<LoadingIndicator label="Refreshing…" />
|
<LoadingIndicator />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -89,13 +89,23 @@ function ShowRow(props: {
|
|||||||
backgroundColor={bg()}
|
backgroundColor={bg()}
|
||||||
onMouseDown={props.onMouseDown}
|
onMouseDown={props.onMouseDown}
|
||||||
>
|
>
|
||||||
<text fg={fg()}>{isFocused() ? props.marker() : " "}</text>
|
<text flexShrink={0} fg={fg()}>
|
||||||
<text fg={fg()}>{props.title}</text>
|
{isFocused() ? props.marker() : " "}
|
||||||
<text fg={isFocused() ? theme.surface : muted()}>
|
</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})
|
({props.feed.episodes.length})
|
||||||
</text>
|
</text>
|
||||||
<Show when={props.wlScope()}>
|
<Show when={props.wlScope()}>
|
||||||
<text
|
<text
|
||||||
|
flexShrink={0}
|
||||||
fg={
|
fg={
|
||||||
isFocused()
|
isFocused()
|
||||||
? theme.surface
|
? theme.surface
|
||||||
@@ -312,6 +322,30 @@ export function MyShowsPage() {
|
|||||||
const focusedEpisode = () =>
|
const focusedEpisode = () =>
|
||||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||||
|
|
||||||
|
// ── 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());
|
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
@@ -537,9 +571,17 @@ export function MyShowsPage() {
|
|||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focused() ? theme.border : undefined}
|
backgroundColor={focused() ? theme.border : undefined}
|
||||||
>
|
>
|
||||||
<text fg={fg()}>{focused() ? marker() : " "}</text>
|
<text flexShrink={0} fg={fg()}>
|
||||||
<text fg={fg()}>{showTitle(feed)}</text>
|
{focused() ? marker() : " "}
|
||||||
<text fg={muted()}>({feed.episodes.length})</text>
|
</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>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -622,11 +664,17 @@ export function MyShowsPage() {
|
|||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={episodes()}>
|
{/* 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) => (
|
{(ep, index) => (
|
||||||
<EpisodeRow
|
<EpisodeRow
|
||||||
episode={ep}
|
episode={ep}
|
||||||
index={index}
|
index={() => listWindow()[0] + index()}
|
||||||
focused={focusedEpIdx}
|
focused={focusedEpIdx}
|
||||||
active={isActive}
|
active={isActive}
|
||||||
selected={() => nav.isSelected(ep.id)}
|
selected={() => nav.isSelected(ep.id)}
|
||||||
@@ -635,11 +683,14 @@ export function MyShowsPage() {
|
|||||||
marker={marker}
|
marker={marker}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 1);
|
nav.setDepthFocus(listWindow()[0] + index(), 1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={episodes().length - listWindow()[1] > 0}>
|
||||||
|
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
|
||||||
|
</Show>
|
||||||
<Show when={showFetchMore()}>
|
<Show when={showFetchMore()}>
|
||||||
<FetchMoreRow
|
<FetchMoreRow
|
||||||
index={() => episodes().length}
|
index={() => episodes().length}
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export function usePreferencesItems(): SettingItem[] {
|
|||||||
kind: "select",
|
kind: "select",
|
||||||
display: () => cacheModeLabel(prefs().episodeCacheMode),
|
display: () => cacheModeLabel(prefs().episodeCacheMode),
|
||||||
help: () =>
|
help: () =>
|
||||||
`How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below).\nCount: keep the N most recent episodes (see Cache Count below).\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`,
|
`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) => {
|
cycle: (dir) => {
|
||||||
const idx = CACHE_MODE_LABELS.findIndex(
|
const idx = CACHE_MODE_LABELS.findIndex(
|
||||||
(s) => s.value === prefs().episodeCacheMode,
|
(s) => s.value === prefs().episodeCacheMode,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import type { Podcast } from "../types/podcast";
|
import type { Podcast } from "../types/podcast";
|
||||||
|
import type { Episode } from "../types/episode";
|
||||||
import { useFeedStore } from "./feed";
|
import { useFeedStore } from "./feed";
|
||||||
|
|
||||||
export interface DiscoverCategory {
|
export interface DiscoverCategory {
|
||||||
@@ -42,6 +43,10 @@ const FEATURED_JSON_URL =
|
|||||||
/** Cache window for the remote featured list (24 hours) */
|
/** Cache window for the remote featured list (24 hours) */
|
||||||
const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
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 */
|
/** Shape of a single entry in the remote JSON */
|
||||||
interface FeaturedEntry {
|
interface FeaturedEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -90,6 +95,19 @@ export function createDiscoverStore() {
|
|||||||
const [isLoading, setIsLoading] = createSignal(false);
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
|
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)
|
// In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
|
||||||
let cachedAt = 0;
|
let cachedAt = 0;
|
||||||
|
|
||||||
@@ -174,13 +192,64 @@ export function createDiscoverStore() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSubscription = (podcastId: string) => {
|
// ── episode preview (drill-in, no subscription) ──────────────────────────
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
/** Cached episode list for a previewed show (empty until first drill-in). */
|
||||||
if (podcast?.isSubscribed) {
|
const episodesForPodcast = (podcastId: string): Episode[] =>
|
||||||
unsubscribe(podcastId);
|
previewEpisodes()[podcastId] ?? [];
|
||||||
} else {
|
|
||||||
subscribe(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 {
|
return {
|
||||||
@@ -195,8 +264,14 @@ export function createDiscoverStore() {
|
|||||||
setSelectedCategory,
|
setSelectedCategory,
|
||||||
subscribe,
|
subscribe,
|
||||||
unsubscribe,
|
unsubscribe,
|
||||||
toggleSubscription,
|
|
||||||
refresh,
|
refresh,
|
||||||
|
|
||||||
|
// Episode preview (drill-in, no subscription)
|
||||||
|
episodesForPodcast,
|
||||||
|
isLoadingEpisodesFor,
|
||||||
|
previewError,
|
||||||
|
openEpisodes,
|
||||||
|
refreshEpisodes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
|
import { Effect } from "effect";
|
||||||
|
import { refreshFeedsBatch } from "../effects/feed-refresh";
|
||||||
import { FeedVisibility } from "../types/feed";
|
import { FeedVisibility } from "../types/feed";
|
||||||
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
|
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
|
||||||
import type { Podcast } from "../types/podcast";
|
import type { Podcast } from "../types/podcast";
|
||||||
@@ -13,8 +15,12 @@ import { DEFAULT_SOURCES } from "../types/source";
|
|||||||
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
|
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
|
||||||
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
|
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
|
||||||
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
||||||
import { mergeEpisodesBounded } from "../utils/episode-merge";
|
|
||||||
import {
|
import {
|
||||||
|
episodeSignature,
|
||||||
|
mergeEpisodesBounded,
|
||||||
|
} from "../utils/episode-merge";
|
||||||
|
import {
|
||||||
|
DEFAULT_EPISODE_WINDOW_DAYS,
|
||||||
episodeInWindow,
|
episodeInWindow,
|
||||||
loadFeedsFromFile,
|
loadFeedsFromFile,
|
||||||
saveFeedsToFile,
|
saveFeedsToFile,
|
||||||
@@ -26,12 +32,17 @@ import { useDownloadStore } from "./download";
|
|||||||
import { useAppStore } from "./app";
|
import { useAppStore } from "./app";
|
||||||
import { DownloadStatus } from "../types/episode";
|
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;
|
const MAX_EPISODES_REFRESH = 50;
|
||||||
|
|
||||||
/** Max episodes to fetch on initial subscribe */
|
/** Max episodes to fetch on initial subscribe */
|
||||||
const MAX_EPISODES_SUBSCRIBE = 20;
|
const MAX_EPISODES_SUBSCRIBE = 20;
|
||||||
|
|
||||||
|
/** 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
|
/** Per-feed fetch timeout — a hung feed must not stall a refresh batch or
|
||||||
* the background refresh loop. */
|
* the background refresh loop. */
|
||||||
const FETCH_TIMEOUT_MS = 20_000;
|
const FETCH_TIMEOUT_MS = 20_000;
|
||||||
@@ -127,6 +138,38 @@ function episodeKeepFn(prefs: {
|
|||||||
return (ep: Episode) => episodeInWindow(ep, now, days);
|
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 60–74d 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). */
|
/** Save feeds to file (async, fire-and-forget). */
|
||||||
function saveFeeds(feeds: Feed[]): void {
|
function saveFeeds(feeds: Feed[]): void {
|
||||||
const prefs = useAppStore().state().preferences;
|
const prefs = useAppStore().state().preferences;
|
||||||
@@ -191,37 +234,21 @@ async function migratePlaintextCredentials(
|
|||||||
* union semantics the merged list legitimately contains episodes BEYOND the
|
* union semantics the merged list legitimately contains episodes BEYOND the
|
||||||
* fetched window, so unchanged-detection must compare the fetched window
|
* fetched window, so unchanged-detection must compare the fetched window
|
||||||
* against the existing list's prefix — comparing full lists would bump
|
* against the existing list's prefix — comparing full lists would bump
|
||||||
* `lastUpdated` on every refresh. */
|
* `lastUpdated` on every refresh. When ids drifted between refreshes (the
|
||||||
function sameRefreshWindow(existing: Episode[], fetched: Episode[]): boolean {
|
* 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;
|
if (fetched.length === 0) return true;
|
||||||
const prefix = existing.slice(0, fetched.length);
|
const prefix = existing.slice(0, fetched.length);
|
||||||
const ids = new Set(prefix.map((e) => e.id));
|
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));
|
||||||
/** Run `fn` over every item with at most `limit` executions in flight — a
|
return fetched.every((e) => signatures.has(episodeSignature(e)));
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFeedStore() {
|
function createFeedStore() {
|
||||||
@@ -237,6 +264,11 @@ function createFeedStore() {
|
|||||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
||||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||||
const [isLoadingFeeds, setIsLoadingFeeds] = 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 ───────────────────────────────────────────────
|
// ── Debounced persistence ───────────────────────────────────────────────
|
||||||
/** Trailing-edge debounce window for config.json writes. */
|
/** Trailing-edge debounce window for config.json writes. */
|
||||||
@@ -345,6 +377,20 @@ function createFeedStore() {
|
|||||||
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
|
(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;
|
return allEpisodes;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -575,40 +621,40 @@ function createFeedStore() {
|
|||||||
})(), "Refreshing");
|
})(), "Refreshing");
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Refresh all feeds — bounded concurrency (at most FETCH_CONCURRENCY
|
/** Refresh all feeds via the Effect batch program (effects/feed-refresh):
|
||||||
* in-flight requests), and each feed's refreshed episodes are applied
|
* bounded concurrency (at most FETCH_CONCURRENCY in-flight requests)
|
||||||
* AS ITS OWN FETCH LANDS (no Promise.all barrier). Per-feed apply is
|
* and each feed's refreshed episodes applied AS ITS OWN FETCH LANDS
|
||||||
* safe because applyRefreshedEpisodes keeps unchanged feeds' object
|
* (no barrier — the apply runs inside the feed's own fiber). Per-feed
|
||||||
* identity and lastUpdated (union merge), so each feed's refreshed
|
* apply is safe because applyRefreshedEpisodes keeps unchanged feeds'
|
||||||
* episodes render as its own fetch resolves — the order flapping the
|
* object identity and lastUpdated (union merge), so each feed's
|
||||||
* old atomic barrier existed to hide can no longer happen. */
|
* 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 () => {
|
const refreshAllFeeds = async () => {
|
||||||
setIsLoadingFeeds(true);
|
setIsLoadingFeeds(true);
|
||||||
try {
|
try {
|
||||||
await mapWithConcurrency(
|
await Effect.runPromise(
|
||||||
feeds(),
|
refreshFeedsBatch(
|
||||||
FETCH_CONCURRENCY,
|
feeds(),
|
||||||
async (feed) => {
|
(feed) =>
|
||||||
const { episodes, coverUrl } = await fetchEpisodes(
|
fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feed.id),
|
||||||
feed.podcast.feedUrl,
|
(feed, { episodes, coverUrl }) => {
|
||||||
MAX_EPISODES_REFRESH,
|
setFeeds((prev) => {
|
||||||
feed.id,
|
let updated = applyRefreshedEpisodes(prev, feed.id, episodes);
|
||||||
);
|
if (coverUrl) {
|
||||||
// A failed fetch (null) leaves that feed untouched.
|
updated = updated.map((f) =>
|
||||||
if (!episodes) return;
|
f.id === feed.id && !f.podcast.coverUrl && coverUrl
|
||||||
setFeeds((prev) => {
|
? { ...f, podcast: { ...f.podcast, coverUrl } }
|
||||||
let updated = applyRefreshedEpisodes(prev, feed.id, episodes);
|
: f,
|
||||||
if (coverUrl) {
|
);
|
||||||
updated = updated.map((f) =>
|
}
|
||||||
f.id === feed.id && !f.podcast.coverUrl && coverUrl
|
if (updated !== prev) scheduleSaveFeeds();
|
||||||
? { ...f, podcast: { ...f.podcast, coverUrl } }
|
return updated;
|
||||||
: f,
|
});
|
||||||
);
|
},
|
||||||
}
|
{ concurrency: FETCH_CONCURRENCY, timeoutMs: FETCH_TIMEOUT_MS },
|
||||||
if (updated !== prev) scheduleSaveFeeds();
|
),
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
// Global auto-download: one idempotent pass after the batch.
|
// Global auto-download: one idempotent pass after the batch.
|
||||||
runAutoDownload();
|
runAutoDownload();
|
||||||
@@ -802,15 +848,25 @@ function createFeedStore() {
|
|||||||
|
|
||||||
/** Check if a feed has more episodes available beyond what's currently
|
/** Check if a feed has more episodes available beyond what's currently
|
||||||
* loaded. The full parse cache holds ALL episodes (including beyond the
|
* loaded. The full parse cache holds ALL episodes (including beyond the
|
||||||
* cache bound), so fetch-more can always page deeper — the bound limits
|
* cache bound), so fetch-more can page deeper — but in DATE mode only
|
||||||
* what the Feed/My Shows list shows initially, not what fetch-more can
|
* when the next unloaded episode falls inside the next 2-week band: a
|
||||||
* reach. When the loaded window reaches the cache length, this flips
|
* sparse/dormant show whose band is empty reports false, so fetch-more
|
||||||
* false. */
|
* 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 hasMoreEpisodes = (feedId: string): boolean => {
|
||||||
const cached = fullEpisodeCache.get(feedId);
|
const cached = fullEpisodeCache.get(feedId);
|
||||||
if (!cached) return false;
|
if (!cached) return false;
|
||||||
const loaded = episodeLoadCount.get(feedId) ?? 0;
|
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 full parse
|
/** Load the next chunk of episodes for one feed from the full parse
|
||||||
@@ -864,10 +920,33 @@ function createFeedStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
||||||
const newCount = Math.min(
|
const prefs = useAppStore().state().preferences;
|
||||||
currentCount + MAX_EPISODES_REFRESH,
|
|
||||||
cached.length,
|
// 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
|
if (newCount <= currentCount) return; // nothing more to load
|
||||||
|
|
||||||
@@ -909,15 +988,78 @@ function createFeedStore() {
|
|||||||
return feeds().some((f) => hasMoreEpisodes(f.id));
|
return feeds().some((f) => hasMoreEpisodes(f.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Advance the loaded window by MAX_EPISODES_REFRESH for every feed that
|
/** Power the Feed page's "[Fetch More]".
|
||||||
* still has cached episodes — powers 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 () => {
|
const loadMoreAllFeeds = async () => {
|
||||||
if (isLoadingMore()) return;
|
if (isLoadingMore()) return;
|
||||||
setIsLoadingMore(true);
|
setIsLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
const pending = feeds().filter((f) => hasMoreEpisodes(f.id));
|
const prefs = useAppStore().state().preferences;
|
||||||
for (const feed of pending) {
|
const count = Math.max(1, prefs.episodeCacheCount ?? 25);
|
||||||
await loadMoreEpisodesForFeed(feed.id);
|
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 {
|
} finally {
|
||||||
setIsLoadingMore(false);
|
setIsLoadingMore(false);
|
||||||
@@ -952,6 +1094,11 @@ function createFeedStore() {
|
|||||||
// Actions
|
// Actions
|
||||||
setFilter,
|
setFilter,
|
||||||
setSelectedFeedId,
|
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,
|
addFeed,
|
||||||
hasFeedByUrl,
|
hasFeedByUrl,
|
||||||
removeFeed,
|
removeFeed,
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
* fills its demuxer cache ahead of time; the first real play just flips
|
* 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
|
* `pause` to false — the ~2s network open is paid at boot, not on the
|
||||||
* user's first Play.
|
* 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";
|
import { platform } from "os";
|
||||||
@@ -274,12 +278,28 @@ class MpvConnection {
|
|||||||
}
|
}
|
||||||
this.handleTeardown();
|
this.handleTeardown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True while the Unix socket is open — a live, reachable daemon. */
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.sock !== null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── mpv Backend ──────────────────────────────────────────────────────
|
// ── mpv Backend ──────────────────────────────────────────────────────
|
||||||
// One resident daemon for the app's lifetime, controlled over a single
|
// One resident daemon for the app's lifetime, controlled over a single
|
||||||
// persistent JSON IPC connection with property observation.
|
// 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). */
|
/** Property observation ids (correlate property-change events). */
|
||||||
const OBS_TIME_POS = 1;
|
const OBS_TIME_POS = 1;
|
||||||
const OBS_PAUSE = 2;
|
const OBS_PAUSE = 2;
|
||||||
@@ -318,14 +338,35 @@ export class MpvBackend implements AudioBackend {
|
|||||||
// ── Daemon lifecycle ─────────────────────────────────────────────
|
// ── Daemon lifecycle ─────────────────────────────────────────────
|
||||||
|
|
||||||
private async ensureDaemon(): Promise<void> {
|
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;
|
if (this.startPromise) return this.startPromise;
|
||||||
this.startPromise = this.spawnDaemon().finally(() => {
|
this.startPromise = this.recoverDaemon().finally(() => {
|
||||||
this.startPromise = null;
|
this.startPromise = null;
|
||||||
});
|
});
|
||||||
return this.startPromise;
|
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> {
|
private async spawnDaemon(): Promise<void> {
|
||||||
// Clean up stale socket
|
// Clean up stale socket
|
||||||
try {
|
try {
|
||||||
@@ -360,9 +401,15 @@ export class MpvBackend implements AudioBackend {
|
|||||||
this._exited = false;
|
this._exited = false;
|
||||||
this.proc.exited
|
this.proc.exited
|
||||||
.then(() => {
|
.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._exited = true;
|
||||||
this._intentPlaying = false;
|
this._intentPlaying = false;
|
||||||
this._loadedUrl = null;
|
this._loadedUrl = null;
|
||||||
|
this._loadedPaused = false;
|
||||||
|
this._ended = false;
|
||||||
this._paused = null;
|
this._paused = null;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -590,6 +637,13 @@ export class MpvBackend implements AudioBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async resume(): Promise<void> {
|
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) {
|
if (this._ended && this._loadedUrl) {
|
||||||
// Play pressed on a finished episode: replay from the top.
|
// Play pressed on a finished episode: replay from the top.
|
||||||
this._ended = false;
|
this._ended = false;
|
||||||
@@ -609,7 +663,12 @@ export class MpvBackend implements AudioBackend {
|
|||||||
this._loadedPaused = false;
|
this._loadedPaused = false;
|
||||||
}
|
}
|
||||||
this._ended = 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;
|
this._intentPlaying = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,35 @@ const ts = (ep: Episode): number => {
|
|||||||
return t === undefined || Number.isNaN(t) ? Infinity : t
|
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
|
* Union of two episode lists keyed by id — on collision the fetched copy
|
||||||
* wins (fresh metadata). Result is sorted newest-first by pubDate and pruned
|
* wins (fresh metadata). An existing episode whose id differs from every
|
||||||
* by the supplied `keep` predicate: episodes outside the configured cache
|
* fetched id but whose content signature matches a fetched episode is a
|
||||||
* bound (date window or count) are dropped. Never mutates either input.
|
* 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
|
* 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.
|
* types — the feed store passes a closure bound to the user's mode/count/days.
|
||||||
@@ -23,8 +47,17 @@ export function mergeEpisodesBounded(
|
|||||||
keep: (ep: Episode, index: number) => boolean,
|
keep: (ep: Episode, index: number) => boolean,
|
||||||
): Episode[] {
|
): Episode[] {
|
||||||
const byId = new Map<string, Episode>()
|
const byId = new Map<string, Episode>()
|
||||||
for (const ep of existing) byId.set(ep.id, ep)
|
const bySignature = new Map<string, Episode>()
|
||||||
for (const ep of fetched) byId.set(ep.id, ep)
|
for (const ep of fetched) {
|
||||||
const sorted = [...byId.values()].sort((a, b) => ts(b) - ts(a))
|
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))
|
return sorted.filter((ep, i) => keep(ep, i))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ deliverables:
|
|||||||
- 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.
|
- 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() }`.
|
- 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`).
|
- Delete the now-unused `sameEpisodes` if nothing else references it (grep first: `grep sameEpisodes src tests`).
|
||||||
- `loadMoreEpisodesForFeed`: window-filter the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before filtering); everything else (window growth by `MAX_EPISODES_REFRESH`, `hasMoreEpisodes` comparing `episodeLoadCount < cached.length`) works unchanged against the filtered cache.
|
- `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.
|
- `tests/feed-volatile-merge.test.ts` (reworked) — see tests section.
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -57,7 +57,9 @@ tests:
|
|||||||
- input arrays not mutated.
|
- 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):
|
- 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).
|
- Refresh-keeps-volatile-window: serve 3 episodes at t0, `addFeed`; then serve the same 3 plus 2 new ones, `refreshFeed`. Assert `feed.episodes.length === 5` AND `lastUpdated` advanced AND a second identical refresh leaves `lastUpdated` untouched (window-compare, not union-compare).
|
||||||
- Boundary: a 25-day-old episode loads; a 31-day-old episode is neither visible nor cached.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
* it by unpausing — the boot-restore fast path with no second load.
|
* it by unpausing — the boot-restore fast path with no second load.
|
||||||
* 6. EOF: the episode ends → isPlaying() goes false on its own; pressing
|
* 6. EOF: the episode ends → isPlaying() goes false on its own; pressing
|
||||||
* resume() afterwards replays from the top.
|
* 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;
|
* All playback runs silent (volume 0). Requires a real mpv on PATH;
|
||||||
* tests skip where it is missing.
|
* tests skip where it is missing.
|
||||||
@@ -19,7 +23,10 @@
|
|||||||
import { test, expect } from "bun:test";
|
import { test, expect } from "bun:test";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { MpvBackend } from "../src/utils/audio-player";
|
import {
|
||||||
|
MpvBackend,
|
||||||
|
PlayerRestartedError,
|
||||||
|
} from "../src/utils/audio-player";
|
||||||
|
|
||||||
const SAMPLE_RATE = 22050;
|
const SAMPLE_RATE = 22050;
|
||||||
const FREQ = 440;
|
const FREQ = 440;
|
||||||
@@ -165,6 +172,46 @@ test.skipIf(!hasMpv)(
|
|||||||
{ timeout: 20000 },
|
{ 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)(
|
test.skipIf(!hasMpv)(
|
||||||
"EOF marks playback ended; resume() then replays from the top",
|
"EOF marks playback ended; resume() then replays from the top",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
337
tests/discover-episode-preview.test.tsx
Normal file
337
tests/discover-episode-preview.test.tsx
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
184
tests/discover-store-preview.test.ts
Normal file
184
tests/discover-store-preview.test.ts
Normal 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();
|
||||||
|
});
|
||||||
@@ -16,6 +16,11 @@
|
|||||||
* property the same way the OS does and asserts useAudio reconciles in
|
* property the same way the OS does and asserts useAudio reconciles in
|
||||||
* both directions. Skipped when mpv isn't installed.
|
* 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
|
* Real-timer note: the reconcile path runs on useAudio's real 150ms poll
|
||||||
* interval against a real mpv process, with no injectable clock — the
|
* interval against a real mpv process, with no injectable clock — the
|
||||||
* deliberate-exception case from the no-real-timers rule (same as
|
* 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 = {
|
const episode = {
|
||||||
id: "ep1",
|
id: "ep1",
|
||||||
podcastId: "pod1",
|
podcastId: "pod1",
|
||||||
@@ -235,6 +261,42 @@ test.skipIf(!hasMpv)(
|
|||||||
{ timeout: 30000 },
|
{ 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 ──────────────────────────────────────────────────────────────
|
// ── Teardown ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const addedFeedIds: string[] = [];
|
|||||||
/** Feed created by the debounce test, reused by the flushPendingSave test. */
|
/** Feed created by the debounce test, reused by the flushPendingSave test. */
|
||||||
let debounceFeedId = "";
|
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 {
|
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||||
const items = episodes
|
const items = episodes
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
* row in a drilled show's episode list (My Shows depth 1) and the Feed
|
* row in a drilled show's episode list (My Shows depth 1) and the Feed
|
||||||
* page's row.
|
* page's row.
|
||||||
*
|
*
|
||||||
* addFeed caches every episode inside the lifecycle window (the last
|
* Runs in COUNT cache mode: `loadMoreEpisodes` advances the loaded window in
|
||||||
* EPISODE_WINDOW_DAYS days — the date bound, not a count) while exposing
|
* fixed MAX_EPISODES_REFRESH (50) chunks until the cache is exhausted.
|
||||||
* only the first MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes`
|
* (Date-mode fetch-more steps by a two-week window instead — that contract
|
||||||
* reports when the cache holds more than the loaded window;
|
* is pinned in feed-volatile-merge.test.ts.) This pins:
|
||||||
* `loadMoreEpisodes` advances that window in MAX_EPISODES_REFRESH (50)
|
|
||||||
* chunks until it is exhausted. This pins:
|
|
||||||
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
|
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
|
||||||
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
|
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
|
||||||
* needed) and hasMoreEpisodes flips false once the window reaches the end.
|
* needed) and hasMoreEpisodes flips false once the window reaches the end.
|
||||||
@@ -26,6 +24,7 @@ const configHome = mkdtempSync(join(tmpdir(), "podtui-pagination-"));
|
|||||||
process.env.XDG_CONFIG_HOME = configHome;
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
|
||||||
import { useFeedStore } from "../src/stores/feed";
|
import { useFeedStore } from "../src/stores/feed";
|
||||||
|
import { useAppStore } from "../src/stores/app";
|
||||||
import type { Podcast } from "../src/types/podcast";
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
|
||||||
const HOUR = 3600 * 1000;
|
const HOUR = 3600 * 1000;
|
||||||
@@ -43,7 +42,7 @@ let servedEpisodes: ServedEpisode[] = [];
|
|||||||
// store (execution order between files is not guaranteed).
|
// store (execution order between files is not guaranteed).
|
||||||
const addedFeedIds: string[] = [];
|
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 {
|
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||||
const items = episodes
|
const items = episodes
|
||||||
.map(
|
.map(
|
||||||
@@ -72,7 +71,15 @@ const makePodcast = (feedUrl: string): Podcast => ({
|
|||||||
isSubscribed: true,
|
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({
|
server = Bun.serve({
|
||||||
port: 0,
|
port: 0,
|
||||||
fetch(req) {
|
fetch(req) {
|
||||||
@@ -89,6 +96,7 @@ beforeAll(() => {
|
|||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
// Leave the shared singleton as we found it (see addedFeedIds note).
|
// Leave the shared singleton as we found it (see addedFeedIds note).
|
||||||
|
useAppStore().updatePreferences({ episodeCacheMode: "date" });
|
||||||
const store = useFeedStore();
|
const store = useFeedStore();
|
||||||
for (const id of addedFeedIds) store.removeFeed(id);
|
for (const id of addedFeedIds) store.removeFeed(id);
|
||||||
server?.stop(true);
|
server?.stop(true);
|
||||||
|
|||||||
230
tests/feed-refresh-effect.test.ts
Normal file
230
tests/feed-refresh-effect.test.ts
Normal 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"])
|
||||||
|
})
|
||||||
@@ -42,7 +42,7 @@ let feedAId = "";
|
|||||||
/** When set, the server 503s this path — simulates a feed going down. */
|
/** When set, the server 503s this path — simulates a feed going down. */
|
||||||
let failPath: string | null = null;
|
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 {
|
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||||
const items = episodes
|
const items = episodes
|
||||||
.map(
|
.map(
|
||||||
@@ -79,6 +79,18 @@ beforeAll(() => {
|
|||||||
if (failPath && url.pathname === failPath) {
|
if (failPath && url.pathname === failPath) {
|
||||||
return new Response("feed unavailable", { status: 503 });
|
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")) {
|
if (url.pathname.endsWith(".xml")) {
|
||||||
return new Response(feedXml(servedEpisodes, url.origin), {
|
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||||
headers: { "Content-Type": "application/rss+xml" },
|
headers: { "Content-Type": "application/rss+xml" },
|
||||||
@@ -139,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 () => {
|
test("a failed refresh does not wipe the feed's episodes", async () => {
|
||||||
const store = useFeedStore();
|
const store = useFeedStore();
|
||||||
const savedEpisodes = servedEpisodes;
|
// /flaky.xml serves its own fixed single-episode feed (see server route)
|
||||||
servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }];
|
// 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 feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`;
|
||||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
expect(feed).not.toBeNull();
|
expect(feed).not.toBeNull();
|
||||||
@@ -161,11 +174,6 @@ test("a failed refresh does not wipe the feed's episodes", async () => {
|
|||||||
|
|
||||||
failPath = null;
|
failPath = null;
|
||||||
store.removeFeed(feedId);
|
store.removeFeed(feedId);
|
||||||
// Restore the shared served content: with union merge semantics (volatile
|
|
||||||
// episodes survive refreshes) this feed keeps its larger in-memory window,
|
|
||||||
// so later tests must serve the same episodes they added — a shrink here
|
|
||||||
// would make the next test's "unchanged" refresh genuinely different.
|
|
||||||
servedEpisodes = savedEpisodes;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
|
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { join } from "path";
|
|||||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-"));
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-"));
|
||||||
process.env.XDG_CONFIG_HOME = configHome;
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
|
||||||
import { useFeedStore } from "../src/stores/feed";
|
import { sameRefreshWindow, useFeedStore } from "../src/stores/feed";
|
||||||
import { mergeEpisodesBounded } from "../src/utils/episode-merge";
|
import { mergeEpisodesBounded } from "../src/utils/episode-merge";
|
||||||
import { episodeInWindow } from "../src/utils/feeds-persistence";
|
import { episodeInWindow } from "../src/utils/feeds-persistence";
|
||||||
import { useAppStore } from "../src/stores/app";
|
import { useAppStore } from "../src/stores/app";
|
||||||
@@ -132,6 +132,67 @@ test("mergeEpisodesBounded dedupes on id collision and keeps the fetched copy",
|
|||||||
expect(merged.find((e) => e.id === "a")!.title).toBe("New Title");
|
expect(merged.find((e) => e.id === "a")!.title).toBe("New Title");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
test("mergeEpisodesBounded unions disjoint lists sorted newest-first", () => {
|
||||||
const existing = [
|
const existing = [
|
||||||
makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")),
|
makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")),
|
||||||
@@ -284,21 +345,103 @@ test("date mode boundary: 25 days in, 70 days out", async () => {
|
|||||||
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
||||||
"In Window",
|
"In Window",
|
||||||
]);
|
]);
|
||||||
// The full cache holds both, but the visible list only shows the in-window
|
// The 70d episode is ~45 days past the 2-week band beyond the oldest
|
||||||
// one — fetch-more surfaces the out-of-window one (volatile).
|
// loaded episode (25d → 39d band): a sparse show must NOT drag it in.
|
||||||
expect(store.hasMoreEpisodes(id)).toBe(true);
|
expect(store.hasMoreEpisodes(id)).toBe(false);
|
||||||
await store.loadMoreEpisodes(id);
|
await store.loadMoreEpisodes(id);
|
||||||
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
||||||
"In Window",
|
"In Window",
|
||||||
"Out 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 ────────────────────────────────────────────────────────────
|
// ── 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 (60–69d).
|
||||||
|
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 (72–81d).
|
||||||
|
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 (84–87d).
|
||||||
|
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 () => {
|
test("count mode: only N most-recent episodes are visible, but fetch-more goes beyond", async () => {
|
||||||
const store = useFeedStore();
|
const store = useFeedStore();
|
||||||
const app = useAppStore();
|
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 });
|
app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 });
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -329,3 +472,67 @@ test("count mode: only N most-recent episodes are visible, but fetch-more goes b
|
|||||||
// Reset to date mode for subsequent tests.
|
// Reset to date mode for subsequent tests.
|
||||||
app.updatePreferences({ episodeCacheMode: "date" });
|
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 0–199d). Feed B: 200 at
|
||||||
|
// 1-day spacing shifted 200 days older (ages 200–399d) — 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" });
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ process.env.XDG_CONFIG_HOME = CONFIG;
|
|||||||
process.env.XDG_DATA_HOME = DATA;
|
process.env.XDG_DATA_HOME = DATA;
|
||||||
process.env.PODTUI_AUDIO_BACKEND = "none";
|
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;
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||||
function feedXml(origin: string): string {
|
function feedXml(origin: string): string {
|
||||||
const items = ["Episode One", "Episode Two"]
|
const items = ["Episode One", "Episode Two"]
|
||||||
|
|||||||
76
tests/rss-episode-ids.test.ts
Normal file
76
tests/rss-episode-ids.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
189
tests/show-row-wrap.test.tsx
Normal file
189
tests/show-row-wrap.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user