UNPKG

podcastsuite

Version:

A set of utilities to work with Podcasts

430 lines (394 loc) 11.9 kB
import xml2js from "xml2js"; import DB, { DBInstance } from "./DB"; import StreamReader from "./StreamReader"; import Format, { IEpisode } from "./Format"; export interface IPodcastSuiteConfig { proxy?: IProxy; podcasts?: string[]; fresh?: number; fetchEngine?: Function; shouldInit?: boolean; } export interface IProxy { "https:": string; "http:": string; } export interface IPodcast { title?: string; description?: string; url: string; link?: string; author?: string; category?: string; explicit?: string; summary?: string; copyright?: string; language?: string; image?: string; items?: IEpisode[]; created: number; length?: number; } export interface IRSS { rss: { [key: string]: any; }; } export const PROXY: IProxy = { "https:": "/rss-pg/", "http:": "/rss-less-pg/", }; const REQCONFIG = { headers: { "User-Agent": "podcastsuite", Accept: "application/rss+xml", }, }; const FRESH = 3600000; class PodcastSuite { /* Receives a URL and IProxy? object and return a URL String prefixed with the proxy path. @param url: URL object of the Podcast RSS URL @param porxy: IProxy object that contain urls that need to be prefixed to any URL. @return string url. */ public static proxyURL(url: URL, proxy: IProxy): string { const { protocol, hostname, pathname, search, hash } = url; return `${proxy[protocol]}${hostname}${pathname}${search}${hash}`; } /* Recieves String with "RSS" content it returns a promise with either a an error if it couldn't be parsed or a RSS JSON Object. @param content: string, string to be parsed. @return Promise with Error Object or JSON Object. */ public static parser(content: string): Promise<IRSS> { return new Promise((accept, reject) => { try { PodcastSuite.parserEngine(content, (err, result) => { if (err) { reject({ error: true, err }); } accept(result); }); } catch (error) { reject(error); } }); } /* A function that creates a DB using indexDB by default uses podcastsuite values. System used internally, exposed to reused by clients. @param Table name string @param Databse name stirng @return DBInstance an object containing set,get,keys,delete and entries. */ public static createDatabase(table: string, database: string): DBInstance { return DB(table, database); } /* fetch function that request RSS URL and Parse it into an object. @param fetch:URL object with the RSS path. @param config?: { proxy: IPRoxy, signal } @return Object Error Object or Parsed RSS Object */ public static fetch( podcastURL: URL, config: { proxy?: IProxy; signal?; fetchEngine? } = {} ): Promise<IPodcast> { const { proxy, signal, fetchEngine = fetch } = config; const podcastProxyURL = proxy ? PodcastSuite.proxyURL(podcastURL, proxy) : podcastURL; const url = podcastURL.toString(); let length = 0; let etag = 0; return new Promise((accept, reject) => { fetchEngine(podcastProxyURL.toString(), { signal, method: "GET", ...REQCONFIG, }) .then((rawresponse) => { if (!rawresponse.ok) { throw new Error("Bad response: " + rawresponse.status); } length = Number(rawresponse.headers.get("content-length")); etag = Number(rawresponse.headers.get("etag")); return rawresponse.text(); }) .then(PodcastSuite.parser) .then((rss: IRSS) => Promise.resolve(PodcastSuite.format(rss, { etag, length, url })) ) .then(accept) .catch(reject); }); } /* fetch function that request Content URL and Parse it into a Blob object @param fetch:URL object with the RSS path. @param config?: { proxy: IPRoxy, signal } @return Object Error Object or Blob Object */ public static fetchContent( contentURL: URL, config: { proxy?: IProxy; signal?; progress?: () => any; fetchEngine? } = {} ): any { const { proxy, signal, progress, fetchEngine = fetch } = config; const contentProxyURL: string = proxy ? PodcastSuite.proxyURL(contentURL, proxy) : contentURL.toString(); return fetchEngine(contentProxyURL, { method: "GET", signal }) .then(StreamReader(progress)) .then((raw) => raw.blob()); } /* fetch function that request RSS URL and get the size of the RSS. @param fetch:URL object with the RSS path. */ public static async fetchSize( url: URL, config?: { proxy?: IProxy; signal?; fetchEngine? } ) { const { proxy, signal, fetchEngine = fetch } = config; const podcastURL = proxy ? PodcastSuite.proxyURL(url, proxy) : url; try { const response = await fetchEngine(podcastURL.toString(), { signal, method: "HEAD", ...REQCONFIG, }); return Number(response.headers.get("content-length")); } catch (error) { return null; } } /* Validate if a Podcast is Fresh @param podcast: IPodcast @return Boleean */ private static isFresh( podcast: IPodcast, config: { fresh?: number; length?: number } ): boolean { if (config.fresh && Date.now() - podcast.created < config.fresh) { return true; } if (config.length && config.length === podcast.length) { return true; } return false; } /* parseEngine: Parser Engine use to convert Response String into RSS Outouts */ private static parserEngine = new xml2js.Parser({ trim: false, normalize: true, mergeAttrs: true, }).parseString; /* Convert IRSS Object into IPodcast Object @param json: IRSS obect to be converted into IPodcast Ojbect @return IPodcast Object */ public static format = Format; /* Get Podcast Data from DB, if it doesn't exist it will fetch it. @param key: string with url @param? latest: if true forces to ignore memory and get the latest version available. @return IPodcast Object @throw Invalid URL */ public async getPodcast( key: string, config: { latest?: boolean; save?: boolean; fresh?: number } = {} ) { const { latest = false, save = true, fresh = this.fresh } = config; try { const podcast = new URL(key); return latest && !fresh ? this.refreshURL(podcast, save) : this.requestURL(podcast, { save, fresh }); } catch (e) { throw "Not a Valid URL"; } } /* Get Content Data from DB, if it doesn't exist it will fetch it. @param key: string with url @param? refresh: if true forces to ignore memory and get the latest version available. @return Blob Object @throw Invalid URL */ public async getContent(contentURL: URL, config: { refresh?: boolean } = {}) { const contentFromMemory = await PodcastSuite.contentDB.get( contentURL.toJSON() ); const { refresh = false } = config; if (contentFromMemory && !refresh) { return contentFromMemory; } try { const fetchedContent = await PodcastSuite.fetchContent(contentURL, { proxy: this.proxy, fetchEngine: this.fetchEngine, }); await PodcastSuite.contentDB.set(contentURL.toJSON(), fetchedContent); return fetchedContent; } catch (error) { console.error(error); throw "Error Retriving Content"; } } /* Get All Keys from Existing DB/Library @return promise with all keys in library */ public async getLibrary() { return await PodcastSuite.db.keys(); } /* Get all entries on library and return */ public async canUpdateLibrary() { const library = await PodcastSuite.db.entries(); const libraryRequest = library.map(async (podcast) => { const [url, content] = podcast; const urlObject = new URL(url); const { length, etag } = content; const webSize = await PodcastSuite.fetchSize(urlObject, { proxy: this.proxy, fetchEngine: this.fetchEngine, }); return [url, { ...podcast, readyforUpdate: !(webSize !== length) }]; }); return (await Promise.all(libraryRequest)).filter((request) => { const [, data] = request; return data.readyforUpdate; }); } /* Get All Keys from Existing DB/Library @fn function recevies value of key and modifies it. @return Promise with an array of all values modified by callback */ public async mapLibraryEntries(fn: (value: IPodcast) => any) { const entries = await PodcastSuite.db.entries(); const results: IPodcast[] = entries.map(fn); return results; } /* Get All Keys from Existing DB/Library @fn function recevies value of key and modifies it. @return Promise with an array of all values modified by callback */ public async mapLibrary(fn: (value: any) => any) { const keys = await PodcastSuite.db.keys(); return Promise.allSettled(keys.map(fn)); } /* Request URL if it exist in Memory it executes FN with response, after if there is an update executes the same FN with the new response. @param fetch:URL object with the RSS path. @return null. */ private async requestURL( podcastURL: URL, config: { fn?: (data: IPodcast) => any; fresh?: number; save?: boolean; } = {} ) { const podcastFromMemory: IPodcast | null = await PodcastSuite.db.get( podcastURL.toJSON() ); const { fn = null, fresh = this.fresh, save = true } = config; if (podcastFromMemory) { if (fn) { fn(podcastFromMemory); } if (PodcastSuite.isFresh(podcastFromMemory, { fresh })) { return podcastFromMemory; } else { const length = await PodcastSuite.fetchSize(podcastURL, { proxy: this.proxy, fetchEngine: this.fetchEngine, }); if (PodcastSuite.isFresh(podcastFromMemory, { length })) { return podcastFromMemory; } } } const podcastFromWeb: IPodcast = await this.refreshURL(podcastURL, save); if (fn) { fn(podcastFromWeb); } return podcastFromWeb; } /* Request URL And force refresh @param fetch:URL object with the RSS path. @return Promise<IPodcast>. */ private async refreshURL( podcastURL: URL, save: boolean = true ): Promise<IPodcast> { let podcastFromWeb: IPodcast; try { podcastFromWeb = await PodcastSuite.fetch(podcastURL, { proxy: this.proxy, fetchEngine: this.fetchEngine, }); if (save) { await PodcastSuite.db.set(podcastURL.toJSON(), podcastFromWeb); } } catch (error) { throw new Error(error); } return podcastFromWeb; } /* Initialize Library based on provided podcast URL. @param ikeys:string[] */ private async init(iKeys: string[]) { const dbKeys = await PodcastSuite.db.keys(); const keys = Array.from(new Set([...iKeys, ...dbKeys])); const request = keys.map((podcast) => this.requestURL(new URL(podcast.toString()), { fn: () => null, fresh: this.fresh, }) ); const completed = await Promise.allSettled(request); return !!completed; } protected static db = DB(); protected static contentDB = DB("content"); private proxy: IProxy; private fresh: number; private fetchEngine: Function; public ready: Promise<Boolean>; constructor(config: IPodcastSuiteConfig = {}) { const { podcasts = [], proxy, fresh = FRESH, fetchEngine = fetch, shouldInit = true, } = config; this.proxy = proxy; this.fresh = fresh; this.fetchEngine = fetchEngine; this.ready = shouldInit ? this.init(podcasts) : (this.ready = Promise.resolve(true)); } } export default PodcastSuite;