options
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user