checkpoint
This commit is contained in:
73
src/api/client.ts
Normal file
73
src/api/client.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Feed } from "../types/feed"
|
||||
import type { Episode } from "../types/episode"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import type { PodcastSource } from "../types/source"
|
||||
import { parseRSSFeed } from "@/api/rss-parser"
|
||||
import { handleAPISource, handleCustomSource, handleRSSSource } from "@/api/source-handler"
|
||||
|
||||
export const fetchEpisodes = async (feedUrl: string): Promise<Episode[]> => {
|
||||
try {
|
||||
const response = await fetch(feedUrl)
|
||||
if (!response.ok) return []
|
||||
const xml = await response.text()
|
||||
return parseRSSFeed(xml, feedUrl).episodes
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchFeeds = async (
|
||||
sourceIds: string[],
|
||||
sources: PodcastSource[]
|
||||
): Promise<Feed[]> => {
|
||||
const active = sources.filter((source) => sourceIds.includes(source.id))
|
||||
const feeds: Feed[] = []
|
||||
|
||||
await Promise.all(
|
||||
active.map(async (source) => {
|
||||
try {
|
||||
if (source.type === "rss") {
|
||||
const rssFeeds = await handleRSSSource(source)
|
||||
feeds.push(...rssFeeds)
|
||||
} else if (source.type === "api") {
|
||||
const apiFeeds = await handleAPISource(source, "")
|
||||
feeds.push(...apiFeeds)
|
||||
} else {
|
||||
const customFeeds = await handleCustomSource(source, "")
|
||||
feeds.push(...customFeeds)
|
||||
}
|
||||
} catch {
|
||||
// ignore individual source errors
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return feeds
|
||||
}
|
||||
|
||||
export const searchPodcasts = async (
|
||||
query: string,
|
||||
sources: PodcastSource[]
|
||||
): Promise<Podcast[]> => {
|
||||
const results: Podcast[] = []
|
||||
await Promise.all(
|
||||
sources.map(async (source) => {
|
||||
try {
|
||||
if (source.type === "rss") {
|
||||
const feeds = await handleRSSSource(source)
|
||||
results.push(...feeds.map((feed: Feed) => feed.podcast))
|
||||
} else if (source.type === "api") {
|
||||
const feeds = await handleAPISource(source, query)
|
||||
results.push(...feeds.map((feed: Feed) => feed.podcast))
|
||||
} else {
|
||||
const feeds = await handleCustomSource(source, query)
|
||||
results.push(...feeds.map((feed: Feed) => feed.podcast))
|
||||
}
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return results
|
||||
}
|
||||
53
src/api/rss-parser.ts
Normal file
53
src/api/rss-parser.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import type { Episode } from "../types/episode"
|
||||
|
||||
const getTagValue = (xml: string, tag: string): string => {
|
||||
const match = xml.match(new RegExp(`<${tag}[^>]*>([\s\S]*?)</${tag}>`, "i"))
|
||||
return match?.[1]?.trim() ?? ""
|
||||
}
|
||||
|
||||
const decodeEntities = (value: string) =>
|
||||
value
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
|
||||
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
|
||||
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
|
||||
const title = decodeEntities(getTagValue(channel, "title")) || "Untitled Podcast"
|
||||
const description = decodeEntities(getTagValue(channel, "description"))
|
||||
const author = decodeEntities(getTagValue(channel, "itunes:author"))
|
||||
const lastUpdated = new Date()
|
||||
|
||||
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
|
||||
const episodes = items.map((item, index) => {
|
||||
const epTitle = decodeEntities(getTagValue(item, "title")) || `Episode ${index + 1}`
|
||||
const epDescription = decodeEntities(getTagValue(item, "description"))
|
||||
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
|
||||
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
|
||||
const audioUrl = enclosure?.[1] ?? ""
|
||||
|
||||
return {
|
||||
id: `${feedUrl}#${index}`,
|
||||
podcastId: feedUrl,
|
||||
title: epTitle,
|
||||
description: epDescription,
|
||||
audioUrl,
|
||||
duration: 0,
|
||||
pubDate,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id: feedUrl,
|
||||
title,
|
||||
description,
|
||||
author,
|
||||
feedUrl,
|
||||
lastUpdated,
|
||||
isSubscribed: true,
|
||||
episodes,
|
||||
}
|
||||
}
|
||||
94
src/api/source-handler.ts
Normal file
94
src/api/source-handler.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { FeedVisibility } from "../types/feed"
|
||||
import type { Feed } from "../types/feed"
|
||||
import type { PodcastSource } from "../types/source"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import { parseRSSFeed } from "./rss-parser"
|
||||
|
||||
const buildFeedFromPodcast = (podcast: Podcast, sourceId: string): Feed => {
|
||||
return {
|
||||
id: `${sourceId}-${podcast.id}`,
|
||||
podcast,
|
||||
episodes: [],
|
||||
visibility: FeedVisibility.PUBLIC,
|
||||
sourceId,
|
||||
lastUpdated: new Date(),
|
||||
isPinned: false,
|
||||
}
|
||||
}
|
||||
|
||||
export const handleRSSSource = async (source: PodcastSource): Promise<Feed[]> => {
|
||||
if (!source.baseUrl) return []
|
||||
const response = await fetch(source.baseUrl)
|
||||
if (!response.ok) return []
|
||||
const xml = await response.text()
|
||||
const parsed = parseRSSFeed(xml, source.baseUrl)
|
||||
return [
|
||||
{
|
||||
id: `${source.id}-${parsed.feedUrl}`,
|
||||
podcast: {
|
||||
id: parsed.id,
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
feedUrl: parsed.feedUrl,
|
||||
author: parsed.author,
|
||||
categories: parsed.categories,
|
||||
lastUpdated: parsed.lastUpdated,
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes: parsed.episodes,
|
||||
visibility: FeedVisibility.PUBLIC,
|
||||
sourceId: source.id,
|
||||
lastUpdated: parsed.lastUpdated,
|
||||
isPinned: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const handleAPISource = async (
|
||||
source: PodcastSource,
|
||||
query: string
|
||||
): Promise<Feed[]> => {
|
||||
const url = new URL(source.baseUrl || "https://itunes.apple.com/search")
|
||||
url.searchParams.set("term", query || "podcast")
|
||||
url.searchParams.set("media", "podcast")
|
||||
url.searchParams.set("entity", "podcast")
|
||||
url.searchParams.set("country", source.country || "US")
|
||||
url.searchParams.set("lang", source.language || "en_us")
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
if (!response.ok) return []
|
||||
const data = (await response.json()) as { results?: Array<{ collectionId?: number; collectionName?: string; feedUrl?: string; artistName?: string }> }
|
||||
const results = data.results ?? []
|
||||
|
||||
return results
|
||||
.filter((item) => item.collectionName && item.feedUrl)
|
||||
.map((item) => {
|
||||
const podcast: Podcast = {
|
||||
id: item.collectionId ? `itunes-${item.collectionId}` : `${source.id}-${item.collectionName}`,
|
||||
title: item.collectionName || "Untitled Podcast",
|
||||
description: item.collectionName || "",
|
||||
feedUrl: item.feedUrl || "",
|
||||
author: item.artistName,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
}
|
||||
return buildFeedFromPodcast(podcast, source.id)
|
||||
})
|
||||
}
|
||||
|
||||
export const handleCustomSource = async (
|
||||
source: PodcastSource,
|
||||
query: string
|
||||
): Promise<Feed[]> => {
|
||||
if (!query) return []
|
||||
const podcast: Podcast = {
|
||||
id: `${source.id}-${query.toLowerCase().replace(/\s+/g, "-")}`,
|
||||
title: `${query} Highlights`,
|
||||
description: `Curated results for ${query}`,
|
||||
feedUrl: source.baseUrl || "",
|
||||
author: source.name,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
}
|
||||
return [buildFeedFromPodcast(podcast, source.id)]
|
||||
}
|
||||
Reference in New Issue
Block a user