From 72b2870f64bcbef04fc4f4e4193efde52c39502d Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 4 Feb 2026 10:02:07 -0500 Subject: [PATCH] options --- src/components/SourceManager.tsx | 92 +++++++++++++++++++++++++++++--- src/stores/feed.ts | 12 +++++ src/types/source.ts | 3 -- src/utils/search.ts | 71 ++++++++++++++++++++++-- src/utils/source-searcher.ts | 1 - 5 files changed, 166 insertions(+), 13 deletions(-) diff --git a/src/components/SourceManager.tsx b/src/components/SourceManager.tsx index 509b1c5..b344a51 100644 --- a/src/components/SourceManager.tsx +++ b/src/components/SourceManager.tsx @@ -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 ( @@ -119,7 +162,7 @@ export function SourceManager(props: SourceManagerProps) { Manage where to search for podcasts {/* Source list */} - + Sources: @@ -166,6 +209,43 @@ export function SourceManager(props: SourceManagerProps) { Space/Enter to toggle, d to delete, a to add + + {/* API settings */} + + + {isApiSource() ? "API Settings" : "API Settings (select an API source)"} + + + + + Country: {sourceCountry()} + + + + + Language: {sourceLanguage() === "ja_jp" ? "Japanese" : "English"} + + + + + Explicit: {sourceExplicit() ? "Yes" : "No"} + + + + Enter/Space to toggle focused setting + {/* Add new source form */} diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 2387bb9..08d95a6 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -351,6 +351,17 @@ export function createFeedStore() { return newSource } + /** Update a source */ + const updateSource = (sourceId: string, updates: Partial) => { + 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, } } diff --git a/src/types/source.ts b/src/types/source.ts index 551fb45..54abb73 100644 --- a/src/types/source.ts +++ b/src/types/source.ts @@ -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, }, { diff --git a/src/utils/search.ts b/src/utils/search.ts index 08e9fc6..2f4a0f5 100644 --- a/src/utils/search.ts +++ b/src/utils/search.ts @@ -12,6 +12,26 @@ type SearchOptions = { } const searchCache = new Map() +const rateLimitState = new Map() +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 => { 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)) } diff --git a/src/utils/source-searcher.ts b/src/utils/source-searcher.ts index d091b68..4d4fffe 100644 --- a/src/utils/source-searcher.ts +++ b/src/utils/source-searcher.ts @@ -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")