This commit is contained in:
Michael Freno
2026-02-04 10:02:07 -05:00
parent f7df578461
commit 72b2870f64
5 changed files with 166 additions and 13 deletions

View File

@@ -5,14 +5,15 @@
import { createSignal, For } from "solid-js"
import { useFeedStore } from "../stores/feed"
import type { PodcastSource, SourceType } from "../types/source"
import { SourceType } from "../types/source"
import type { PodcastSource } from "../types/source"
interface SourceManagerProps {
focused?: boolean
onClose?: () => void
}
type FocusArea = "list" | "add" | "url"
type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language"
export function SourceManager(props: SourceManagerProps) {
const feedStore = useFeedStore()
@@ -36,7 +37,14 @@ export function SourceManager(props: SourceManagerProps) {
}
if (key.name === "tab") {
const areas: FocusArea[] = ["list", "add", "url"]
const areas: FocusArea[] = [
"list",
"country",
"language",
"explicit",
"add",
"url",
]
const idx = areas.indexOf(focusArea())
const nextIdx = key.shift
? (idx - 1 + areas.length) % areas.length
@@ -67,6 +75,35 @@ export function SourceManager(props: SourceManagerProps) {
setFocusArea("add")
}
}
if (focusArea() === "country") {
if (key.name === "enter" || key.name === "return" || key.name === "space") {
const source = sources()[selectedIndex()]
if (source && source.type === SourceType.API) {
const next = source.country === "US" ? "GB" : "US"
feedStore.updateSource(source.id, { country: next })
}
}
}
if (focusArea() === "explicit") {
if (key.name === "enter" || key.name === "return" || key.name === "space") {
const source = sources()[selectedIndex()]
if (source && source.type === SourceType.API) {
feedStore.updateSource(source.id, { allowExplicit: !source.allowExplicit })
}
}
}
if (focusArea() === "language") {
if (key.name === "enter" || key.name === "return" || key.name === "space") {
const source = sources()[selectedIndex()]
if (source && source.type === SourceType.API) {
const next = source.language === "ja_jp" ? "en_us" : "ja_jp"
feedStore.updateSource(source.id, { language: next })
}
}
}
}
const handleAddSource = () => {
@@ -100,11 +137,17 @@ export function SourceManager(props: SourceManagerProps) {
}
const getSourceIcon = (source: PodcastSource) => {
if (source.type === "api") return "[API]"
if (source.type === "rss") return "[RSS]"
if (source.type === SourceType.API) return "[API]"
if (source.type === SourceType.RSS) return "[RSS]"
return "[?]"
}
const selectedSource = () => sources()[selectedIndex()]
const isApiSource = () => selectedSource()?.type === SourceType.API
const sourceCountry = () => selectedSource()?.country || "US"
const sourceExplicit = () => selectedSource()?.allowExplicit !== false
const sourceLanguage = () => selectedSource()?.language || "en_us"
return (
<box flexDirection="column" border padding={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
@@ -119,7 +162,7 @@ export function SourceManager(props: SourceManagerProps) {
<text fg="gray">Manage where to search for podcasts</text>
{/* Source list */}
<box border padding={1} flexDirection="column">
<box border padding={1} flexDirection="column" gap={1}>
<text fg={focusArea() === "list" ? "cyan" : "gray"}>Sources:</text>
<scrollbox height={6}>
<For each={sources()}>
@@ -166,6 +209,43 @@ export function SourceManager(props: SourceManagerProps) {
</For>
</scrollbox>
<text fg="gray">Space/Enter to toggle, d to delete, a to add</text>
{/* API settings */}
<box flexDirection="column" gap={1}>
<text fg={isApiSource() ? "gray" : "yellow"}>
{isApiSource() ? "API Settings" : "API Settings (select an API source)"}
</text>
<box flexDirection="row" gap={2}>
<box
border
padding={0}
backgroundColor={focusArea() === "country" ? "#333" : undefined}
>
<text fg={focusArea() === "country" ? "cyan" : "gray"}>
Country: {sourceCountry()}
</text>
</box>
<box
border
padding={0}
backgroundColor={focusArea() === "language" ? "#333" : undefined}
>
<text fg={focusArea() === "language" ? "cyan" : "gray"}>
Language: {sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
</text>
</box>
<box
border
padding={0}
backgroundColor={focusArea() === "explicit" ? "#333" : undefined}
>
<text fg={focusArea() === "explicit" ? "cyan" : "gray"}>
Explicit: {sourceExplicit() ? "Yes" : "No"}
</text>
</box>
</box>
<text fg="gray">Enter/Space to toggle focused setting</text>
</box>
</box>
{/* Add new source form */}

View File

@@ -351,6 +351,17 @@ export function createFeedStore() {
return newSource
}
/** Update a source */
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
setSources((prev) => {
const updated = prev.map((source) =>
source.id === sourceId ? { ...source, ...updates } : source
)
saveSources(updated)
return updated
})
}
/** Remove a source */
const removeSource = (sourceId: string) => {
// Don't remove default sources
@@ -409,6 +420,7 @@ export function createFeedStore() {
addSource,
removeSource,
toggleSource,
updateSource,
}
}

View File

@@ -34,8 +34,6 @@ export interface PodcastSource {
country?: string
/** Default language for search results */
language?: string
/** Default results limit */
searchLimit?: number
/** Include explicit results */
allowExplicit?: boolean
/** Rate limit (requests per minute) */
@@ -105,7 +103,6 @@ export const DEFAULT_SOURCES: PodcastSource[] = [
description: "Search the Apple Podcasts directory",
country: "US",
language: "en_us",
searchLimit: 25,
allowExplicit: true,
},
{

View File

@@ -12,6 +12,26 @@ type SearchOptions = {
}
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) ?? []
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 buildCacheKey = (query: string, sourceIds: string[]) => {
const keySources = [...sourceIds].sort().join(",")
@@ -61,6 +81,7 @@ export const searchPodcasts = async (
await Promise.all(
activeSources.map(async (source) => {
try {
await throttleSource(source.id)
const sourceResults = await searchSourceByType(trimmed, source)
results.push(...sourceResults)
} catch (error) {
@@ -80,12 +101,56 @@ export const searchPodcasts = async (
return sorted
}
type ItunesEpisodeResult = {
trackId?: number
trackName?: string
description?: string
shortDescription?: string
releaseDate?: string
trackTimeMillis?: number
episodeUrl?: string
previewUrl?: string
trackViewUrl?: string
}
type ItunesEpisodeResponse = {
resultCount: number
results: ItunesEpisodeResult[]
}
export const searchEpisodes = async (
query: string,
_feedId: string
feedId: string
): Promise<Episode[]> => {
const trimmed = query.trim()
if (!trimmed) return []
await new Promise((resolve) => setTimeout(resolve, 200))
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 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 || ""
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))
}

View File

@@ -117,7 +117,6 @@ const buildItunesUrl = (query: string, source: PodcastSource) => {
params.set("term", query.trim())
params.set("media", "podcast")
params.set("entity", "podcast")
params.set("limit", String(source.searchLimit ?? 25))
params.set("country", source.country ?? "US")
params.set("lang", source.language ?? "en_us")
params.set("explicit", source.allowExplicit === false ? "No" : "Yes")