docs: add human-oriented CONTRIBUTING.md (repo map, FFI notes, gotchas, release & tap auto-sync workflow)

This commit is contained in:
2026-08-07 18:09:51 -04:00
parent 592cfd4093
commit 1d3abd53d4
5 changed files with 844 additions and 597 deletions

200
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,200 @@
# Contributing to PodTui
This file is written **for humans**. If you're an AI agent or LLM working in
this repo, read [AGENTS.md](AGENTS.md) instead — it has the machine-oriented
build/test/lint contract and code-style rules. Both describe the same project;
CONTRIBUTING.md focuses on *understanding* and *navigating* the codebase.
PodTui is a keyboard-first, yazi-style terminal podcast client. TypeScript +
[OpenTUI](https://github.com/opentui/opentui) on top, [Bun](https://bun.sh)
as the runtime and toolchain.
---
## Quick start
```bash
brew install bun # or: curl -fsSL https://bun.sh/install | bash
git clone git@github.com:mikefreno/podtui.git
cd podtui
bun install # install JS dependencies
make native # build libcavacore.dylib from the vendored C source
bun run dev # launch with hot reload (alias: make dev)
```
The app is a TUI — it expects a real terminal (kitty, iTerm2, WezTerm, tmux,
…). It will not render in a plain captured `bash` session.
## What each command does
| Command | Purpose |
|--------------------|--------------------------------------------------------------------------|
| `bun install` | Install JS dependencies |
| `make native` | Compile `cava/cavacore.c``src/native/libcavacore.<dylib\|so>` |
| `bun run dev` | Run with hot reload |
| `bun run start` | Run once (no watch) |
| `bun test` | Run the test suite (see [Testing](#testing)) |
| `make lint` | Type-check with `bun tsc --noEmit` |
| `bun run build` | Bundle JS into `dist/` + copy native libs (the `podtui` npm script path) |
| `make dist` | Compile the standalone binary + make the current platform's tarball |
| `make clean` | Remove `dist/` |
> Note: `package.json` also has a `lint` script that points at a
> `lint.ts` file that doesn't exist. Use `make lint` (real type-checking).
## Repository layout
```
src/
api/ Network + XML/RSS — client.ts, rss-parser.ts
components/ Reusable UI pieces: Shell, Navigation, YaziPaneRow, TabPanel…
config/ App config: keybinds.jsonc, shortcuts, auth
constants/ Static tables (sync formats, themes)
context/ Solid contexts: KeybindContext, NavigationContext, ThemeContext
hooks/ useAudio, useMultimediaKeys, useCachedData
native/ FFI glue + the built libcavacore.{dylib,so}
pages/ App screens: Feed, MyShows, Discover, Search, Player, Settings
stores/ Zustand stores — app, feed, audio-nav, search, auth, progress…
styles/ theme.css
themes/ catppuccin, gruvbox, nord, tokyo schemes + schema.json
types/ All shared interfaces (podcast, episode, feed, settings…)
ui/ Modal-adjacent UI: command.tsx, dialog.tsx, toast.tsx
utils/ Parser/persistence/audio helpers (audio-player, config-dir…)
scripts/
build-cavacore.sh C → shared lib; finds libfftw3.a on macOS & Debian
tui-harness.tsx Headless harness for scripted interaction (see below)
cava/ Vendored cavacore C source (MIT, from karlstav/cava)
tests/ bun test suite + cavacore smoke test
dist/ Build output (JS bundle + libs + tarballs)
```
## Native libraries: how the FFI layer works
PodTui loads **two** native libraries at runtime:
1. **libopentui** — the OpenTUI renderer (shipped inside the
`@opentui/core-<platform>-<arch>` npm packages, copied to `dist/` by
`build.ts`).
2. **libcavacore** — the audio spectrum renderer, built from C. The source is
vendored under `cava/` (it must stay committed — every CI runner builds it).
`libfftw3` is needed to build it:
- macOS: `brew install fftw`
- Debian/Ubuntu: `apt-get install libfftw3-dev`
(CI installs it for you; locally run `make native`.)
**Critical sibling rule**: both libraries are loaded *relative to the binary*,
so `podtui`, `libopentui.*` and `libcavacore.*` must sit in the **same
directory**. Never move a single binary out of the tarball. The Homebrew
formula keeps all three in `libexec/` and exposes only a `podtui` symlink.
Cavacore smoke test: `bun tests/cavacore-smoke.ts`
(FFI-calls `cava_init` / `cava_execute` / `cava_destroy` and prints results).
## Gotchas (read before touching anything)
1. **Never add a top-level `preload` to `bunfig.toml`.**
A compiled PodTui binary's embedded runtime reads the *launching process's*
CWD `bunfig.toml`, and a `preload` entry points at a module the standalone
can't resolve (`@opentui/solid/preload`) → the binary dies at startup with
`preload not found`. This is why `bunfig.toml` has **no** top-level
`preload`; dev-mode preloading happens via explicit `--preload` flags in
`package.json`. The `[test]` section *does* keep a preload — that only
affects `bun test`.
2. **Smoke-test the compiled binary from a bunfig-free dir.**
Because of (1), `./dist/podtui --version` run from the repo root launched
inside CI would fail. CI always unpacks the tarball into a `mktemp` dir
before booting. Do the same when testing a release build locally.
3. **Homebrew's dylib-repair warning is benign.**
`brew install` may print “load commands do not fit in the header … needs
`-headerpad`” for a prebuilt dylib. The app dlopens the libs by path, so
the warning is cosmetic; installs complete and the app boots.
4. **`make lint` is the truth, not the `package.json` scripts.**
The repo's ESLint wiring is stale; `make lint` runs the real
type-check and is what CI treats as the clean bar.
## Testing
```bash
bun test # full suite (54 tests across 6 files today)
```
The suite covers the keyboard/nav model, keybind dispatch, and the yazi pane
logic; plus `tests/cavacore-smoke.ts` asserting the native lib exports.
For scripted end-to-end interaction there's a **headless harness**,
`scripts/tui-harness.tsx`: each invocation snapshot-rebuilds the app state
into a sandboxed `.harness/` config dir, replays the saved action log
(`.harness/actions.json`), executes one more key/action passed on the CLI, and
prints the resulting frame + a style summary — all without a real terminal.
Audio is a no-op during those snapshots. The last frame lands in
`.harness/last-frame.{json,txt}` for inspection.
## Releasing
Releases are built and published from **tags**; CI does the heavy lifting.
### Steps
1. Bump `VERSION` in `src/index.tsx` (e.g. `0.1.0``0.2.0`). Commit and push.
2. Tag and push:
```bash
git tag -a v0.2.0 -m 'PodTUI v0.2.0' && git push gh v0.2.0
```
3. CI (`.github/workflows/release.yml`) runs four builds in parallel,
each producing `podtui-<platform>-<arch>.tar.gz`:
| Runner | Platform/Arch |
|---------------------|---------------|
| `ubuntu-latest` | linux-x64 |
| `ubuntu-24.04-arm` | linux-arm64 |
| `macos-15-intel` | darwin-x64 |
| `macos-14` | darwin-arm64 |
Each runner: installs deps → installs fftw → `scripts/build-cavacore.sh`
→ `make dist` → smoke-boots the binary from a temp dir → uploads the
tarball. (`macos-15-intel` matters: GitHub's `macos-latest` is arm64 now.)
4. A release is auto-created with all 4 tarballs attached. `brew` never
sees the new version: the **tap self-updates**: the
`mikefreno/homebrew-podtui` repo has a scheduled workflow (hourly) that
polls GitHub releases, and when a new tag appears, rewrites
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local
test: `brew install mikefreno/podtui/podtui`.
### Manual fallback
If you ever need to sync the tap by hand (or before the hourly job runs):
```bash
cd <clone of mikefreno/homebrew-podtui>
./scripts/sync-formula.sh 0.2.0
git commit -am 'podtui 0.2.0' && git push
```
### Local release build
```bash
make dist # builds the binary + tarball for THIS machine only
```
Bun cannot cross-compile — the other platforms come from CI.
---
## Open items / things to sort out
- **LICENSE**: `README.md` says "TBD — choose and document a license before
the first release". Pick one (MIT/BSD-3) and add `LICENSE` + update the
README footer.
- **Native libs in `dist/` still need committing?** No — they're built from
sources kept in the repo (`cava/`, `node_modules/@opentui/core-*`). Only
`src/native/libcavacore.dylib` is a committed binary artifact; macOS arm64
ships from it directly until a full rebuild replaces it. On other hosts the
`make native` build is required — see `scripts/build-cavacore.sh`.

View File

@@ -1,7 +0,0 @@
- [x] Audio play can survive quit out
- [x] Discover tab does not move highlight on jk, only moves a star, My Feeds tab
moves nothing, other tabs(and main tab panel) are the correct pattern
- [x] Weird focus colors happen at times, the search panel does not get the correct pane
border color when focused for instance
- [x] Feed tab needs to fully drop the depth 1 panel - its effectively a duplication
of My Shows - Just immediately go into the full list

View File

@@ -25,6 +25,7 @@ import {
onCleanup, onCleanup,
} from "solid-js"; } from "solid-js";
import { useSearchStore } from "@/stores/search"; import { useSearchStore } from "@/stores/search";
import { useFeedStore } from "@/stores/feed";
import { format } from "date-fns"; import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { import {
@@ -44,6 +45,7 @@ export const SearchPaneCount = 1;
function SearchPage() { function SearchPage() {
const searchStore = useSearchStore(); const searchStore = useSearchStore();
const feedStore = useFeedStore();
const [inputValue, setInputValue] = createSignal(""); const [inputValue, setInputValue] = createSignal("");
const { theme } = useTheme(); const { theme } = useTheme();
const muted = () => theme.muted || theme.text; const muted = () => theme.muted || theme.text;
@@ -127,6 +129,8 @@ function SearchPage() {
}; };
const handleSubscribe = (result: SearchResult) => { const handleSubscribe = (result: SearchResult) => {
// Actually add the feed to the feed store, then mark the result subscribed
feedStore.addFeed(result.podcast, result.sourceId).catch(() => {});
searchStore.markSubscribed(result.podcast.id); searchStore.markSubscribed(result.podcast.id);
}; };

View File

@@ -3,213 +3,233 @@
* Manages trending/popular podcasts and category filtering * Manages trending/popular podcasts and category filtering
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import type { Podcast } from "../types/podcast" import type { Podcast } from "../types/podcast";
import { useFeedStore } from "./feed";
export interface DiscoverCategory { export interface DiscoverCategory {
id: string id: string;
name: string name: string;
icon: string icon: string;
} }
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [ export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
{ id: "all", name: "All", icon: "*" }, { id: "all", name: "All", icon: "*" },
{ id: "technology", name: "Technology", icon: ">" }, { id: "technology", name: "Technology", icon: ">" },
{ id: "science", name: "Science", icon: "~" }, { id: "science", name: "Science", icon: "~" },
{ id: "comedy", name: "Comedy", icon: ")" }, { id: "comedy", name: "Comedy", icon: ")" },
{ id: "news", name: "News", icon: "!" }, { id: "news", name: "News", icon: "!" },
{ id: "business", name: "Business", icon: "$" }, { id: "business", name: "Business", icon: "$" },
{ id: "health", name: "Health", icon: "+" }, { id: "health", name: "Health", icon: "+" },
{ id: "education", name: "Education", icon: "?" }, { id: "education", name: "Education", icon: "?" },
{ id: "sports", name: "Sports", icon: "#" }, { id: "sports", name: "Sports", icon: "#" },
{ id: "true-crime", name: "True Crime", icon: "%" }, { id: "true-crime", name: "True Crime", icon: "%" },
{ id: "arts", name: "Arts", icon: "@" }, { id: "arts", name: "Arts", icon: "@" },
] ];
/** Mock trending podcasts */ /** Mock trending podcasts */
const TRENDING_PODCASTS: Podcast[] = [ const TRENDING_PODCASTS: Podcast[] = [
{ {
id: "trend-1", id: "trend-1",
title: "AI Today", title: "AI Today",
description: "The latest developments in artificial intelligence, machine learning, and their impact on society.", description:
feedUrl: "https://example.com/aitoday.rss", "The latest developments in artificial intelligence, machine learning, and their impact on society.",
author: "Tech Futures", feedUrl: "https://example.com/aitoday.rss",
categories: ["Technology", "Science"], author: "Tech Futures",
coverUrl: undefined, categories: ["Technology", "Science"],
lastUpdated: new Date(), coverUrl: undefined,
isSubscribed: false, lastUpdated: new Date(),
}, isSubscribed: false,
{ },
id: "trend-2", {
title: "The History Hour", id: "trend-2",
description: "Fascinating stories from history that shaped our world today.", title: "The History Hour",
feedUrl: "https://example.com/historyhour.rss", description:
author: "History Channel", "Fascinating stories from history that shaped our world today.",
categories: ["Education", "History"], feedUrl: "https://example.com/historyhour.rss",
lastUpdated: new Date(), author: "History Channel",
isSubscribed: false, categories: ["Education", "History"],
}, lastUpdated: new Date(),
{ isSubscribed: false,
id: "trend-3", },
title: "Comedy Gold", {
description: "Weekly stand-up comedy, sketches, and hilarious conversations.", id: "trend-3",
feedUrl: "https://example.com/comedygold.rss", title: "Comedy Gold",
author: "Laugh Factory", description:
categories: ["Comedy", "Entertainment"], "Weekly stand-up comedy, sketches, and hilarious conversations.",
lastUpdated: new Date(), feedUrl: "https://example.com/comedygold.rss",
isSubscribed: false, author: "Laugh Factory",
}, categories: ["Comedy", "Entertainment"],
{ lastUpdated: new Date(),
id: "trend-4", isSubscribed: false,
title: "Market Watch", },
description: "Daily financial news, stock analysis, and investing tips.", {
feedUrl: "https://example.com/marketwatch.rss", id: "trend-4",
author: "Finance Daily", title: "Market Watch",
categories: ["Business", "News"], description: "Daily financial news, stock analysis, and investing tips.",
lastUpdated: new Date(), feedUrl: "https://example.com/marketwatch.rss",
isSubscribed: true, author: "Finance Daily",
}, categories: ["Business", "News"],
{ lastUpdated: new Date(),
id: "trend-5", isSubscribed: true,
title: "Science Weekly", },
description: "Breaking science news and in-depth analysis of the latest research.", {
feedUrl: "https://example.com/scienceweekly.rss", id: "trend-5",
author: "Science Network", title: "Science Weekly",
categories: ["Science", "Education"], description:
lastUpdated: new Date(), "Breaking science news and in-depth analysis of the latest research.",
isSubscribed: false, feedUrl: "https://example.com/scienceweekly.rss",
}, author: "Science Network",
{ categories: ["Science", "Education"],
id: "trend-6", lastUpdated: new Date(),
title: "True Crime Files", isSubscribed: false,
description: "Investigative journalism into real criminal cases and unsolved mysteries.", },
feedUrl: "https://example.com/truecrime.rss", {
author: "Crime Network", id: "trend-6",
categories: ["True Crime", "Documentary"], title: "True Crime Files",
lastUpdated: new Date(), description:
isSubscribed: false, "Investigative journalism into real criminal cases and unsolved mysteries.",
}, feedUrl: "https://example.com/truecrime.rss",
{ author: "Crime Network",
id: "trend-7", categories: ["True Crime", "Documentary"],
title: "Wellness Journey", lastUpdated: new Date(),
description: "Tips for mental and physical health, meditation, and mindful living.", isSubscribed: false,
feedUrl: "https://example.com/wellness.rss", },
author: "Health Media", {
categories: ["Health", "Self-Help"], id: "trend-7",
lastUpdated: new Date(), title: "Wellness Journey",
isSubscribed: false, description:
}, "Tips for mental and physical health, meditation, and mindful living.",
{ feedUrl: "https://example.com/wellness.rss",
id: "trend-8", author: "Health Media",
title: "Sports Talk Live", categories: ["Health", "Self-Help"],
description: "Live commentary, analysis, and interviews from the world of sports.", lastUpdated: new Date(),
feedUrl: "https://example.com/sportstalk.rss", isSubscribed: false,
author: "Sports Network", },
categories: ["Sports", "News"], {
lastUpdated: new Date(), id: "trend-8",
isSubscribed: false, title: "Sports Talk Live",
}, description:
{ "Live commentary, analysis, and interviews from the world of sports.",
id: "trend-9", feedUrl: "https://example.com/sportstalk.rss",
title: "Creative Minds", author: "Sports Network",
description: "Interviews with artists, designers, and creative professionals.", categories: ["Sports", "News"],
feedUrl: "https://example.com/creativeminds.rss", lastUpdated: new Date(),
author: "Arts Weekly", isSubscribed: false,
categories: ["Arts", "Culture"], },
lastUpdated: new Date(), {
isSubscribed: false, id: "trend-9",
}, title: "Creative Minds",
{ description:
id: "trend-10", "Interviews with artists, designers, and creative professionals.",
title: "Dev Talk", feedUrl: "https://example.com/creativeminds.rss",
description: "Software development, programming tutorials, and tech career advice.", author: "Arts Weekly",
feedUrl: "https://example.com/devtalk.rss", categories: ["Arts", "Culture"],
author: "Code Academy", lastUpdated: new Date(),
categories: ["Technology", "Education"], isSubscribed: false,
lastUpdated: new Date(), },
isSubscribed: true, {
}, id: "trend-10",
] title: "Dev Talk",
description:
"Software development, programming tutorials, and tech career advice.",
feedUrl: "https://example.com/devtalk.rss",
author: "Code Academy",
categories: ["Technology", "Education"],
lastUpdated: new Date(),
isSubscribed: true,
},
];
/** Create discover store */ /** Create discover store */
export function createDiscoverStore() { export function createDiscoverStore() {
const [selectedCategory, setSelectedCategory] = createSignal<string>("all") const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
const [isLoading, setIsLoading] = createSignal(false) const [isLoading, setIsLoading] = createSignal(false);
const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS) const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS);
/** Get filtered podcasts by category */ /** Get filtered podcasts by category */
const filteredPodcasts = () => { const filteredPodcasts = () => {
const category = selectedCategory() const category = selectedCategory();
if (category === "all") { if (category === "all") {
return podcasts() return podcasts();
} }
return podcasts().filter((p) => { return podcasts().filter((p) => {
const cats = p.categories?.map((c) => c.toLowerCase()) ?? [] const cats = p.categories?.map((c) => c.toLowerCase()) ?? [];
return cats.some((c) => c.includes(category.toLowerCase().replace("-", " "))) return cats.some((c) =>
}) c.includes(category.toLowerCase().replace("-", " ")),
} );
});
};
/** Subscribe to a podcast */ /** Subscribe to a podcast */
const subscribe = (podcastId: string) => { const subscribe = (podcastId: string) => {
setPodcasts((prev) => const podcast = podcasts().find((p) => p.id === podcastId);
prev.map((p) => if (podcast) {
p.id === podcastId ? { ...p, isSubscribed: true } : p // Actually add the feed to the feed store
) const feedStore = useFeedStore();
) feedStore.addFeed(podcast, "discover").catch(() => {});
} }
setPodcasts((prev) =>
prev.map((p) => (p.id === podcastId ? { ...p, isSubscribed: true } : p)),
);
};
/** Unsubscribe from a podcast */ /** Unsubscribe from a podcast */
const unsubscribe = (podcastId: string) => { const unsubscribe = (podcastId: string) => {
setPodcasts((prev) => const podcast = podcasts().find((p) => p.id === podcastId);
prev.map((p) => if (podcast) {
p.id === podcastId ? { ...p, isSubscribed: false } : p // Remove the feed from the feed store
) const feedStore = useFeedStore();
) feedStore.removeFeedByUrl(podcast.feedUrl);
} }
setPodcasts((prev) =>
prev.map((p) => (p.id === podcastId ? { ...p, isSubscribed: false } : p)),
);
};
/** Toggle subscription */ /** Toggle subscription */
const toggleSubscription = (podcastId: string) => { const toggleSubscription = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId) const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast?.isSubscribed) { if (podcast?.isSubscribed) {
unsubscribe(podcastId) unsubscribe(podcastId);
} else { } else {
subscribe(podcastId) subscribe(podcastId);
} }
} };
/** Refresh trending podcasts (mock) */ /** Refresh trending podcasts (mock) */
const refresh = async () => { const refresh = async () => {
setIsLoading(true) setIsLoading(true);
// Simulate network delay // Simulate network delay
await new Promise((r) => setTimeout(r, 500)) await new Promise((r) => setTimeout(r, 500));
// In real app, would fetch from API // In real app, would fetch from API
setIsLoading(false) setIsLoading(false);
} };
return { return {
// State // State
selectedCategory, selectedCategory,
isLoading, isLoading,
podcasts, podcasts,
filteredPodcasts, filteredPodcasts,
categories: DISCOVER_CATEGORIES, categories: DISCOVER_CATEGORIES,
// Actions // Actions
setSelectedCategory, setSelectedCategory,
subscribe, subscribe,
unsubscribe, unsubscribe,
toggleSubscription, toggleSubscription,
refresh, refresh,
} };
} }
/** Singleton discover store */ /** Singleton discover store */
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
export function useDiscoverStore() { export function useDiscoverStore() {
if (!discoverStoreInstance) { if (!discoverStoreInstance) {
discoverStoreInstance = createDiscoverStore() discoverStoreInstance = createDiscoverStore();
} }
return discoverStoreInstance return discoverStoreInstance;
} }

