diff --git a/package.json b/package.json
index 72455ac19..d387e3456 100644
--- a/package.json
+++ b/package.json
@@ -54,12 +54,12 @@
"electron-notarize": "^1.0.0",
"electron-updater": "^4.2.4",
"express": "^4.17.1",
- "feed": "^4.2.2",
"if-env": "^1.0.4",
"react-datetime-picker": "^3.2.1",
"react-plastic": "^1.1.1",
"react-top-loading-bar": "^2.0.1",
"remove-markdown": "^0.3.0",
+ "rss": "^1.2.2",
"source-map-explorer": "^2.5.2",
"tempy": "^0.6.0",
"videojs-contrib-ads": "^6.9.0",
diff --git a/web/src/routes.js b/web/src/routes.js
index d41d1a753..d3e91f631 100644
--- a/web/src/routes.js
+++ b/web/src/routes.js
@@ -19,9 +19,11 @@ function getStreamUrl(ctx) {
}
const rssMiddleware = async (ctx) => {
- const xml = await getRss(ctx);
- ctx.set('Content-Type', 'application/rss+xml');
- ctx.body = xml;
+ const rss = await getRss(ctx);
+ if (rss.startsWith(' {
diff --git a/web/src/rss.js b/web/src/rss.js
index 98684ab89..9bd1a07b7 100644
--- a/web/src/rss.js
+++ b/web/src/rss.js
@@ -1,7 +1,7 @@
const { generateDownloadUrl } = require('../../ui/util/web');
-const { URL, SITE_NAME, LBRY_WEB_API, FAVICON } = require('../../config.js');
+const { URL, SITE_NAME, LBRY_WEB_API } = require('../../config.js');
const { Lbry } = require('lbry-redux');
-const Feed = require('feed').Feed;
+const Rss = require('rss');
const SDK_API_PATH = `${LBRY_WEB_API}/api/v1`;
const proxyURL = `${SDK_API_PATH}/proxy`;
@@ -9,6 +9,10 @@ Lbry.setDaemonConnectionString(proxyURL);
const NUM_ENTRIES = 500;
+// ****************************************************************************
+// Fetch claim info
+// ****************************************************************************
+
async function doClaimSearch(options) {
let results;
try {
@@ -19,15 +23,21 @@ async function doClaimSearch(options) {
async function getChannelClaim(name, claimId) {
let claim;
+ let error;
+
try {
const url = `lbry://${name}#${claimId}`;
const response = await Lbry.resolve({ urls: [url] });
-
if (response && response[url] && !response[url].error) {
claim = response && response[url];
}
} catch {}
- return claim || 'The RSS URL is invalid or is not associated with any channel.';
+
+ if (!claim) {
+ error = 'The RSS URL is invalid or is not associated with any channel.';
+ }
+
+ return { claim, error };
}
async function getClaimsFromChannel(claimId, count) {
@@ -43,103 +53,218 @@ async function getClaimsFromChannel(claimId, count) {
return await doClaimSearch(options);
}
-async function getFeed(channelClaim, feedLink) {
- const replaceLineFeeds = (str) => str.replace(/(?:\r\n|\r|\n)/g, '
');
+// ****************************************************************************
+// Helpers
+// ****************************************************************************
- const fmtDescription = (description) => replaceLineFeeds(description);
+const generateEnclosureForClaimContent = (claim) => {
+ const value = claim.value;
+ if (!value || !value.stream_type) {
+ return undefined;
+ }
- const sanitizeThumbsUrl = (url) => {
- if (typeof url === 'string' && url.startsWith('https://')) {
- return encodeURI(url).replace(/&/g, '%26');
- }
- return '';
- };
+ switch (value.stream_type) {
+ case 'video':
+ case 'audio':
+ case 'image':
+ case 'document':
+ case 'software':
+ return {
+ url: generateDownloadUrl(claim.name, claim.claim_id),
+ type: (value.source && value.source.media_type) || undefined,
+ size: (value.source && value.source.size) || 0, // Per spec, 0 is a valid fallback.
+ };
- const getEnclosure = (claim) => {
- const value = claim.value;
- if (!value || !value.stream_type || !value.source || !value.source.media_type) {
+ default:
return undefined;
+ }
+};
+
+const getLanguageValue = (claim) => {
+ if (claim && claim.value && claim.value.languages && claim.value.languages.length > 0) {
+ return claim.value.languages[0];
+ }
+ return 'en';
+};
+
+const replaceLineFeeds = (str) => str.replace(/(?:\r\n|\r|\n)/g, '
');
+
+const isEmailRoughlyValid = (email) => /^\S+@\S+$/.test(email);
+
+/**
+ * 'itunes:owner' is required by castfeedvalidator (w3c allows omission), and
+ * both name and email must be defined. The email must also be a "valid" one.
+ *
+ * Use a fallback email when the creator did not specify one. The email will not
+ * be shown to the user; it is just used for administrative purposes.
+ *
+ * @param claim
+ * @returns any
+ */
+const generateItunesOwnerElement = (claim) => {
+ let name = '---';
+ let email = 'no-reply@odysee.com';
+
+ if (claim && claim.value) {
+ name = claim.name;
+ if (isEmailRoughlyValid(claim.value.email)) {
+ email = claim.value.email;
}
+ }
- switch (value.stream_type) {
- case 'video':
- case 'audio':
- case 'image':
- case 'document':
- case 'software':
- return {
- url: encodeURI(generateDownloadUrl(claim.name, claim.claim_id)),
- type: value.source.media_type,
- length: value.source.size || 0, // Per spec, 0 is a valid fallback.
- };
+ return {
+ 'itunes:owner': [{ 'itunes:name': name }, { 'itunes:email': email }],
+ };
+};
- default:
- return undefined;
+const generateItunesExplicitElement = (claim) => {
+ const tags = (claim && claim.value && claim.tags) || [];
+ return { 'itunes:explicit': tags.includes('mature') ? 'yes' : 'no' };
+};
+
+const getItunesCategory = (claim) => {
+ const itunesCategories = [
+ 'Arts',
+ 'Business',
+ 'Comedy',
+ 'Education',
+ 'Fiction',
+ 'Government',
+ 'History',
+ 'Health & Fitness',
+ 'Kids & Family',
+ 'Leisure',
+ 'Music',
+ 'News',
+ 'Religion & Spirituality',
+ 'Science',
+ 'Society & Culture',
+ 'Sports',
+ 'Technology',
+ 'True Crime',
+ 'TV & Film',
+ ];
+
+ const tags = (claim && claim.value && claim.tags) || [];
+ for (let i = 0; i < tags.length; ++i) {
+ const tag = tags[i];
+ if (itunesCategories.includes(tag)) {
+ // "Note: Although you can specify more than one category and subcategory
+ // in your RSS feed, Apple Podcasts only recognizes the first category and
+ // subcategory."
+ // --> The only parse the first found tag.
+ return tag.replace('&', '&');
}
- };
+ }
- const value = channelClaim.value;
- const title = value ? value.title : channelClaim.name;
+ // itunes will not accept any other categories, and the element is required
+ // to pass castfeedvalidator. So, fallback to 'Leisure' (closes to "General")
+ // if the creator did not specify a tag.
+ return 'Leisure';
+};
- const options = {
- favicon: FAVICON || URL + '/public/favicon.png',
- generator: SITE_NAME + ' RSS Feed',
- title: title + ' on ' + SITE_NAME,
- description: fmtDescription(value && value.description ? value.description : ''),
- link: encodeURI(`${URL}/${channelClaim.name}:${channelClaim.claim_id}`),
- image: sanitizeThumbsUrl(value && value.thumbnail ? value.thumbnail.url : ''),
- feedLinks: {
- rss: encodeURI(feedLink),
- },
- author: {
- name: encodeURI(channelClaim.name),
- link: encodeURI(URL + '/' + channelClaim.name + ':' + channelClaim.claim_id),
- },
- };
+const generateItunesDurationElement = (claim) => {
+ let duration;
+ if (claim && claim.value) {
+ if (claim.value.video) {
+ duration = claim.value.video.duration;
+ } else if (claim.value.audio) {
+ duration = claim.value.audio.duration;
+ }
+ }
- const feed = new Feed(options);
- const latestClaims = await getClaimsFromChannel(channelClaim.claim_id, NUM_ENTRIES);
+ if (duration) {
+ return { 'itunes:duration': `${duration}` };
+ }
+};
- latestClaims.forEach((c) => {
- const meta = c.meta;
- const value = c.value;
+const generateItunesImageElement = (claim) => {
+ const thumbnailUrl = (claim && claim.value && claim.value.thumbnail && claim.value.thumbnail.url) || '';
+ if (thumbnailUrl) {
+ return {
+ 'itunes:image': { _attr: { href: thumbnailUrl } },
+ };
+ }
+};
- const title = value && value.title ? value.title : c.name;
- const thumbnailUrl = value && value.thumbnail ? value.thumbnail.url : '';
- const thumbnailHtml = thumbnailUrl ? `