start revive
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -20,128 +20,148 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
type EventHandler<T = unknown> = (data: T) => void
|
||||
type EventHandler<T = unknown> = (data: T) => void;
|
||||
|
||||
// Export EventHandler type for external use
|
||||
export type { EventHandler }
|
||||
export type { EventHandler };
|
||||
|
||||
interface EventBusInstance {
|
||||
on<T = unknown>(event: string, handler: EventHandler<T>): () => void
|
||||
once<T = unknown>(event: string, handler: EventHandler<T>): () => void
|
||||
off<T = unknown>(event: string, handler: EventHandler<T>): void
|
||||
emit<T = unknown>(event: string, data: T): void
|
||||
clear(): void
|
||||
on<T = unknown>(event: string, handler: EventHandler<T>): () => void;
|
||||
once<T = unknown>(event: string, handler: EventHandler<T>): () => void;
|
||||
off<T = unknown>(event: string, handler: EventHandler<T>): void;
|
||||
emit<T = unknown>(event: string, data: T): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
function createEventBus(): EventBusInstance {
|
||||
const handlers = new Map<string, Set<EventHandler>>()
|
||||
const handlers = new Map<string, Set<EventHandler>>();
|
||||
|
||||
return {
|
||||
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
||||
if (!handlers.has(event)) {
|
||||
handlers.set(event, new Set())
|
||||
}
|
||||
handlers.get(event)!.add(handler as EventHandler)
|
||||
return {
|
||||
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
||||
if (!handlers.has(event)) {
|
||||
handlers.set(event, new Set());
|
||||
}
|
||||
handlers.get(event)!.add(handler as EventHandler);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.off(event, handler)
|
||||
}
|
||||
},
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.off(event, handler);
|
||||
};
|
||||
},
|
||||
|
||||
once<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
||||
const wrappedHandler: EventHandler<T> = (data) => {
|
||||
this.off(event, wrappedHandler)
|
||||
handler(data)
|
||||
}
|
||||
return this.on(event, wrappedHandler)
|
||||
},
|
||||
once<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
||||
const wrappedHandler: EventHandler<T> = (data) => {
|
||||
this.off(event, wrappedHandler);
|
||||
handler(data);
|
||||
};
|
||||
return this.on(event, wrappedHandler);
|
||||
},
|
||||
|
||||
off<T = unknown>(event: string, handler: EventHandler<T>): void {
|
||||
const eventHandlers = handlers.get(event)
|
||||
if (eventHandlers) {
|
||||
eventHandlers.delete(handler as EventHandler)
|
||||
if (eventHandlers.size === 0) {
|
||||
handlers.delete(event)
|
||||
}
|
||||
}
|
||||
},
|
||||
off<T = unknown>(event: string, handler: EventHandler<T>): void {
|
||||
const eventHandlers = handlers.get(event);
|
||||
if (eventHandlers) {
|
||||
eventHandlers.delete(handler as EventHandler);
|
||||
if (eventHandlers.size === 0) {
|
||||
handlers.delete(event);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
emit<T = unknown>(event: string, data: T): void {
|
||||
const eventHandlers = handlers.get(event)
|
||||
if (eventHandlers) {
|
||||
for (const handler of eventHandlers) {
|
||||
try {
|
||||
handler(data)
|
||||
} catch (error) {
|
||||
console.error(`Error in event handler for "${event}":`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
emit<T = unknown>(event: string, data: T): void {
|
||||
const eventHandlers = handlers.get(event);
|
||||
if (eventHandlers) {
|
||||
for (const handler of eventHandlers) {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error(`Error in event handler for "${event}":`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
handlers.clear()
|
||||
},
|
||||
}
|
||||
clear(): void {
|
||||
handlers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton event bus instance
|
||||
export const EventBus = createEventBus()
|
||||
export const EventBus = createEventBus();
|
||||
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { TABS } from "@/utils/navigation";
|
||||
import type { PaneId, NavMode } from "@/context/NavigationContext";
|
||||
|
||||
// Common event types for the application
|
||||
export type AppEvents = {
|
||||
"theme.changed": { theme: string; mode: "dark" | "light" }
|
||||
"theme.mode.changed": { mode: "dark" | "light" }
|
||||
"theme.reload": {}
|
||||
"navigation.tab.changed": { tab: string; previousTab?: string }
|
||||
"navigation.layer.changed": { depth: number; previousDepth: number }
|
||||
"feed.subscribed": { feedId: string; feedUrl: string }
|
||||
"feed.unsubscribed": { feedId: string }
|
||||
"player.play": { episodeId: string }
|
||||
"player.pause": { episodeId: string }
|
||||
"player.stop": {}
|
||||
"auth.login": { userId: string }
|
||||
"auth.logout": {}
|
||||
"toast.show": { message: string; variant: "info" | "success" | "warning" | "error"; title?: string; duration?: number }
|
||||
"dialog.open": { dialogId: string }
|
||||
"dialog.close": { dialogId?: string }
|
||||
"command.execute": { command: string; args?: unknown }
|
||||
"clipboard.copied": { text: string }
|
||||
"selection.start": { x: number; y: number }
|
||||
"selection.end": { text: string }
|
||||
"theme.changed": { theme: string; mode: "dark" | "light" };
|
||||
"theme.mode.changed": { mode: "dark" | "light" };
|
||||
"theme.reload": {};
|
||||
"navigation.tab.changed": { tab: string; previousTab?: string };
|
||||
"navigation.layer.changed": { depth: number; previousDepth: number };
|
||||
"feed.subscribed": { feedId: string; feedUrl: string };
|
||||
"feed.unsubscribed": { feedId: string };
|
||||
"player.play": { episodeId: string };
|
||||
"player.pause": { episodeId: string };
|
||||
"player.stop": {};
|
||||
"auth.login": { userId: string };
|
||||
"auth.logout": {};
|
||||
"toast.show": {
|
||||
message: string;
|
||||
variant: "info" | "success" | "warning" | "error";
|
||||
title?: string;
|
||||
duration?: number;
|
||||
};
|
||||
"dialog.open": { dialogId: string };
|
||||
"dialog.close": { dialogId?: string };
|
||||
"command.execute": { command: string; args?: unknown };
|
||||
// Yazi-style unified router → active page dispatch. The Shell router
|
||||
// emits these; each page subscribes to the subset it implements.
|
||||
"nav.action": {
|
||||
action: KeybindActionName;
|
||||
tab: TABS;
|
||||
pane: PaneId;
|
||||
mode: NavMode;
|
||||
};
|
||||
"clipboard.copied": { text: string };
|
||||
"selection.start": { x: number; y: number };
|
||||
"selection.end": { text: string };
|
||||
|
||||
// Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio)
|
||||
"media.toggle": {}
|
||||
"media.volumeUp": {}
|
||||
"media.volumeDown": {}
|
||||
"media.seekForward": {}
|
||||
"media.seekBackward": {}
|
||||
"media.speedCycle": {}
|
||||
}
|
||||
// Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio)
|
||||
"media.toggle": {};
|
||||
"media.volumeUp": {};
|
||||
"media.volumeDown": {};
|
||||
"media.seekForward": {};
|
||||
"media.seekBackward": {};
|
||||
"media.speedCycle": {};
|
||||
};
|
||||
|
||||
// Type-safe emit and on functions
|
||||
export function emit<K extends keyof AppEvents>(event: K, data: AppEvents[K]): void {
|
||||
EventBus.emit(event, data)
|
||||
export function emit<K extends keyof AppEvents>(
|
||||
event: K,
|
||||
data: AppEvents[K],
|
||||
): void {
|
||||
EventBus.emit(event, data);
|
||||
}
|
||||
|
||||
export function on<K extends keyof AppEvents>(
|
||||
event: K,
|
||||
handler: EventHandler<AppEvents[K]>
|
||||
event: K,
|
||||
handler: EventHandler<AppEvents[K]>,
|
||||
): () => void {
|
||||
return EventBus.on(event, handler)
|
||||
return EventBus.on(event, handler);
|
||||
}
|
||||
|
||||
export function once<K extends keyof AppEvents>(
|
||||
event: K,
|
||||
handler: EventHandler<AppEvents[K]>
|
||||
event: K,
|
||||
handler: EventHandler<AppEvents[K]>,
|
||||
): () => void {
|
||||
return EventBus.once(event, handler)
|
||||
return EventBus.once(event, handler);
|
||||
}
|
||||
|
||||
export function off<K extends keyof AppEvents>(
|
||||
event: K,
|
||||
handler: EventHandler<AppEvents[K]>
|
||||
event: K,
|
||||
handler: EventHandler<AppEvents[K]>,
|
||||
): void {
|
||||
EventBus.off(event, handler)
|
||||
EventBus.off(event, handler);
|
||||
}
|
||||
|
||||
@@ -1,90 +1,121 @@
|
||||
/**
|
||||
* Keybinds persistence via JSONC file in XDG_CONFIG_HOME
|
||||
*
|
||||
* Handles copying keybind.jsonc from package to user config directory
|
||||
* Handles copying keybinds.jsonc from package to user config directory
|
||||
* and loading/saving keybind configurations.
|
||||
*/
|
||||
|
||||
import { copyFile, mkdir } from "fs/promises";
|
||||
import { copyFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { parseJSONC } from "./jsonc";
|
||||
import { getConfigFilePath, ensureConfigDir } from "./config-dir";
|
||||
import type { KeybindsResolved } from "../context/KeybindContext";
|
||||
|
||||
const KEYBINDS_SOURCE = path.join(
|
||||
process.cwd(),
|
||||
"src",
|
||||
"config",
|
||||
"keybind.jsonc",
|
||||
process.cwd(),
|
||||
"src",
|
||||
"config",
|
||||
"keybinds.jsonc",
|
||||
);
|
||||
const KEYBINDS_FILE = "keybinds.jsonc";
|
||||
|
||||
/** Default keybinds from package */
|
||||
/** Default keybinds (yazi-style) — mirrors src/config/keybinds.jsonc so the
|
||||
* app works before a user keybinds file is copied into place. */
|
||||
const DEFAULT_KEYBINDS: KeybindsResolved = {
|
||||
up: ["up", "k"],
|
||||
down: ["down", "j"],
|
||||
left: ["left", "h"],
|
||||
right: ["right", "l"],
|
||||
cycle: ["tab"],
|
||||
dive: ["return"],
|
||||
select: ["return"],
|
||||
out: ["esc"],
|
||||
inverseModifier: "shift",
|
||||
leader: ":",
|
||||
quit: ["<leader>q"],
|
||||
"audio-toggle": ["<leader>p"],
|
||||
"audio-pause": [],
|
||||
"audio-play": [],
|
||||
"audio-next": ["<leader>n"],
|
||||
"audio-prev": ["<leader>l"],
|
||||
"audio-seek-forward": ["<leader>sf"],
|
||||
"audio-seek-backward": ["<leader>sb"],
|
||||
// movement
|
||||
"move-down": ["j", "down"],
|
||||
"move-up": ["k", "up"],
|
||||
"page-down": ["ctrl-d"],
|
||||
"page-up": ["ctrl-u"],
|
||||
"full-down": ["ctrl-f"],
|
||||
"full-up": ["ctrl-b"],
|
||||
"jump-down": ["J"],
|
||||
"jump-up": ["K"],
|
||||
"goto-top": [["g", "g"]],
|
||||
"goto-bottom": ["G"],
|
||||
// pane swipe
|
||||
"swipe-prev": ["h", "left"],
|
||||
"swipe-next": ["l", "right"],
|
||||
// open / select
|
||||
open: ["return", "enter"],
|
||||
"open-interactive": ["shift-return"],
|
||||
"toggle-select": ["space"],
|
||||
"visual-mode": ["v"],
|
||||
"toggle-all": ["ctrl-a"],
|
||||
"invert-all": ["ctrl-r"],
|
||||
escape: ["escape", "ctrl-["],
|
||||
// tabs
|
||||
"tab-prev": ["["],
|
||||
"tab-next": ["]"],
|
||||
"tab-goto-1": ["1"],
|
||||
"tab-goto-2": ["2"],
|
||||
"tab-goto-3": ["3"],
|
||||
"tab-goto-4": ["4"],
|
||||
"tab-goto-5": ["5"],
|
||||
"tab-goto-6": ["6"],
|
||||
// command / help / quit
|
||||
command: [":"],
|
||||
quit: ["q", "ctrl-c"],
|
||||
help: ["~", "f1"],
|
||||
// list ops
|
||||
search: ["s"],
|
||||
filter: ["f"],
|
||||
sort: [","],
|
||||
"toggle-hidden": ["."],
|
||||
refresh: ["r"],
|
||||
// audio transport (preserved; shifted single keys, no collisions)
|
||||
"audio-toggle": ["P"],
|
||||
"audio-next": ["N"],
|
||||
"audio-prev": ["B"],
|
||||
"audio-seek-forward": ["shift-."],
|
||||
"audio-seek-backward": ["shift-,"],
|
||||
};
|
||||
|
||||
/** Copy keybind.jsonc to user config directory on first run */
|
||||
/** Copy keybinds.jsonc to user config directory on first run */
|
||||
export async function copyKeybindsIfNeeded(): Promise<void> {
|
||||
try {
|
||||
const targetPath = getConfigFilePath(KEYBINDS_FILE);
|
||||
try {
|
||||
const targetPath = getConfigFilePath(KEYBINDS_FILE);
|
||||
|
||||
// Check if file already exists
|
||||
const targetFile = Bun.file(targetPath);
|
||||
if (await targetFile.exists()) return;
|
||||
// Check if file already exists
|
||||
const targetFile = Bun.file(targetPath);
|
||||
if (await targetFile.exists()) return;
|
||||
|
||||
await ensureConfigDir();
|
||||
await copyFile(KEYBINDS_SOURCE, targetPath);
|
||||
} catch {
|
||||
// Silently ignore errors
|
||||
}
|
||||
await ensureConfigDir();
|
||||
await copyFile(KEYBINDS_SOURCE, targetPath);
|
||||
} catch {
|
||||
// Silently ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/** Load keybinds from JSONC file */
|
||||
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
|
||||
try {
|
||||
const filePath = getConfigFilePath(KEYBINDS_FILE);
|
||||
const file = Bun.file(filePath);
|
||||
try {
|
||||
const filePath = getConfigFilePath(KEYBINDS_FILE);
|
||||
const file = Bun.file(filePath);
|
||||
|
||||
if (!(await file.exists())) return DEFAULT_KEYBINDS;
|
||||
if (!(await file.exists())) return DEFAULT_KEYBINDS;
|
||||
|
||||
const raw = await file.text();
|
||||
const parsed = parseJSONC(raw);
|
||||
const raw = await file.text();
|
||||
const parsed = parseJSONC(raw);
|
||||
|
||||
if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS;
|
||||
if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS;
|
||||
|
||||
return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved;
|
||||
} catch {
|
||||
return DEFAULT_KEYBINDS;
|
||||
}
|
||||
// Merge so partial user configs inherit defaults for missing keys.
|
||||
return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved;
|
||||
} catch {
|
||||
return DEFAULT_KEYBINDS;
|
||||
}
|
||||
}
|
||||
|
||||
/** Save keybinds to JSONC file */
|
||||
export async function saveKeybindsToFile(
|
||||
keybinds: KeybindsResolved,
|
||||
keybinds: KeybindsResolved,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ensureConfigDir();
|
||||
const filePath = getConfigFilePath(KEYBINDS_FILE);
|
||||
await Bun.write(filePath, JSON.stringify(keybinds, null, 2));
|
||||
} catch {
|
||||
// Silently ignore write errors
|
||||
}
|
||||
try {
|
||||
await ensureConfigDir();
|
||||
const filePath = getConfigFilePath(KEYBINDS_FILE);
|
||||
await Bun.write(filePath, JSON.stringify(keybinds, null, 2));
|
||||
} catch {
|
||||
// Silently ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,33 +6,55 @@ import { SearchPage, SearchPaneCount } from "@/pages/Search/SearchPage";
|
||||
import { SettingsPage, SettingsPaneCount } from "@/pages/Settings/SettingsPage";
|
||||
|
||||
export enum DIRECTION {
|
||||
Increment,
|
||||
Decrement,
|
||||
Increment,
|
||||
Decrement,
|
||||
}
|
||||
|
||||
export enum TABS {
|
||||
FEED = 1,
|
||||
MYSHOWS = 2,
|
||||
DISCOVER = 3,
|
||||
SEARCH = 4,
|
||||
PLAYER = 5,
|
||||
SETTINGS = 6,
|
||||
FEED = 1,
|
||||
MYSHOWS = 2,
|
||||
DISCOVER = 3,
|
||||
SEARCH = 4,
|
||||
PLAYER = 5,
|
||||
SETTINGS = 6,
|
||||
}
|
||||
export const TabsCount = 6;
|
||||
|
||||
export const LayerGraph = {
|
||||
[TABS.FEED]: FeedPage,
|
||||
[TABS.MYSHOWS]: MyShowsPage,
|
||||
[TABS.DISCOVER]: DiscoverPage,
|
||||
[TABS.SEARCH]: SearchPage,
|
||||
[TABS.PLAYER]: PlayerPage,
|
||||
[TABS.SETTINGS]: SettingsPage,
|
||||
[TABS.FEED]: FeedPage,
|
||||
[TABS.MYSHOWS]: MyShowsPage,
|
||||
[TABS.DISCOVER]: DiscoverPage,
|
||||
[TABS.SEARCH]: SearchPage,
|
||||
[TABS.PLAYER]: PlayerPage,
|
||||
[TABS.SETTINGS]: SettingsPage,
|
||||
};
|
||||
export const LayerDepths = {
|
||||
[TABS.FEED]: FeedPaneCount,
|
||||
[TABS.MYSHOWS]: MyShowsPaneCount,
|
||||
[TABS.DISCOVER]: DiscoverPaneCount,
|
||||
[TABS.SEARCH]: SearchPaneCount,
|
||||
[TABS.PLAYER]: PlayerPaneCount,
|
||||
[TABS.SETTINGS]: SettingsPaneCount,
|
||||
[TABS.FEED]: FeedPaneCount,
|
||||
[TABS.MYSHOWS]: MyShowsPaneCount,
|
||||
[TABS.DISCOVER]: DiscoverPaneCount,
|
||||
[TABS.SEARCH]: SearchPaneCount,
|
||||
[TABS.PLAYER]: PlayerPaneCount,
|
||||
[TABS.SETTINGS]: SettingsPaneCount,
|
||||
};
|
||||
|
||||
// Yazi-style pane grow ratios (parent : current : preview) ≈ [1, 4, 3].
|
||||
// Panes use flexGrow (Yoga) so columns always sum to the row width regardless
|
||||
// of terminal size — more robust than fixed percentages and exactly mirrors
|
||||
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
|
||||
export const PANE_RATIO = {
|
||||
parent: 1,
|
||||
current: 4,
|
||||
preview: 3,
|
||||
} as const;
|
||||
|
||||
// Number of interactive panes per tab (for the yazi h/l swipe). Slots beyond
|
||||
// a tab's count are not focusable. Defined here (after TABS) to avoid re-introducing
|
||||
// the old NavigationContext top-level-init circular deadlock.
|
||||
export const TabPaneCount: Record<TABS, number> = {
|
||||
[TABS.FEED]: 3, // feeds | episodes | preview
|
||||
[TABS.MYSHOWS]: 3, // shows | episodes | preview
|
||||
[TABS.DISCOVER]: 3, // categories | results | detail
|
||||
[TABS.SEARCH]: 3, // query | results | detail
|
||||
[TABS.PLAYER]: 1, // single pane
|
||||
[TABS.SETTINGS]: 2, // sections | panel
|
||||
};
|
||||
|
||||
@@ -1,156 +1,174 @@
|
||||
import { searchSourceByType } from "./source-searcher"
|
||||
import type { PodcastSource, SearchResult } from "../types/source"
|
||||
import type { Episode } from "../types/episode"
|
||||
import { searchSourceByType } from "./source-searcher";
|
||||
import type { PodcastSource, SearchResult } from "../types/source";
|
||||
import type { Episode } from "../types/episode";
|
||||
|
||||
type SearchCacheEntry = {
|
||||
timestamp: number
|
||||
results: SearchResult[]
|
||||
}
|
||||
timestamp: number;
|
||||
results: SearchResult[];
|
||||
};
|
||||
|
||||
type SearchOptions = {
|
||||
cacheTtl?: number
|
||||
}
|
||||
cacheTtl?: number;
|
||||
};
|
||||
|
||||
const searchCache = new Map<string, SearchCacheEntry>()
|
||||
const rateLimitState = new Map<string, number[]>()
|
||||
const RATE_LIMIT_WINDOW_MS = 60000
|
||||
const RATE_LIMIT_MAX_CALLS = 20
|
||||
const searchCache = new Map<string, SearchCacheEntry>();
|
||||
const rateLimitState = new Map<string, number[]>();
|
||||
const RATE_LIMIT_WINDOW_MS = 60000;
|
||||
const RATE_LIMIT_MAX_CALLS = 20;
|
||||
|
||||
const throttleSource = async (sourceId: string) => {
|
||||
const now = Date.now()
|
||||
const windowStart = now - RATE_LIMIT_WINDOW_MS
|
||||
const timestamps = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? []
|
||||
const now = Date.now();
|
||||
const windowStart = now - RATE_LIMIT_WINDOW_MS;
|
||||
const timestamps =
|
||||
rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [];
|
||||
|
||||
if (timestamps.length >= RATE_LIMIT_MAX_CALLS) {
|
||||
const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now
|
||||
if (waitMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs))
|
||||
}
|
||||
}
|
||||
if (timestamps.length >= RATE_LIMIT_MAX_CALLS) {
|
||||
const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now;
|
||||
if (waitMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
}
|
||||
|
||||
const updated = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? []
|
||||
updated.push(Date.now())
|
||||
rateLimitState.set(sourceId, updated)
|
||||
}
|
||||
const updated =
|
||||
rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [];
|
||||
updated.push(Date.now());
|
||||
rateLimitState.set(sourceId, updated);
|
||||
};
|
||||
|
||||
const buildCacheKey = (query: string, sourceIds: string[]) => {
|
||||
const keySources = [...sourceIds].sort().join(",")
|
||||
return `${query.toLowerCase()}::${keySources}`
|
||||
}
|
||||
const keySources = [...sourceIds].sort().join(",");
|
||||
return `${query.toLowerCase()}::${keySources}`;
|
||||
};
|
||||
|
||||
const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
|
||||
Date.now() - entry.timestamp < ttl
|
||||
Date.now() - entry.timestamp < ttl;
|
||||
|
||||
const dedupeResults = (results: SearchResult[]): SearchResult[] => {
|
||||
const map = new Map<string, SearchResult>()
|
||||
for (const result of results) {
|
||||
const key = result.podcast.feedUrl || result.podcast.id || result.podcast.title
|
||||
const existing = map.get(key)
|
||||
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
|
||||
map.set(key, result)
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
}
|
||||
const map = new Map<string, SearchResult>();
|
||||
for (const result of results) {
|
||||
const key =
|
||||
result.podcast.feedUrl || result.podcast.id || result.podcast.title;
|
||||
const existing = map.get(key);
|
||||
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
|
||||
map.set(key, result);
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
export const searchPodcasts = async (
|
||||
query: string,
|
||||
sourceIds: string[],
|
||||
sources: PodcastSource[],
|
||||
options: SearchOptions = {}
|
||||
query: string,
|
||||
sourceIds: string[],
|
||||
sources: PodcastSource[],
|
||||
options: SearchOptions = {},
|
||||
): Promise<SearchResult[]> => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return []
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const activeSources = sources.filter(
|
||||
(source) => sourceIds.includes(source.id) && source.enabled
|
||||
)
|
||||
const activeSources = sources.filter(
|
||||
(source) => sourceIds.includes(source.id) && source.enabled,
|
||||
);
|
||||
|
||||
if (activeSources.length === 0) return []
|
||||
if (activeSources.length === 0) {
|
||||
// No enabled sources — surface a clear cause instead of returning empty,
|
||||
// which otherwise looks indistinguishable from a network failure.
|
||||
if (sourceIds.length === 0) {
|
||||
throw new Error("No search sources are enabled");
|
||||
}
|
||||
throw new Error("No enabled sources match the selected search sources");
|
||||
}
|
||||
|
||||
const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5
|
||||
const cacheKey = buildCacheKey(trimmed, activeSources.map((s) => s.id))
|
||||
const cached = searchCache.get(cacheKey)
|
||||
if (cached && isCacheValid(cached, cacheTtl)) {
|
||||
return cached.results
|
||||
}
|
||||
const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5;
|
||||
const cacheKey = buildCacheKey(
|
||||
trimmed,
|
||||
activeSources.map((s) => s.id),
|
||||
);
|
||||
const cached = searchCache.get(cacheKey);
|
||||
if (cached && isCacheValid(cached, cacheTtl)) {
|
||||
return cached.results;
|
||||
}
|
||||
|
||||
const results: SearchResult[] = []
|
||||
const errors: Error[] = []
|
||||
const results: SearchResult[] = [];
|
||||
const errors: Error[] = [];
|
||||
|
||||
await Promise.all(
|
||||
activeSources.map(async (source) => {
|
||||
try {
|
||||
await throttleSource(source.id)
|
||||
const sourceResults = await searchSourceByType(trimmed, source)
|
||||
results.push(...sourceResults)
|
||||
} catch (error) {
|
||||
errors.push(error as Error)
|
||||
}
|
||||
})
|
||||
)
|
||||
await Promise.all(
|
||||
activeSources.map(async (source) => {
|
||||
try {
|
||||
await throttleSource(source.id);
|
||||
const sourceResults = await searchSourceByType(trimmed, source);
|
||||
results.push(...sourceResults);
|
||||
} catch (error) {
|
||||
errors.push(error as Error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const deduped = dedupeResults(results)
|
||||
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
||||
const deduped = dedupeResults(results);
|
||||
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
|
||||
if (sorted.length === 0 && errors.length > 0) {
|
||||
throw new Error("Search failed for all sources")
|
||||
}
|
||||
if (sorted.length === 0 && errors.length > 0) {
|
||||
throw new Error("Search failed for all sources");
|
||||
}
|
||||
|
||||
searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted })
|
||||
return sorted
|
||||
}
|
||||
searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted });
|
||||
return sorted;
|
||||
};
|
||||
|
||||
type ItunesEpisodeResult = {
|
||||
trackId?: number
|
||||
trackName?: string
|
||||
description?: string
|
||||
shortDescription?: string
|
||||
releaseDate?: string
|
||||
trackTimeMillis?: number
|
||||
episodeUrl?: string
|
||||
previewUrl?: string
|
||||
trackViewUrl?: string
|
||||
}
|
||||
trackId?: number;
|
||||
trackName?: string;
|
||||
description?: string;
|
||||
shortDescription?: string;
|
||||
releaseDate?: string;
|
||||
trackTimeMillis?: number;
|
||||
episodeUrl?: string;
|
||||
previewUrl?: string;
|
||||
trackViewUrl?: string;
|
||||
};
|
||||
|
||||
type ItunesEpisodeResponse = {
|
||||
resultCount: number
|
||||
results: ItunesEpisodeResult[]
|
||||
}
|
||||
resultCount: number;
|
||||
results: ItunesEpisodeResult[];
|
||||
};
|
||||
|
||||
export const searchEpisodes = async (
|
||||
query: string,
|
||||
feedId: string
|
||||
query: string,
|
||||
feedId: string,
|
||||
): Promise<Episode[]> => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return []
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const url = new URL("https://itunes.apple.com/search")
|
||||
url.searchParams.set("term", trimmed)
|
||||
url.searchParams.set("media", "podcast")
|
||||
url.searchParams.set("entity", "podcastEpisode")
|
||||
url.searchParams.set("country", "US")
|
||||
url.searchParams.set("lang", "en_us")
|
||||
const url = new URL("https://itunes.apple.com/search");
|
||||
url.searchParams.set("term", trimmed);
|
||||
url.searchParams.set("media", "podcast");
|
||||
url.searchParams.set("entity", "podcastEpisode");
|
||||
url.searchParams.set("country", "US");
|
||||
url.searchParams.set("lang", "en_us");
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
if (!response.ok) return []
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) return [];
|
||||
|
||||
const data = (await response.json()) as ItunesEpisodeResponse
|
||||
return data.results
|
||||
.map((item) => {
|
||||
if (!item.trackName) return null
|
||||
const id = item.trackId ? `episode-${item.trackId}` : `episode-${item.trackName}`
|
||||
const audioUrl = item.episodeUrl || item.previewUrl || item.trackViewUrl || ""
|
||||
const data = (await response.json()) as ItunesEpisodeResponse;
|
||||
return data.results
|
||||
.map((item) => {
|
||||
if (!item.trackName) return null;
|
||||
const id = item.trackId
|
||||
? `episode-${item.trackId}`
|
||||
: `episode-${item.trackName}`;
|
||||
const audioUrl =
|
||||
item.episodeUrl || item.previewUrl || item.trackViewUrl || "";
|
||||
|
||||
return {
|
||||
id,
|
||||
podcastId: feedId,
|
||||
title: item.trackName,
|
||||
description: item.description || item.shortDescription || "",
|
||||
audioUrl,
|
||||
duration: item.trackTimeMillis ? Math.round(item.trackTimeMillis / 1000) : 0,
|
||||
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
|
||||
}
|
||||
})
|
||||
.filter((item): item is Episode => Boolean(item))
|
||||
}
|
||||
return {
|
||||
id,
|
||||
podcastId: feedId,
|
||||
title: item.trackName,
|
||||
description: item.description || item.shortDescription || "",
|
||||
audioUrl,
|
||||
duration: item.trackTimeMillis
|
||||
? Math.round(item.trackTimeMillis / 1000)
|
||||
: 0,
|
||||
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
|
||||
};
|
||||
})
|
||||
.filter((item): item is Episode => Boolean(item));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user