View File

@@ -7,15 +7,15 @@ import { createSignal } from "solid-js";
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";
import type { Episode, EpisodeStatus } from "../types/episode"; import type { Episode } from "../types/episode";
import type { PodcastSource, SourceType } from "../types/source"; import type { PodcastSource } from "../types/source";
import { DEFAULT_SOURCES } from "../types/source"; import { DEFAULT_SOURCES } from "../types/source";
import { parseRSSFeed } from "../api/rss-parser"; import { parseRSSFeed } from "../api/rss-parser";
import { import {
loadFeedsFromFile, loadFeedsFromFile,
saveFeedsToFile, saveFeedsToFile,
loadSourcesFromFile, loadSourcesFromFile,
saveSourcesToFile, saveSourcesToFile,
} from "../utils/feeds-persistence"; } from "../utils/feeds-persistence";
import { useDownloadStore } from "./download"; import { useDownloadStore } from "./download";
import { DownloadStatus } from "../types/episode"; import { DownloadStatus } from "../types/episode";
@@ -35,461 +35,491 @@ const episodeLoadCount = new Map<string, number>();
/** 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 {
saveFeedsToFile(feeds).catch(() => {}); saveFeedsToFile(feeds).catch(() => {});
} }
/** Save sources to file (async, fire-and-forget) */ /** Save sources to file (async, fire-and-forget) */
function saveSources(sources: PodcastSource[]): void { function saveSources(sources: PodcastSource[]): void {
saveSourcesToFile(sources).catch(() => {}); saveSourcesToFile(sources).catch(() => {});
} }
/** Create feed store */ /** Create feed store */
export function createFeedStore() { export function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]); const [feeds, setFeeds] = createSignal<Feed[]>([]);
const [sources, setSources] = createSignal<PodcastSource[]>([ const [sources, setSources] = createSignal<PodcastSource[]>([
...DEFAULT_SOURCES, ...DEFAULT_SOURCES,
]); ]);
const [filter, setFilter] = createSignal<FeedFilter>({ const [filter, setFilter] = createSignal<FeedFilter>({
visibility: "all", visibility: "all",
sortBy: "updated" as FeedSortField, sortBy: "updated" as FeedSortField,
sortDirection: "desc", sortDirection: "desc",
}); });
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);
/** Get filtered and sorted feeds */ /** Get filtered and sorted feeds */
const getFilteredFeeds = (): Feed[] => { const getFilteredFeeds = (): Feed[] => {
let result = [...feeds()]; let result = [...feeds()];
const f = filter(); const f = filter();
const authStore = useAuthStore(); const authStore = useAuthStore();
// Filter by visibility // Filter by visibility
if (f.visibility && f.visibility !== "all") { if (f.visibility && f.visibility !== "all") {
result = result.filter((feed) => feed.visibility === f.visibility); result = result.filter((feed) => feed.visibility === f.visibility);
} else if (f.visibility === "all") { } else if (f.visibility === "all") {
// Only show private feeds if authenticated // Only show private feeds if authenticated
result = result.filter((feed) => feed.visibility === FeedVisibility.PUBLIC || authStore.isAuthenticated); result = result.filter(
} (feed) =>
feed.visibility === FeedVisibility.PUBLIC ||
authStore.isAuthenticated,
);
}
// Filter by source // Filter by source
if (f.sourceId) { if (f.sourceId) {
result = result.filter((feed) => feed.sourceId === f.sourceId); result = result.filter((feed) => feed.sourceId === f.sourceId);
} }
// Filter by pinned // Filter by pinned
if (f.pinnedOnly) { if (f.pinnedOnly) {
result = result.filter((feed) => feed.isPinned); result = result.filter((feed) => feed.isPinned);
} }
// Filter by search query // Filter by search query
if (f.searchQuery) { if (f.searchQuery) {
const query = f.searchQuery.toLowerCase(); const query = f.searchQuery.toLowerCase();
result = result.filter( result = result.filter(
(feed) => (feed) =>
feed.podcast.title.toLowerCase().includes(query) || feed.podcast.title.toLowerCase().includes(query) ||
feed.customName?.toLowerCase().includes(query) || feed.customName?.toLowerCase().includes(query) ||
feed.podcast.description?.toLowerCase().includes(query), feed.podcast.description?.toLowerCase().includes(query),
); );
} }
// Sort by selected field // Sort by selected field
const sortDir = f.sortDirection === "asc" ? 1 : -1; const sortDir = f.sortDirection === "asc" ? 1 : -1;
result.sort((a, b) => { result.sort((a, b) => {
switch (f.sortBy) { switch (f.sortBy) {
case "title": case "title":
return ( return (
sortDir * sortDir *
(a.customName || a.podcast.title).localeCompare( (a.customName || a.podcast.title).localeCompare(
b.customName || b.podcast.title, b.customName || b.podcast.title,
) )
); );
case "episodeCount": case "episodeCount":
return sortDir * (a.episodes.length - b.episodes.length); return sortDir * (a.episodes.length - b.episodes.length);
case "latestEpisode": case "latestEpisode":
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0; const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0; const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
return sortDir * (aLatest - bLatest); return sortDir * (aLatest - bLatest);
case "updated": case "updated":
default: default:
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime()); return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
} }
}); });
// Pinned feeds always first // Pinned feeds always first
result.sort((a, b) => { result.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1; if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1; if (!a.isPinned && b.isPinned) return 1;
return 0; return 0;
}); });
return result; return result;
}; };
/** Get episodes in reverse chronological order across all feeds */ /** Get episodes in reverse chronological order across all feeds */
const getAllEpisodesChronological = (): Array<{ const getAllEpisodesChronological = (): Array<{
episode: Episode; episode: Episode;
feed: Feed; feed: Feed;
}> => { }> => {
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = []; const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [];
for (const feed of feeds()) { for (const feed of feeds()) {
for (const episode of feed.episodes) { for (const episode of feed.episodes) {
allEpisodes.push({ episode, feed }); allEpisodes.push({ episode, feed });
} }
} }
// Sort by publication date (newest first) // Sort by publication date (newest first)
allEpisodes.sort( allEpisodes.sort(
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(), (a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
); );
return allEpisodes; return allEpisodes;
}; };
/** Sort episodes in reverse chronological order (newest first) */ /** Sort episodes in reverse chronological order (newest first) */
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => { const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
return [...episodes].sort( return [...episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(), (a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
); );
}; };
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */ /** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
const fetchEpisodes = async ( const fetchEpisodes = async (
feedUrl: string, feedUrl: string,
limit: number, limit: number,
feedId?: string, feedId?: string,
): Promise<Episode[]> => { ): Promise<Episode[]> => {
try { try {
const response = await fetch(feedUrl, { const response = await fetch(feedUrl, {
headers: { headers: {
"Accept-Encoding": "identity", "Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*", Accept: "application/rss+xml, application/xml, text/xml, */*",
}, },
}); });
if (!response.ok) return []; if (!response.ok) return [];
const xml = await response.text(); const xml = await response.text();
const parsed = parseRSSFeed(xml, feedUrl); const parsed = parseRSSFeed(xml, feedUrl);
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes); const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
// Cache all parsed episodes for pagination // Cache all parsed episodes for pagination
if (feedId) { if (feedId) {
fullEpisodeCache.set(feedId, allEpisodes); fullEpisodeCache.set(feedId, allEpisodes);
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length)); episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
} }
return allEpisodes.slice(0, limit); return allEpisodes.slice(0, limit);
} catch { } catch {
return []; return [];
} }
}; };
/** Add a new feed and auto-fetch latest 20 episodes */ /** Check if a feed with this URL already exists */
const addFeed = async ( const hasFeedByUrl = (feedUrl: string): boolean => {
podcast: Podcast, return feeds().some((f) => f.podcast.feedUrl === feedUrl);
sourceId: string, };
visibility: FeedVisibility = FeedVisibility.PUBLIC,
) => {
const feedId = crypto.randomUUID();
const episodes = await fetchEpisodes(
podcast.feedUrl,
MAX_EPISODES_SUBSCRIBE,
feedId,
);
const newFeed: Feed = {
id: feedId,
podcast,
episodes,
visibility,
sourceId,
lastUpdated: new Date(),
isPinned: false,
};
setFeeds((prev) => {
const updated = [...prev, newFeed];
saveFeeds(updated);
return updated;
});
return newFeed;
};
/** Auto-download newest episodes for a feed */ /** Add a new feed and auto-fetch latest 20 episodes */
const autoDownloadEpisodes = ( const addFeed = async (
feedId: string, podcast: Podcast,
newEpisodes: Episode[], sourceId: string,
count: number, visibility: FeedVisibility = FeedVisibility.PUBLIC,
) => { ): Promise<Feed | null> => {
try { // Guard: don't add a feed we already have (matched by feedUrl)
const dlStore = useDownloadStore(); if (hasFeedByUrl(podcast.feedUrl)) {
// Sort by pubDate descending (newest first) return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
const sorted = [...newEpisodes].sort( }
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
// count = 0 means download all new episodes
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
for (const ep of toDownload) {
const status = dlStore.getDownloadStatus(ep.id);
if (
status === DownloadStatus.NONE ||
status === DownloadStatus.FAILED
) {
dlStore.startDownload(ep, feedId);
}
}
} catch {
// Download store may not be available yet
}
};
/** Refresh a single feed - re-fetch latest 50 episodes */ const feedId = crypto.randomUUID();
const refreshFeed = async (feedId: string) => { const episodes = await fetchEpisodes(
const feed = getFeed(feedId); podcast.feedUrl,
if (!feed) return; MAX_EPISODES_SUBSCRIBE,
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id)); feedId,
const episodes = await fetchEpisodes( );
feed.podcast.feedUrl, const newFeed: Feed = {
MAX_EPISODES_REFRESH, id: feedId,
feedId, podcast,
); episodes,
setFeeds((prev) => { visibility,
const updated = prev.map((f) => sourceId,
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f, lastUpdated: new Date(),
); isPinned: false,
saveFeeds(updated); };
return updated; setFeeds((prev) => {
}); const updated = [...prev, newFeed];
saveFeeds(updated);
return updated;
});
return newFeed;
};
// Auto-download new episodes if enabled for this feed /** Auto-download newest episodes for a feed */
if (feed.autoDownload) { const autoDownloadEpisodes = (
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id)); feedId: string,
if (newEpisodes.length > 0) { newEpisodes: Episode[],
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0); count: number,
} ) => {
} try {
}; const dlStore = useDownloadStore();
// Sort by pubDate descending (newest first)
const sorted = [...newEpisodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
// count = 0 means download all new episodes
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
for (const ep of toDownload) {
const status = dlStore.getDownloadStatus(ep.id);
if (
status === DownloadStatus.NONE ||
status === DownloadStatus.FAILED
) {
dlStore.startDownload(ep, feedId);
}
}
} catch {
// Download store may not be available yet
}
};
/** Refresh all feeds */ /** Refresh a single feed - re-fetch latest 50 episodes */
const refreshAllFeeds = async () => { const refreshFeed = async (feedId: string) => {
setIsLoadingFeeds(true); const feed = getFeed(feedId);
try { if (!feed) return;
const currentFeeds = feeds(); const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
for (const feed of currentFeeds) { const episodes = await fetchEpisodes(
await refreshFeed(feed.id); feed.podcast.feedUrl,
} MAX_EPISODES_REFRESH,
} finally { feedId,
setIsLoadingFeeds(false); );
} setFeeds((prev) => {
}; const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
);
saveFeeds(updated);
return updated;
});
(async () => { // Auto-download new episodes if enabled for this feed
const loadedFeeds = await loadFeedsFromFile(); if (feed.autoDownload) {
if (loadedFeeds.length > 0) setFeeds(loadedFeeds); const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
const loadedSources = await loadSourcesFromFile<PodcastSource>(); if (newEpisodes.length > 0) {
if (loadedSources && loadedSources.length > 0) setSources(loadedSources); autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
await refreshAllFeeds(); }
})(); }
};
/** Remove a feed */ /** Refresh all feeds */
const removeFeed = (feedId: string) => { const refreshAllFeeds = async () => {
fullEpisodeCache.delete(feedId); setIsLoadingFeeds(true);
episodeLoadCount.delete(feedId); try {
setFeeds((prev) => { const currentFeeds = feeds();
const updated = prev.filter((f) => f.id !== feedId); for (const feed of currentFeeds) {
saveFeeds(updated); await refreshFeed(feed.id);
return updated; }
}); } finally {
}; setIsLoadingFeeds(false);
}
};
/** Update a feed */ (async () => {
const updateFeed = (feedId: string, updates: Partial<Feed>) => { const loadedFeeds = await loadFeedsFromFile();
setFeeds((prev) => { if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
const updated = prev.map((f) => const loadedSources = await loadSourcesFromFile<PodcastSource>();
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f, if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
); await refreshAllFeeds();
saveFeeds(updated); })();
return updated;
});
};
/** Toggle feed pinned status */ /** Remove a feed */
const togglePinned = (feedId: string) => { const removeFeed = (feedId: string) => {
setFeeds((prev) => { fullEpisodeCache.delete(feedId);
const updated = prev.map((f) => episodeLoadCount.delete(feedId);
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f, setFeeds((prev) => {
); const updated = prev.filter((f) => f.id !== feedId);
saveFeeds(updated); saveFeeds(updated);
return updated; return updated;
}); });
}; };
/** Add a source */ /** Remove a feed by its RSS URL (for sources that match by URL, not ID) */
const addSource = (source: Omit<PodcastSource, "id">) => { const removeFeedByUrl = (feedUrl: string) => {
const newSource: PodcastSource = { const feed = feeds().find((f) => f.podcast.feedUrl === feedUrl);
...source, if (feed) {
id: crypto.randomUUID(), fullEpisodeCache.delete(feed.id);
}; episodeLoadCount.delete(feed.id);
setSources((prev) => { setFeeds((prev) => {
const updated = [...prev, newSource]; const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl);
saveSources(updated); saveFeeds(updated);
return updated; return updated;
}); });
return newSource; }
}; };
/** Update a source */ /** Update a feed */
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => { const updateFeed = (feedId: string, updates: Partial<Feed>) => {
setSources((prev) => { setFeeds((prev) => {
const updated = prev.map((source) => const updated = prev.map((f) =>
source.id === sourceId ? { ...source, ...updates } : source, f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
); );
saveSources(updated); saveFeeds(updated);
return updated; return updated;
}); });
}; };
/** Remove a source */ /** Toggle feed pinned status */
const removeSource = (sourceId: string) => { const togglePinned = (feedId: string) => {
// Don't remove default sources setFeeds((prev) => {
if (sourceId === "itunes" || sourceId === "rss") return false; const updated = prev.map((f) =>
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
);
saveFeeds(updated);
return updated;
});
};
setSources((prev) => { /** Add a source */
const updated = prev.filter((s) => s.id !== sourceId); const addSource = (source: Omit<PodcastSource, "id">) => {
saveSources(updated); const newSource: PodcastSource = {
return updated; ...source,
}); id: crypto.randomUUID(),
return true; };
}; setSources((prev) => {
const updated = [...prev, newSource];
saveSources(updated);
return updated;
});
return newSource;
};
/** Toggle source enabled status */ /** Update a source */
const toggleSource = (sourceId: string) => { const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
setSources((prev) => { setSources((prev) => {
const updated = prev.map((s) => const updated = prev.map((source) =>
s.id === sourceId ? { ...s, enabled: !s.enabled } : s, source.id === sourceId ? { ...source, ...updates } : source,
); );
saveSources(updated); saveSources(updated);
return updated; return updated;
}); });
}; };
/** Get feed by ID */ /** Remove a source */
const getFeed = (feedId: string): Feed | undefined => { const removeSource = (sourceId: string) => {
return feeds().find((f) => f.id === feedId); // Don't remove default sources
}; if (sourceId === "itunes" || sourceId === "rss") return false;
/** Get selected feed */ setSources((prev) => {
const getSelectedFeed = (): Feed | undefined => { const updated = prev.filter((s) => s.id !== sourceId);
const id = selectedFeedId(); saveSources(updated);
return id ? getFeed(id) : undefined; return updated;
}; });
return true;
};
/** Check if a feed has more episodes available beyond what's currently loaded */ /** Toggle source enabled status */
const hasMoreEpisodes = (feedId: string): boolean => { const toggleSource = (sourceId: string) => {
const cached = fullEpisodeCache.get(feedId); setSources((prev) => {
if (!cached) return false; const updated = prev.map((s) =>
const loaded = episodeLoadCount.get(feedId) ?? 0; s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
return loaded < cached.length; );
}; saveSources(updated);
return updated;
});
};
/** Load the next chunk of episodes for a feed from the cache. /** Get feed by ID */
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */ const getFeed = (feedId: string): Feed | undefined => {
const loadMoreEpisodes = async (feedId: string) => { return feeds().find((f) => f.id === feedId);
if (isLoadingMore()) return; };
const feed = getFeed(feedId);
if (!feed) return;
setIsLoadingMore(true); /** Get selected feed */
try { const getSelectedFeed = (): Feed | undefined => {
let cached = fullEpisodeCache.get(feedId); const id = selectedFeedId();
return id ? getFeed(id) : undefined;
};
// If no cache, re-fetch and parse the full feed /** Check if a feed has more episodes available beyond what's currently loaded */
if (!cached) { const hasMoreEpisodes = (feedId: string): boolean => {
const response = await fetch(feed.podcast.feedUrl, { const cached = fullEpisodeCache.get(feedId);
headers: { if (!cached) return false;
"Accept-Encoding": "identity", const loaded = episodeLoadCount.get(feedId) ?? 0;
Accept: "application/rss+xml, application/xml, text/xml, */*", return loaded < cached.length;
}, };
});
if (!response.ok) return;
const xml = await response.text();
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
cached = parsed.episodes;
fullEpisodeCache.set(feedId, cached);
// Set current load count to match what's already displayed
episodeLoadCount.set(feedId, feed.episodes.length);
}
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length; /** Load the next chunk of episodes for a feed from the cache.
const newCount = Math.min( * If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
currentCount + MAX_EPISODES_REFRESH, const loadMoreEpisodes = async (feedId: string) => {
cached.length, if (isLoadingMore()) return;
); const feed = getFeed(feedId);
if (!feed) return;
if (newCount <= currentCount) return; // nothing more to load setIsLoadingMore(true);
try {
let cached = fullEpisodeCache.get(feedId);
episodeLoadCount.set(feedId, newCount); // If no cache, re-fetch and parse the full feed
const episodes = cached.slice(0, newCount); if (!cached) {
const response = await fetch(feed.podcast.feedUrl, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
});
if (!response.ok) return;
const xml = await response.text();
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
cached = parsed.episodes;
fullEpisodeCache.set(feedId, cached);
// Set current load count to match what's already displayed
episodeLoadCount.set(feedId, feed.episodes.length);
}
setFeeds((prev) => { const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
const updated = prev.map((f) => const newCount = Math.min(
f.id === feedId ? { ...f, episodes } : f, currentCount + MAX_EPISODES_REFRESH,
); cached.length,
saveFeeds(updated); );
return updated;
});
} finally {
setIsLoadingMore(false);
}
};
/** Set auto-download settings for a feed */ if (newCount <= currentCount) return; // nothing more to load
const setAutoDownload = (
feedId: string,
enabled: boolean,
count: number = 0,
) => {
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
};
return { episodeLoadCount.set(feedId, newCount);
// State const episodes = cached.slice(0, newCount);
feeds,
sources,
filter,
selectedFeedId,
isLoadingMore,
// Computed setFeeds((prev) => {
getFilteredFeeds, const updated = prev.map((f) =>
getAllEpisodesChronological, f.id === feedId ? { ...f, episodes } : f,
getFeed, );
getSelectedFeed, saveFeeds(updated);
hasMoreEpisodes, return updated;
isLoadingFeeds, });
} finally {
setIsLoadingMore(false);
}
};
// Actions /** Set auto-download settings for a feed */
setFilter, const setAutoDownload = (
setSelectedFeedId, feedId: string,
addFeed, enabled: boolean,
removeFeed, count: number = 0,
updateFeed, ) => {
togglePinned, updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
refreshFeed, };
refreshAllFeeds,
loadMoreEpisodes, return {
addSource, // State
removeSource, feeds,
toggleSource, sources,
updateSource, filter,
setAutoDownload, selectedFeedId,
}; isLoadingMore,
// Computed
getFilteredFeeds,
getAllEpisodesChronological,
getFeed,
getSelectedFeed,
hasMoreEpisodes,
isLoadingFeeds,
// Actions
setFilter,
setSelectedFeedId,
addFeed,
hasFeedByUrl,
removeFeed,
removeFeedByUrl,
updateFeed,
togglePinned,
refreshFeed,
refreshAllFeeds,
loadMoreEpisodes,
addSource,
removeSource,
toggleSource,
updateSource,
setAutoDownload,
};
} }
/** Singleton feed store */ /** Singleton feed store */
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null; let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
export function useFeedStore() { export function useFeedStore() {
if (!feedStoreInstance) { if (!feedStoreInstance) {
feedStoreInstance = createFeedStore(); feedStoreInstance = createFeedStore();
} }
return feedStoreInstance; return feedStoreInstance;
} }