feat(feed): bound feed lifecycle to 30-day window with nonblocking refresh
Persisted feeds keep only episodes from the last 30 days (plus completed downloads); older episodes live in volatile memory and survive refreshes via union merge, with per-feed in-memory caches capped at 500. Refresh batches run at FETCH_CONCURRENCY=4 with per-feed incremental apply (no Promise.all barrier), config.json writes are trailing-edge debounced (250ms, immediate flushPendingSave for unsubscribes), and cold fetch-more refetches abort at FETCH_TIMEOUT_MS. A shared activity store powers a global top-right indicator covering refresh, fetch-more, subscribe, search, and downloads. Also includes the in-flight incremental RSS parsing (chunked with event-loop yields) and refresh spinner work this tree already carried.
This commit is contained in:
@@ -74,6 +74,72 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Extract the `<item>` blocks from an RSS document (the sync part of
|
||||
* parsing is bounded to this single regex pass). Exported so the feed store
|
||||
* can parse episodes incrementally without re-deriving item boundaries. */
|
||||
export const getRSSItems = (xml: string): string[] => {
|
||||
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
|
||||
return channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
|
||||
}
|
||||
|
||||
/** Parse a single `<item>` into an Episode. Exported so the feed store can
|
||||
* parse large feeds in bounded chunks (yielding to the event loop between
|
||||
* chunks) instead of one synchronous block. */
|
||||
export const parseRSSItem = (item: string, feedUrl: string, index: number): Episode => {
|
||||
const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}`
|
||||
const epDescription = cleanField(getTagValue(item, "description"))
|
||||
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
|
||||
|
||||
// Audio URL + file size + MIME type from <enclosure>
|
||||
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
|
||||
const audioUrl = enclosure?.[1] ?? ""
|
||||
const fileSizeStr = getAttr(item, "enclosure", "length")
|
||||
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
|
||||
const mimeType = getAttr(item, "enclosure", "type") || undefined
|
||||
|
||||
// Duration from <itunes:duration>
|
||||
const durationRaw = getTagValue(item, "itunes:duration")
|
||||
const duration = parseDuration(durationRaw)
|
||||
|
||||
// Episode & season numbers
|
||||
const episodeNumRaw = getTagValue(item, "itunes:episode")
|
||||
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
|
||||
const seasonNumRaw = getTagValue(item, "itunes:season")
|
||||
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
|
||||
|
||||
// Episode type & explicit
|
||||
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
|
||||
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
|
||||
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
|
||||
|
||||
// Episode image (itunes:image has href attribute)
|
||||
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
|
||||
|
||||
const ep: Episode = {
|
||||
id: `${feedUrl}#${index}`,
|
||||
podcastId: feedUrl,
|
||||
title: epTitle,
|
||||
description: epDescription,
|
||||
audioUrl,
|
||||
duration,
|
||||
pubDate,
|
||||
}
|
||||
|
||||
// Only set optional fields if present
|
||||
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
|
||||
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
|
||||
if (episodeType) ep.episodeType = episodeType
|
||||
if (explicit !== undefined) ep.explicit = explicit
|
||||
if (imageUrl) ep.imageUrl = imageUrl
|
||||
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
|
||||
if (mimeType) ep.mimeType = mimeType
|
||||
|
||||
return ep
|
||||
}
|
||||
|
||||
/** Parse a full RSS document (channel metadata + all episodes). The sync
|
||||
* whole-feed variant — callers that parse potentially huge feeds on a UI
|
||||
* thread should prefer the store's chunked incremental parse instead. */
|
||||
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
|
||||
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
|
||||
const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast"
|
||||
@@ -81,58 +147,8 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
|
||||
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 = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}`
|
||||
const epDescription = cleanField(getTagValue(item, "description"))
|
||||
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
|
||||
|
||||
// Audio URL + file size + MIME type from <enclosure>
|
||||
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
|
||||
const audioUrl = enclosure?.[1] ?? ""
|
||||
const fileSizeStr = getAttr(item, "enclosure", "length")
|
||||
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
|
||||
const mimeType = getAttr(item, "enclosure", "type") || undefined
|
||||
|
||||
// Duration from <itunes:duration>
|
||||
const durationRaw = getTagValue(item, "itunes:duration")
|
||||
const duration = parseDuration(durationRaw)
|
||||
|
||||
// Episode & season numbers
|
||||
const episodeNumRaw = getTagValue(item, "itunes:episode")
|
||||
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
|
||||
const seasonNumRaw = getTagValue(item, "itunes:season")
|
||||
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
|
||||
|
||||
// Episode type & explicit
|
||||
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
|
||||
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
|
||||
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
|
||||
|
||||
// Episode image (itunes:image has href attribute)
|
||||
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
|
||||
|
||||
const ep: Episode = {
|
||||
id: `${feedUrl}#${index}`,
|
||||
podcastId: feedUrl,
|
||||
title: epTitle,
|
||||
description: epDescription,
|
||||
audioUrl,
|
||||
duration,
|
||||
pubDate,
|
||||
}
|
||||
|
||||
// Only set optional fields if present
|
||||
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
|
||||
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
|
||||
if (episodeType) ep.episodeType = episodeType
|
||||
if (explicit !== undefined) ep.explicit = explicit
|
||||
if (imageUrl) ep.imageUrl = imageUrl
|
||||
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
|
||||
if (mimeType) ep.mimeType = mimeType
|
||||
|
||||
return ep
|
||||
})
|
||||
const items = getRSSItems(xml)
|
||||
const episodes = items.map((item, index) => parseRSSItem(item, feedUrl, index))
|
||||
|
||||
return {
|
||||
id: feedUrl,
|
||||
|
||||
Reference in New Issue
Block a user