broke
This commit is contained in:
91
src/utils/search.ts
Normal file
91
src/utils/search.ts
Normal 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 []
|
||||
}
|
||||
196
src/utils/source-searcher.ts
Normal file
196
src/utils/source-searcher.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user