working playback
This commit is contained in:
@@ -1,11 +1,19 @@
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import type { Episode } from "../types/episode"
|
||||
import type { Episode, EpisodeType } from "../types/episode"
|
||||
|
||||
const getTagValue = (xml: string, tag: string): string => {
|
||||
const match = xml.match(new RegExp(`<${tag}[^>]*>([\s\S]*?)</${tag}>`, "i"))
|
||||
const match = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i"))
|
||||
return match?.[1]?.trim() ?? ""
|
||||
}
|
||||
|
||||
/** Get an attribute value from a self-closing or open tag */
|
||||
const getAttr = (xml: string, tag: string, attr: string): string => {
|
||||
const tagMatch = xml.match(new RegExp(`<${tag}[^>]*>`, "i"))
|
||||
if (!tagMatch) return ""
|
||||
const attrMatch = tagMatch[0].match(new RegExp(`${attr}\\s*=\\s*["']([^"']*)["']`, "i"))
|
||||
return attrMatch?.[1] ?? ""
|
||||
}
|
||||
|
||||
const decodeEntities = (value: string) =>
|
||||
value
|
||||
.replace(/</g, "<")
|
||||
@@ -14,6 +22,42 @@ const decodeEntities = (value: string) =>
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
|
||||
/**
|
||||
* Parse an itunes:duration value which can be:
|
||||
* - "HH:MM:SS"
|
||||
* - "MM:SS"
|
||||
* - seconds as a plain number string (e.g. "1234")
|
||||
* Returns duration in seconds, or 0 if unparseable.
|
||||
*/
|
||||
const parseDuration = (raw: string): number => {
|
||||
if (!raw) return 0
|
||||
const trimmed = raw.trim()
|
||||
|
||||
// Pure numeric (seconds)
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
return parseInt(trimmed, 10)
|
||||
}
|
||||
|
||||
// HH:MM:SS or MM:SS
|
||||
const parts = trimmed.split(":").map(Number)
|
||||
if (parts.some(isNaN)) return 0
|
||||
if (parts.length === 3) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2]
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return parts[0] * 60 + parts[1]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const parseEpisodeType = (raw: string): EpisodeType | undefined => {
|
||||
const lower = raw.trim().toLowerCase()
|
||||
if (lower === "trailer") return "trailer" as EpisodeType
|
||||
if (lower === "bonus") return "bonus" as EpisodeType
|
||||
if (lower === "full") return "full" as EpisodeType
|
||||
return undefined
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -26,18 +70,52 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
|
||||
const epTitle = decodeEntities(getTagValue(item, "title")) || `Episode ${index + 1}`
|
||||
const epDescription = decodeEntities(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
|
||||
|
||||
return {
|
||||
// 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: 0,
|
||||
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
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user