Files
PodTui/src/stores/search.ts
Michael Freno 0c3506beb5 feat(search): episode search scope with tab toggle
Search now covers individual episodes, not just shows: the iTunes
Search API (entity=podcastEpisode) matches episode titles and show
notes, so a guest or topic finds the episodes they appear in across
shows. Enter on an episode result subscribes to the parent show.

- types: SearchResult becomes a kind-discriminated union
  (podcast | episode); EpisodeSearchResult carries the parent show so
  existing consumers compile unchanged
- source-searcher: searchEpisodesByType (RSS/CUSTOM return []),
  buildItunesEpisodeUrl, cleanDescription (HTML -> text),
  mapItunesEpisodeResult (episode id/duration ms->s/audioUrl, reuses
  mapItunesResult so delisted shows keep a directoryUrl)
- search: searchEpisodes with an 'episode' cache/dedupe namespace so
  shows and episodes for the same query never mix
- stores/search: scope signal (podcast | episode) persisted to
  podtui_search_scope; search() branches on scope
- SearchPage: Shows/Episodes pills row with 'tab to toggle', scope-
  aware placeholder/empty state/result rows (episode row = title +
  Show · date) and preview; toggling re-runs the current query
- keybinds: search-scope-toggle bound to tab (keybinds.jsonc AND the
  runtime DEFAULT_KEYBINDS merge so the binding exists for users with
  a pre-existing config file); while the input is focused the Shell
  router never sees Tab, so the input handles it via onKeyDown +
  preventDefault (no double-toggle: the router path only fires when
  the input is defocused)
- Shell help overlay documents [tab] shows/episodes
2026-08-11 00:50:13 -04:00

247 lines
6.1 KiB
TypeScript

/**
* Search store for PodTUI
* Manages search state, history, and results
*/
import { createSignal } from "solid-js";
import { searchPodcasts, searchEpisodes, searchByFeedUrl } from "../utils/search";
import { useFeedStore } from "./feed";
import type { SearchResult, SearchScope } from "../types/source";
const STORAGE_KEY = "podtui_search_history";
const STORAGE_SCOPE_KEY = "podtui_search_scope";
const MAX_HISTORY = 20;
export interface SearchState {
query: string;
isSearching: boolean;
results: SearchResult[];
error: string | null;
}
const CACHE_TTL = 1000 * 60 * 5;
/** Load search history from localStorage */
function loadHistory(): string[] {
if (typeof localStorage === "undefined") return [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/** Save search history to localStorage */
function saveHistory(history: string[]): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
} catch {
// Ignore errors
}
}
/** Load persisted search scope ("podcast" | "episode"), defaulting to shows. */
function loadScope(): SearchScope {
if (typeof localStorage === "undefined") return "podcast";
try {
const stored = localStorage.getItem(STORAGE_SCOPE_KEY);
return stored === "episode" ? "episode" : "podcast";
} catch {
return "podcast";
}
}
/** Save search scope to localStorage */
function saveScope(scope: SearchScope): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_SCOPE_KEY, scope);
} catch {
// Ignore errors
}
}
/** Create search store */
export function createSearchStore() {
const feedStore = useFeedStore();
const [query, setQuery] = createSignal("");
const [isSearching, setIsSearching] = createSignal(false);
const [results, setResults] = createSignal<SearchResult[]>([]);
const [error, setError] = createSignal<string | null>(null);
const [history, setHistory] = createSignal<string[]>(loadHistory());
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
/** Set the search scope (shows vs episodes) and persist it. */
const setScope = (next: SearchScope) => {
setScopeState(next);
saveScope(next);
};
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
const feeds = feedStore.feeds();
const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl));
const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id));
return items.map((item) => ({
...item,
podcast: {
...item.podcast,
isSubscribed:
item.podcast.isSubscribed ||
subscribedUrls.has(item.podcast.feedUrl) ||
subscribedIds.has(item.podcast.id),
},
}));
};
/** Perform search (multi-source implementation) */
const search = async (searchQuery: string): Promise<void> => {
const q = searchQuery.trim();
if (!q) {
setResults([]);
return;
}
setQuery(q);
setIsSearching(true);
setError(null);
addToHistory(q);
try {
// A query that is a direct RSS feed URL (e.g. a private feed that
// isn't in any public directory) resolves to that feed directly,
// independent of enabled search sources.
const urlResults = await searchByFeedUrl(q);
if (urlResults.length > 0) {
setResults(applySubscribedStatus(urlResults));
return;
}
const sources = feedStore.sources();
const enabledSourceIds = sources
.filter((s) => s.enabled)
.map((s) => s.id);
const sourceIds =
selectedSources().length > 0 ? selectedSources() : enabledSourceIds;
// Empty query guard already returned above; if there are no enabled
// sources, tell the user instead of returning an empty list that looks
// like a network outage.
if (enabledSourceIds.length === 0) {
setError(
"No search sources are enabled. Enable one in Settings → Sources.",
);
setResults([]);
return;
}
const searchResults =
scope() === "episode"
? await searchEpisodes(q, sourceIds, sources, {
cacheTtl: CACHE_TTL,
})
: await searchPodcasts(q, sourceIds, sources, {
cacheTtl: CACHE_TTL,
});
setResults(applySubscribedStatus(searchResults));
} catch (e) {
setError(
e instanceof Error && e.message
? e.message
: "Search failed. Please try again.",
);
setResults([]);
} finally {
setIsSearching(false);
}
};
/** Add query to history */
const addToHistory = (q: string) => {
setHistory((prev) => {
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
saveHistory(updated);
return updated;
});
};
/** Clear search history */
const clearHistory = () => {
setHistory([]);
saveHistory([]);
};
/** Remove single history item */
const removeFromHistory = (q: string) => {
setHistory((prev) => {
const updated = prev.filter((h) => h !== q);
saveHistory(updated);
return updated;
});
};
/** Clear results */
const clearResults = () => {
setResults([]);
setQuery("");
setError(null);
};
/** Mark a podcast as subscribed in results */
const markSubscribed = (podcastId: string, feedUrl?: string) => {
setResults((prev) =>
prev.map((result) => {
const matchesId = result.podcast.id === podcastId;
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false;
if (matchesId || matchesUrl) {
return {
...result,
podcast: {
...result.podcast,
isSubscribed: true,
},
};
}
return result;
}),
);
};
return {
// State
query,
isSearching,
results,
error,
history,
selectedSources,
scope,
// Actions
search,
setQuery,
clearResults,
clearHistory,
removeFromHistory,
setSelectedSources,
setScope,
markSubscribed,
};
}
/** Singleton search store */
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
export function useSearchStore() {
if (!searchStoreInstance) {
searchStoreInstance = createSearchStore();
}
return searchStoreInstance;
}