298 lines
9.1 KiB
JavaScript
298 lines
9.1 KiB
JavaScript
const { generateStreamUrl } = require('../../ui/util/web');
|
|
const { URL, SITE_NAME, LBRY_WEB_API } = require('../../config.js');
|
|
const { Lbry } = require('lbry-redux');
|
|
const Rss = require('rss');
|
|
const Mime = require('mime-types');
|
|
|
|
const SDK_API_PATH = `${LBRY_WEB_API}/api/v1`;
|
|
const proxyURL = `${SDK_API_PATH}/proxy`;
|
|
Lbry.setDaemonConnectionString(proxyURL);
|
|
|
|
const NUM_ENTRIES = 500;
|
|
|
|
// ****************************************************************************
|
|
// Fetch claim info
|
|
// ****************************************************************************
|
|
|
|
async function doClaimSearch(options) {
|
|
let results;
|
|
try {
|
|
results = await Lbry.claim_search(options);
|
|
} catch {}
|
|
return results ? results.items : undefined;
|
|
}
|
|
|
|
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 {}
|
|
|
|
if (!claim) {
|
|
error = 'The RSS URL is invalid or is not associated with any channel.';
|
|
}
|
|
|
|
return { claim, error };
|
|
}
|
|
|
|
async function getClaimsFromChannel(claimId, count) {
|
|
const options = {
|
|
channel_ids: [claimId],
|
|
page_size: count,
|
|
has_source: true,
|
|
claim_type: 'stream',
|
|
order_by: ['release_time'],
|
|
no_totals: true,
|
|
};
|
|
|
|
return await doClaimSearch(options);
|
|
}
|
|
|
|
// ****************************************************************************
|
|
// Helpers
|
|
// ****************************************************************************
|
|
|
|
function encodeWithSpecialCharEncode(string) {
|
|
// encodeURIComponent doesn't encode `'` and others
|
|
// which other services may not like
|
|
return encodeURIComponent(string).replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29');
|
|
}
|
|
|
|
const generateEnclosureForClaimContent = (claim) => {
|
|
const value = claim.value;
|
|
if (!value || !value.stream_type) {
|
|
return undefined;
|
|
}
|
|
const fileExt = value.source && value.source.media_type && '.' + Mime.extension(value.source.media_type);
|
|
|
|
switch (value.stream_type) {
|
|
case 'video':
|
|
return {
|
|
url: generateStreamUrl(claim.name, claim.claim_id) + (fileExt || '.mp4'),
|
|
type: (value.source && value.source.media_type) || 'video/mp4',
|
|
size: (value.source && value.source.size) || 0, // Per spec, 0 is a valid fallback.
|
|
};
|
|
|
|
case 'audio':
|
|
return {
|
|
url: generateStreamUrl(claim.name, claim.claim_id) + (fileExt || '.mp3'),
|
|
type: (value.source && value.source.media_type) || 'audio/mpeg',
|
|
size: (value.source && value.source.size) || 0, // Per spec, 0 is a valid fallback.
|
|
};
|
|
case 'image':
|
|
return {
|
|
url: generateStreamUrl(claim.name, claim.claim_id) + (fileExt || '.jpeg'),
|
|
type: (value.source && value.source.media_type) || 'image/jpeg',
|
|
size: (value.source && value.source.size) || 0, // Per spec, 0 is a valid fallback.
|
|
};
|
|
case 'document':
|
|
case 'software':
|
|
return {
|
|
url: generateStreamUrl(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.
|
|
};
|
|
|
|
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, '<br />');
|
|
|
|
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 email = 'no-reply@odysee.com';
|
|
let name = claim && (claim.value && claim.value.title ? claim.value.title : claim.name);
|
|
|
|
if (claim && claim.value) {
|
|
if (isEmailRoughlyValid(claim.value.email)) {
|
|
email = claim.value.email;
|
|
}
|
|
}
|
|
|
|
return {
|
|
'itunes:owner': [{ 'itunes:name': name }, { 'itunes:email': email }],
|
|
};
|
|
};
|
|
|
|
const generateItunesExplicitElement = (claim) => {
|
|
const tags = (claim && claim.value && claim.value.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.value.tags) || [];
|
|
|
|
for (let i = 0; i < itunesCategories.length; ++i) {
|
|
const itunesCategory = itunesCategories[i];
|
|
if (tags.includes(itunesCategory.toLowerCase())) {
|
|
// "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 itunesCategory.replace('&', '&');
|
|
}
|
|
}
|
|
|
|
// 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 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;
|
|
}
|
|
}
|
|
|
|
if (duration) {
|
|
return { 'itunes:duration': `${duration}` };
|
|
}
|
|
};
|
|
|
|
const generateItunesImageElement = (claim) => {
|
|
const thumbnailUrl = (claim && claim.value && claim.value.thumbnail && claim.value.thumbnail.url) || '';
|
|
if (thumbnailUrl) {
|
|
return {
|
|
'itunes:image': { _attr: { href: thumbnailUrl } },
|
|
};
|
|
}
|
|
};
|
|
|
|
const getFormattedDescription = (claim) => {
|
|
return replaceLineFeeds((claim && claim.value && claim.value.description) || '');
|
|
};
|
|
|
|
// ****************************************************************************
|
|
// Generate
|
|
// ****************************************************************************
|
|
|
|
function generateFeed(feedLink, channelClaim, claimsInChannel) {
|
|
// --- Channel ---
|
|
let channelTitle = (channelClaim.value && channelClaim.value.title) || channelClaim.name;
|
|
const feed = new Rss({
|
|
title: channelTitle + ' on ' + SITE_NAME,
|
|
description: getFormattedDescription(channelClaim),
|
|
feed_url: feedLink,
|
|
site_url: (channelClaim.value && channelClaim.value.website_url) || URL,
|
|
image_url: (channelClaim.value && channelClaim.value.thumbnail && channelClaim.value.thumbnail.url) || undefined,
|
|
language: getLanguageValue(channelClaim),
|
|
custom_namespaces: { itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd' },
|
|
custom_elements: [
|
|
{ 'itunes:author': channelTitle },
|
|
{
|
|
'itunes:category': [
|
|
{
|
|
_attr: {
|
|
text: getItunesCategory(channelClaim),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
generateItunesImageElement(channelClaim),
|
|
generateItunesOwnerElement(channelClaim),
|
|
generateItunesExplicitElement(channelClaim),
|
|
],
|
|
});
|
|
|
|
// --- Content ---
|
|
claimsInChannel.forEach((c) => {
|
|
const title = (c.value && c.value.title) || c.name;
|
|
const thumbnailUrl = (c.value && c.value.thumbnail && c.value.thumbnail.url) || '';
|
|
const thumbnailHtml = thumbnailUrl
|
|
? `<p><img src="${thumbnailUrl}" width="480" alt="thumbnail" title="${title}" /></p>`
|
|
: '';
|
|
const description = thumbnailHtml + getFormattedDescription(c);
|
|
|
|
const url = `${URL}/${encodeWithSpecialCharEncode(c.name)}:${c.claim_id}`;
|
|
const date = c.release_time ? c.release_time * 1000 : c.meta && c.meta.creation_timestamp * 1000;
|
|
|
|
feed.item({
|
|
title: title,
|
|
description: description,
|
|
url: url,
|
|
guid: undefined, // defaults to 'url'
|
|
author: undefined, // defaults feed author property
|
|
date: new Date(date),
|
|
enclosure: generateEnclosureForClaimContent(c),
|
|
custom_elements: [
|
|
{ 'itunes:title': title },
|
|
{ 'itunes:author': channelTitle },
|
|
generateItunesImageElement(c),
|
|
generateItunesDurationElement(c),
|
|
generateItunesExplicitElement(c),
|
|
],
|
|
});
|
|
});
|
|
|
|
return feed;
|
|
}
|
|
|
|
async function getRss(ctx) {
|
|
if (!ctx.params.claimName || !ctx.params.claimId) {
|
|
return 'Invalid URL';
|
|
}
|
|
|
|
const { claim: channelClaim, error } = await getChannelClaim(ctx.params.claimName, ctx.params.claimId);
|
|
if (error) {
|
|
return error;
|
|
}
|
|
|
|
const latestClaimsInChannel = await getClaimsFromChannel(channelClaim.claim_id, NUM_ENTRIES);
|
|
const feed = generateFeed(`${URL}${ctx.request.url}`, channelClaim, latestClaimsInChannel);
|
|
return feed.xml();
|
|
}
|
|
|
|
module.exports = { getRss };
|