podcastsuite
Version:
A set of utilities to work with Podcasts
430 lines (394 loc) • 11.9 kB
text/typescript
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;