This commit is contained in:
2026-02-04 09:39:58 -05:00
parent bd4747679d
commit f7df578461
26 changed files with 907 additions and 783 deletions

91
src/utils/search.ts Normal file
View File

@@ -0,0 +1,91 @@
import { searchSourceByType } from "./source-searcher"
import type { PodcastSource, SearchResult } from "../types/source"
import type { Episode } from "../types/episode"
type SearchCacheEntry = {
timestamp: number
results: SearchResult[]
}
type SearchOptions = {
cacheTtl?: number
}
const searchCache = new Map<string, SearchCacheEntry>()
const buildCacheKey = (query: string, sourceIds: string[]) => {
const keySources = [...sourceIds].sort().join(",")
return `${query.toLowerCase()}::${keySources}`
}
const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
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())
}
export const searchPodcasts = async (
query: string,
sourceIds: string[],
sources: PodcastSource[],
options: SearchOptions = {}
): Promise<SearchResult[]> => {
const trimmed = query.trim()
if (!trimmed) return []
const activeSources = sources.filter(
(source) => sourceIds.includes(source.id) && source.enabled
)
if (activeSources.length === 0) return []
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[] = []
await Promise.all(
activeSources.map(async (source) => {
try {
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))
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
}
export const searchEpisodes = async (
query: string,
_feedId: string
): Promise<Episode[]> => {
const trimmed = query.trim()
if (!trimmed) return []
await new Promise((resolve) => setTimeout(resolve, 200))
return []
}

View File

@@ -0,0 +1,196 @@
import type { Podcast } from "../types/podcast"
import { SourceType } from "../types/source"
import type { PodcastSource, SearchResult } from "../types/source"
type SearcherResult = SearchResult[]
const delay = async (min = 200, max = 500) =>
new Promise((resolve) => setTimeout(resolve, min + Math.random() * max))
const hashString = (input: string): number => {
let hash = 0
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i)
hash |= 0
}
return Math.abs(hash)
}
const slugify = (input: string): string =>
input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
const sourceLabel = (source: PodcastSource): string =>
source.name || source.id
const buildPodcast = (
idBase: string,
title: string,
description: string,
author: string,
categories: string[],
source: PodcastSource
): Podcast => ({
id: idBase,
title,
description,
feedUrl: `https://example.com/${slugify(title)}/feed.xml`,
author,
categories,
lastUpdated: new Date(),
isSubscribed: false,
})
const makeResults = (query: string, source: PodcastSource, seedOffset = 0): SearcherResult => {
const seed = hashString(`${source.id}:${query}`) + seedOffset
const baseTitles = [
"Daily Briefing",
"Studio Sessions",
"Signal & Noise",
"The Long Play",
"Off the Record",
]
const descriptors = [
"Deep dives into",
"A fast-paced look at",
"Smart conversations about",
"A weekly roundup of",
"Curated stories on",
]
const categories = ["Technology", "Business", "Science", "Culture", "News"]
return baseTitles.map((base, index) => {
const title = `${query} ${base}`
const desc = `${descriptors[index % descriptors.length]} ${query.toLowerCase()} from ${sourceLabel(source)}.`
const author = `${sourceLabel(source)} Network`
const cat = [categories[(seed + index) % categories.length]]
const podcast = buildPodcast(
`search-${source.id}-${seed + index}`,
title,
desc,
author,
cat,
source
)
return {
sourceId: source.id,
sourceName: source.name,
sourceType: source.type,
podcast,
score: 1 - index * 0.08,
}
})
}
export const searchRSSSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
await delay(200, 450)
return makeResults(query, source, 1)
}
type ItunesResult = {
collectionId?: number
collectionName?: string
artistName?: string
feedUrl?: string
artworkUrl100?: string
artworkUrl600?: string
primaryGenreName?: string
releaseDate?: string
}
type ItunesResponse = {
resultCount: number
results: ItunesResult[]
}
const buildItunesUrl = (query: string, source: PodcastSource) => {
const baseUrl = source.baseUrl?.trim() || "https://itunes.apple.com/search"
const url = new URL(baseUrl)
const params = url.searchParams
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")
return url.toString()
}
const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast | null => {
if (!result.collectionName || !result.feedUrl) return null
const id = result.collectionId
? `itunes-${result.collectionId}`
: `itunes-${slugify(result.collectionName)}`
const descriptionParts = [result.collectionName]
if (result.artistName) descriptionParts.push(`by ${result.artistName}`)
if (result.primaryGenreName) descriptionParts.push(result.primaryGenreName)
return {
id,
title: result.collectionName,
description: descriptionParts.join(" • "),
feedUrl: result.feedUrl,
author: result.artistName,
categories: result.primaryGenreName ? [result.primaryGenreName] : undefined,
coverUrl: result.artworkUrl600 || result.artworkUrl100,
lastUpdated: result.releaseDate ? new Date(result.releaseDate) : new Date(),
isSubscribed: false,
}
}
export const searchAPISource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
const url = buildItunesUrl(query, source)
const response = await fetch(url)
if (!response.ok) {
throw new Error(`iTunes search failed: ${response.status}`)
}
const data = (await response.json()) as ItunesResponse
const results = data.results
.map((item) => mapItunesResult(item, source))
.filter((item): item is Podcast => Boolean(item))
return results.map((podcast, index) => ({
sourceId: source.id,
sourceName: source.name,
sourceType: source.type,
podcast,
score: 1 - index * 0.02,
}))
}
export const searchCustomSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
await delay(300, 650)
return makeResults(query, source, 13)
}
export const searchSourceByType = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
if (source.type === SourceType.RSS) {
return searchRSSSource(query, source)
}
if (source.type === SourceType.CUSTOM) {
return searchCustomSource(query, source)
}
return searchAPISource(query, source)
}