UNPKG

podcastindexjs

Version:

client-side JS library for interacting with the Podcast Index API

335 lines (327 loc) 11.7 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var ramda = require('ramda'); // eslint-disable-next-line @typescript-eslint/no-namespace var ApiResponse; (function (ApiResponse) { (function (Status) { Status["Success"] = "true"; })(ApiResponse.Status || (ApiResponse.Status = {})); (function (NewFeedStatus) { NewFeedStatus["Confirmed"] = "confirmed"; NewFeedStatus["Success"] = "true"; NewFeedStatus["Pending"] = "pending"; })(ApiResponse.NewFeedStatus || (ApiResponse.NewFeedStatus = {})); })(ApiResponse || (ApiResponse = {})); var PodcastFeedType; (function (PodcastFeedType) { PodcastFeedType[PodcastFeedType["RSS"] = 0] = "RSS"; PodcastFeedType[PodcastFeedType["ATOM"] = 1] = "ATOM"; })(PodcastFeedType || (PodcastFeedType = {})); var types = /*#__PURE__*/Object.freeze({ __proto__: null, get ApiResponse () { return ApiResponse; }, get PodcastFeedType () { return PodcastFeedType; } }); function isDate(d) { return Object.prototype.hasOwnProperty.call(d, "getUTCMilliseconds"); } function toEpochTimestamp(date) { if (!date) { return undefined; } const ts = isDate(date) ? date.getTime() : date; const asDate = new Date(ts); if (asDate.getFullYear() > 2003) { return Math.floor(ts / 1000); } return ts; } function normalizeKey(fn, key, obj) { const val = fn(obj[key]); return { ...obj, [key]: val, }; } const clientUserAgent = "podcastindexjs"; const apiVersion = "1.0"; function encodeObjectToQueryString(qs) { if (!qs) { return null; } return Object.entries(qs) .map(([key, val]) => { if (!val) { return null; } if (Array.isArray(val)) { return `${key}[]=${val.map((v) => encodeURI(`${v}`)).join(",")}`; } if (val === true) { return key; } return `${key}=${encodeURI(`${val}`)}`; }) .filter((x) => x) .join("&"); } class PodcastIndexClient { apiUrl = `https://api.podcastindex.org/api/1.0`; userAgent = clientUserAgent; version = apiVersion; key; secret; constructor(key, secret) { if (!key || !secret) { throw new Error("Unable to initialize due to missing key or secret"); } this.key = key; this.secret = secret; } async hexDigestMessage(message) { const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array const hashBuffer = await crypto.subtle.digest("SHA-1", msgUint8); // hash the message const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); } async generateHeaders() { if (!this.key || !this.secret) { throw new Error("Missing key or secret"); } const apiHeaderTime = Math.floor(Date.now() / 1000); const data4Hash = this.key + this.secret + apiHeaderTime; const hash4Header = await this.hexDigestMessage(data4Hash); return { "Content-Type": "application/json", "X-Auth-Date": `${apiHeaderTime}`, "X-Auth-Key": this.key, Authorization: hash4Header, "User-Agent": `${this.userAgent}/${this.version}`, }; } async fetch(endpoint, qs) { const queryString = qs ? encodeObjectToQueryString(qs) : null; const options = { method: `GET`, headers: await this.generateHeaders(), }; const url = `${this.apiUrl}${endpoint}${queryString ? `?${queryString}` : ``}`; return fetch(url, options).then((res) => { if (res.status >= 200 && res.status < 300) { return res.json(); } throw new Error(res.statusText); }); } /** * Make a raw request to podcast index. This is an escape hatch for leveraging the auth handling in the client * but managing the calls and responses yourself. * Example: * client.raw("/podcasts/byfeedid?id=75075"); * client.raw("/podcasts/byfeedid", { id: 75075 }); * * @param endpoint * @param qs */ async raw(endpoint, qs) { return await this.fetch(endpoint, qs); } // #region Search /** * List all categories * * @param query search query */ async categories() { return await this.fetch("/categories/list"); } // #endregion // #region Search /** * This call returns all of the feeds that match the search terms in the title, author, or owner of the feed. * This is ordered by the last-released episode, with the latest at the top of the results. * * @param query search query */ async search(query, options = {}) { return await this.fetch("/search/byterm", { q: query, max: options.max ?? 25, clean: Boolean(options.clean), fulltext: Boolean(options.fulltext), }); } /** * This call returns all of the episodes where the specified person is mentioned. * * @param query search query */ async searchPerson(query, options = {}) { return await this.fetch("/search/byperson", { q: query, fulltext: Boolean(options.fulltext), }); } // #endregion // #region Recent /** * This call returns the most recent [max] number of episodes globally across the whole index, in reverse chronological order. Max of 1000 */ async recentEpisodes(options = {}) { return await this.fetch("/recent/episodes", { ...options, max: options.max ?? 10, }); } /** * This call returns the most recently feeds in reverse chronological order. * * @param options additional api options */ async recentFeeds(options = {}) { const apiOptions = { max: options.max ?? 40, ...ramda.pick(["since"], options), }; if (options.lang) { if (Array.isArray(options.lang)) { apiOptions.lang = options.lang.join(","); } else { apiOptions.lang = options.lang; } } if (options.notCategory) { apiOptions.notcat = Array.isArray(options.notCategory) ? options.notCategory.join(",") : options.notCategory; } if (options.category) { apiOptions.cat = Array.isArray(options.category) ? options.category.join(",") : options.category; } const result = await this.fetch("/recent/feeds", apiOptions); return { ...result, feeds: result.feeds .map((feed) => { if (!feed.categories) { return { ...feed, categories: {} }; } return feed; }) .map((feed) => normalizeKey((lang) => lang.toLowerCase(), "language", feed)), }; } /** * This call returns every new feed added to the index over the past 24 hours in reverse chronological order. Max of 1000 * * @param options */ async recentNewFeeds(options = {}) { return await this.fetch("/recent/newfeeds", { max: options.max ?? 10, }); } /** * The most recent 60 soundbites that the index has discovered */ async recentSoundbites() { return await this.fetch("/recent/soundbites"); } // #endregion // #region Podcasts /** This call returns everything we know about the feed. */ async podcastByUrl(url) { const result = await this.fetch("/podcasts/byfeedurl", { url }); if (!result.feed.categories) { result.feed.categories = {}; } return result; } /** This call returns everything we know about the feed. */ async podcastById(id) { const result = await this.fetch("/podcasts/byfeedid", { id }); if (!result.feed.categories) { result.feed.categories = {}; } return result; } /** If we have an itunes id on file for a feed, then this call returns everything we know about that feed. */ async podcastByItunesId(id) { const result = await this.fetch("/podcasts/byitunesid", { id }); if (!result.feed.categories) { result.feed.categories = {}; } return result; } // #endregion // #region Episodes /** This call returns all the episodes we know about for this feed, in reverse chronological order. */ async episodesByFeedUrl(url, options = {}) { const { since, ...rest } = options; return await this.fetch("/episodes/byfeedurl", { ...rest, since: toEpochTimestamp(since), url, }); } /** * This call returns all the episodes we know about for this feed, in reverse chronological order. * Note: The id parameter is the internal Podcastindex id for this feed. */ async episodesByFeedId(id, options = {}) { const { since, ...rest } = options; const parsedId = Array.isArray(id) ? id.join(",") : id; return await this.fetch("/episodes/byfeedid", { ...rest, since: toEpochTimestamp(since), id: parsedId, }); } /** * If we have an itunes id on file for a feed, then this call returns all the episodes we know about for the feed, in reverse chronological order. * Note: The itunes id parameter can either be the number alone, or be prepended with “id”. */ async episodesByItunesId(id, options = {}) { const { since, ...rest } = options; return await this.fetch("/episodes/byitunesid", { ...rest, since: toEpochTimestamp(since), id, }); } /** * This call returns a random batch of [max] episodes, in no specific order. * * Note: If no [max] is specified, the default is 1. You can return up to 40 episodes at a time. * Note: Language and category names are case-insensitive. * Note: You can mix and match the cat and notcat filters to fine tune a very specific result set. */ async episodesRandom(options = {}) { const parsedOptions = options.max ? { max: options.max } : {}; parsedOptions.lang = Array.isArray(options.lang) ? options.lang.join(",") : options.lang; parsedOptions.cat = Array.isArray(options.cat) ? options.cat.join(",") : options.cat; parsedOptions.notcat = Array.isArray(options.notcat) ? options.notcat.join(",") : options.notcat; return await this.fetch("/episodes/random", parsedOptions); } /** Get all the metadata for a single episode by passing its id. */ async episodeById(id, options = {}) { return await this.fetch("/episodes/byid", { id, ...options, }); } // #endregion async stats() { return await this.fetch("/stats/current"); } } exports.PodcastIndexClient = PodcastIndexClient; exports.Types = types;