1
0
Fork 0
mirror of https://git.sr.ht/~cadence/cloudtube synced 2026-03-02 10:41:36 +00:00

Move utils folder and fix published text

This commit is contained in:
Cadence Ember 2020-09-24 00:05:02 +12:00
parent 643f1e0889
commit 2e69dfc4b7
No known key found for this signature in database
GPG key ID: BC1C2C61CF521B17
16 changed files with 53 additions and 619 deletions

View file

@ -1,7 +1,7 @@
const {render} = require("pinski/plugins")
const constants = require("./utils/constants")
const {fetchChannel} = require("./utils/youtube")
const {getUser} = require("./utils/getuser")
const constants = require("../utils/constants")
const {fetchChannel} = require("../utils/youtube")
const {getUser} = require("../utils/getuser")
module.exports = [
{

View file

@ -1,10 +1,10 @@
const {redirect} = require("pinski/plugins")
const db = require("./utils/db")
const constants = require("./utils/constants")
const {getUser} = require("./utils/getuser")
const validate = require("./utils/validate")
const db = require("../utils/db")
const constants = require("../utils/constants")
const {getUser} = require("../utils/getuser")
const validate = require("../utils/validate")
const V = validate.V
const {fetchChannel} = require("./utils/youtube")
const {fetchChannel} = require("../utils/youtube")
module.exports = [
{

View file

@ -1,8 +1,8 @@
const {render, redirect} = require("pinski/plugins")
const db = require("./utils/db")
const {getToken, getUser} = require("./utils/getuser")
const constants = require("./utils/constants")
const validate = require("./utils/validate")
const db = require("../utils/db")
const {getToken, getUser} = require("../utils/getuser")
const constants = require("../utils/constants")
const validate = require("../utils/validate")
const V = validate.V
module.exports = [

View file

@ -1,7 +1,8 @@
const {render} = require("pinski/plugins")
const db = require("./utils/db")
const {fetchChannelLatest} = require("./utils/youtube")
const {getUser} = require("./utils/getuser")
const db = require("../utils/db")
const {fetchChannelLatest} = require("../utils/youtube")
const {getUser} = require("../utils/getuser")
const converters = require("../utils/converters")
module.exports = [
{
@ -22,6 +23,10 @@ module.exports = [
hasSubscriptions = true
const template = Array(subscriptions.length).fill("?").join(", ")
videos = db.prepare(`SELECT * FROM Videos WHERE authorId IN (${template}) ORDER BY published DESC LIMIT 60`).all(subscriptions)
.map(video => {
video.publishedText = converters.timeToPastText(video.published * 1000)
return video
})
}
}
return render(200, "pug/subscriptions.pug", {hasSubscriptions, videos, channels})

View file

@ -1,25 +0,0 @@
const constants = {
user_settings: {
instance: {
type: "string",
default: "https://second.cadence.moe"
},
save_history: {
type: "boolean",
default: false
}
},
caching: {
csrf_time: 4*60*60*1000,
seen_token_subscriptions_eligible: 40*60*60*1000,
subscriptions_refresh_loop_min: 5*60*1000,
},
regex: {
ucid: "[A-Za-z0-9-_]+",
video_id: "[A-Za-z0-9-_]+"
}
}
module.exports = constants

View file

@ -1,8 +0,0 @@
const sqlite = require("better-sqlite3")
const pj = require("path").join
const fs = require("fs")
const dir = pj(__dirname, "../../db")
fs.mkdirSync(pj(dir, "backups"), {recursive: true})
const db = new sqlite(pj(dir, "cloudtube.db"))
module.exports = db

View file

@ -1,96 +0,0 @@
const crypto = require("crypto")
const {parse: parseCookie} = require("cookie")
const constants = require("./constants")
const db = require("./db")
function getToken(req, responseHeaders) {
if (!req.headers.cookie) req.headers.cookie = ""
const cookie = parseCookie(req.headers.cookie)
let token = cookie.token
if (!token) {
if (responseHeaders) { // we should create a token
const setCookie = responseHeaders["set-cookie"] || []
token = crypto.randomBytes(18).toString("base64").replace(/\W/g, "_")
setCookie.push(`token=${token}; Path=/; Max-Age=2147483648; HttpOnly; SameSite=Lax`)
responseHeaders["set-cookie"] = setCookie
} else {
return null
}
}
db.prepare("REPLACE INTO SeenTokens (token, seen) VALUES (?, ?)").run([token, Date.now()])
return token
}
class User {
constructor(token) {
this.token = token
}
getSettings() {
if (this.token) {
return db.prepare("SELECT * FROM Settings WHERE token = ?").get(this.token) || {}
} else {
return {}
}
}
getSettingsOrDefaults() {
const settings = this.getSettings()
for (const key of Object.keys(constants.user_settings)) {
if (settings[key] == null) settings[key] = constants.user_settings[key].default
}
return settings
}
getSubscriptions() {
if (this.token) {
return db.prepare("SELECT ucid FROM Subscriptions WHERE token = ?").pluck().all(this.token)
} else {
return []
}
}
isSubscribed(ucid) {
if (this.token) {
return !!db.prepare("SELECT * FROM Subscriptions WHERE token = ? AND ucid = ?").get([this.token, ucid])
} else {
return false
}
}
}
/**
* @param {any} responseHeaders supply this to create a token
*/
function getUser(req, responseHeaders) {
const token = getToken(req, responseHeaders)
return new User(token)
}
function generateCSRF() {
const token = crypto.randomBytes(16).toString("hex")
const expires = Date.now() + constants.caching.csrf_time
db.prepare("INSERT INTO CSRFTokens (token, expires) VALUES (?, ?)").run(token, expires)
return token
}
function checkCSRF(token) {
const row = db.prepare("SELECT * FROM CSRFTokens WHERE token = ? AND expires > ?").get(token, Date.now())
if (row) {
db.prepare("DELETE FROM CSRFTokens WHERE token = ?").run(token)
return true
} else {
return false
}
}
function cleanCSRF() {
db.prepare("DELETE FROM CSRFTokens WHERE expires <= ?").run(Date.now())
}
cleanCSRF()
setInterval(cleanCSRF, constants.caching.csrf_time).unref()
module.exports.getToken = getToken
module.exports.generateCSRF = generateCSRF
module.exports.checkCSRF = checkCSRF
module.exports.getUser = getUser

View file

@ -1,61 +0,0 @@
const pj = require("path").join
const db = require("./db")
const deltas = [
// 0: from empty file, +DatabaseVersion, +Subscriptions
function() {
db.prepare("CREATE TABLE DatabaseVersion (version INTEGER NOT NULL, PRIMARY KEY (version))")
.run()
db.prepare("CREATE TABLE Subscriptions (token TEXT NOT NULL, ucid TEXT NOT NULL, PRIMARY KEY (token, ucid))")
.run()
db.prepare("CREATE TABLE Channels (ucid TEXT NOT NULL, name TEXT NOT NULL, icon_url TEXT, PRIMARY KEY (ucid))")
.run()
db.prepare("CREATE TABLE Videos (videoId TEXT NOT NULL, title TEXT NOT NULL, author TEXT, authorId TEXT NOT NULL, published INTEGER, publishedText TEXT, lengthText TEXT, viewCountText TEXT, descriptionHtml TEXT, PRIMARY KEY (videoId))")
.run()
db.prepare("CREATE TABLE CSRFTokens (token TEXT NOT NULL, expires INTEGER NOT NULL, PRIMARY KEY (token))")
.run()
db.prepare("CREATE TABLE SeenTokens (token TEXT NOT NULL, seen INTEGER NOT NULL, PRIMARY KEY (token))")
.run()
db.prepare("CREATE TABLE Settings (token TEXT NOT NULL, instance TEXT, save_history INTEGER, PRIMARY KEY (token))")
.run()
}
]
async function createBackup(entry) {
const filename = `db/backups/cloudtube.db.bak-v${entry-1}`
process.stdout.write(`Backing up current to ${filename}... `)
await db.backup(pj(__dirname, "../../", filename))
process.stdout.write("done.\n")
}
/**
* @param {number} entry
* @param {boolean} log
*/
function runDelta(entry, log) {
process.stdout.write(`Upgrading database to version ${entry}... `)
deltas[entry]()
db.prepare("DELETE FROM DatabaseVersion").run()
db.prepare("INSERT INTO DatabaseVersion (version) VALUES (?)").run(entry)
process.stdout.write("done.\n")
}
module.exports = async function() {
let currentVersion = -1
const newVersion = deltas.length - 1
try {
currentVersion = db.prepare("SELECT version FROM DatabaseVersion").pluck().get()
} catch (e) {} // if the table doesn't exist yet then we don't care
if (currentVersion !== newVersion) {
// go through the entire upgrade sequence
for (let entry = currentVersion+1; entry <= newVersion; entry++) {
// Back up current version
if (entry > 0) await createBackup(entry)
// Run delta
runDelta(entry)
}
}
}

View file

@ -1,83 +0,0 @@
class V {
constructor() {
this.chain = []
this.state = {}
this.finished = false
this.endValue = null
}
with(preset) {
this.check(...preset)
return this
}
check(conditionCallback, elseCallback) {
this.chain.push(() => {
if (!conditionCallback(this.state)) this._end(elseCallback(this.state))
})
return this
}
last(callback) {
this.chain.push(() => {
this._end(callback(this.state))
})
return this
}
go() {
for (const s of this.chain) {
s()
if (this.finished) return this.endValue
}
return {
statusCode: 500,
contentType: "application/json",
content: {
error: "Reached end of V chain without response"
}
}
}
_end(value) {
this.finished = true
this.endValue = value
}
}
function presetLoad(additions) {
return [
state => {
Object.assign(state, additions)
return true
},
null
]
}
function presetURLParamsBody() {
return [
state => {
try {
state.params = new URLSearchParams(state.body.toString())
return true
} catch (e) {
console.error(e)
return false
}
},
() => {
return {
statusCode: 400,
contentType: "application/json",
content: {
error: "Could not parse body as URLSearchParams"
}
}
}
]
}
module.exports.V = V
module.exports.presetLoad = presetLoad
module.exports.presetURLParamsBody = presetURLParamsBody

View file

@ -1,16 +0,0 @@
const fetch = require("node-fetch")
const db = require("./db")
async function fetchChannel(ucid, instance) {
if (!instance) throw new Error("No instance parameter provided")
// fetch
const channel = await fetch(`${instance}/api/v1/channels/${ucid}`).then(res => res.json())
// update database
const bestIcon = channel.authorThumbnails.slice(-1)[0]
const iconURL = bestIcon ? bestIcon.url : null
db.prepare("REPLACE INTO Channels (ucid, name, icon_url) VALUES (?, ?, ?)").run([channel.authorId, channel.author, iconURL])
// return
return channel
}
module.exports.fetchChannel = fetchChannel

View file

@ -1,7 +1,7 @@
const fetch = require("node-fetch")
const {render} = require("pinski/plugins")
const db = require("./utils/db")
const {getToken, getUser} = require("./utils/getuser")
const db = require("../utils/db")
const {getToken, getUser} = require("../utils/getuser")
const pug = require("pug")
class InstanceError extends Error {

View file

@ -1,596 +0,0 @@
const fetch = require("node-fetch")
const {render} = require("pinski/plugins")
const fs = require("fs").promises
class TTLCache {
constructor(ttl) {
this.backing = new Map()
this.ttl = ttl
}
set(key, value) {
this.backing.set(key, {time: Date.now(), value})
return value
}
clean(key) {
if (this.backing.has(key) && Date.now() - this.backing.get(key).time > this.ttl) {
this.delete(key)
}
}
has(key) {
this.clean(key)
return this.backing.has(key)
}
get(key) {
this.clean(key)
if (this.has(key)) {
return this.backing.get(key).value
} else {
return null
}
}
async getAs(key, callback) {
return this.get(key) || callback().then(value => this.set(key, value))
}
}
const videoCache = new TTLCache(Infinity)
const channelCacheTimeout = 4*60*60*1000;
let shareWords = [];
fs.readFile("util/words.txt", "utf8").then(words => {
shareWords = words.split("\n");
})
const IDLetterIndex = []
.concat(Array(26).fill().map((_, i) => String.fromCharCode(i+65)))
.concat(Array(26).fill().map((_, i) => String.fromCharCode(i+97)))
.concat(Array(10).fill().map((_, i) => i.toString()))
.join("")
+"-_"
function getShareWords(id) {
if (shareWords.length == 0) {
console.error("Tried to get share words, but they aren't loaded yet!");
return "";
}
// Convert ID string to binary number string
let binaryString = "";
for (let letter of id) {
binaryString += IDLetterIndex.indexOf(letter).toString(2).padStart(6, "0");
}
binaryString = binaryString.slice(0, 64);
// Convert binary string to words
let words = [];
for (let i = 0; i < 6; i++) {
let bitFragment = binaryString.substr(i*11, 11).padEnd(11, "0");
let number = parseInt(bitFragment, 2);
let word = shareWords[number];
words.push(word);
}
return words;
}
function getIDFromWords(words) {
// Convert words to binary number string
let binaryString = "";
for (let word of words) {
binaryString += shareWords.indexOf(word).toString(2).padStart(11, "0")
}
binaryString = binaryString.slice(0, 64);
// Convert binary string to ID
let id = "";
for (let i = 0; i < 11; i++) {
let bitFragment = binaryString.substr(i*6, 6).padEnd(6, "0");
let number = parseInt(bitFragment, 2);
id += IDLetterIndex[number];
}
return id;
}
function validateShareWords(words) {
if (words.length != 6) throw new Error("Expected 6 words, got "+words.length);
for (let word of words) {
if (!shareWords.includes(word)) throw new Error(word+" is not a valid share word");
}
}
function findShareWords(string) {
if (string.includes(" ")) {
return string.toLowerCase().split(" ");
} else {
let words = [];
let currentWord = "";
for (let i = 0; i < string.length; i++) {
if (string[i] == string[i].toUpperCase()) {
if (currentWord) words.push(currentWord);
currentWord = string[i].toLowerCase();
} else {
currentWord += string[i];
}
}
words.push(currentWord);
return words;
}
}
let channelCache = new Map();
function refreshCache() {
for (let e of channelCache.entries()) {
if (Date.now()-e[1].refreshed > channelCacheTimeout) channelCache.delete(e[0]);
}
}
function fetchChannel(channelID, ignoreCache) {
refreshCache();
let cache = channelCache.get(channelID);
if (cache && !ignoreCache) {
if (cache.constructor.name == "Promise") {
//cf.log("Waiting on promise for "+channelID, "info");
return cache;
} else {
//cf.log("Using cache for "+channelID+", expires in "+Math.floor((channelCacheTimeout-Date.now()+cache.refreshed)/1000/60)+" minutes", "spam");
return Promise.resolve(cache.data);
}
} else {
//cf.log("Setting new cache for "+channelID, "spam");
let promise = new Promise(resolve => {
let channelType = channelID.startsWith("UC") && channelID.length == 24 ? "channel_id" : "user";
Promise.all([
rp(`${getInvidiousHost("channel")}/api/v1/channels/${channelID}`),
rp(`https://www.youtube.com/feeds/videos.xml?${channelType}=${channelID}`)
]).then(([body, xml]) => {
let data = JSON.parse(body);
if (data.error) throw new Error("Couldn't refresh "+channelID+": "+data.error);
let feedItems = fxp.parse(xml).feed.entry;
//console.log(feedItems.slice(0, 2))
data.latestVideos.forEach(v => {
v.author = data.author;
let gotDateFromFeed = false;
if (Array.isArray(feedItems)) {
let feedItem = feedItems.find(i => i["yt:videoId"] == v.videoId);
if (feedItem) {
const date = new Date(feedItem.published)
v.published = date.getTime();
v.publishedText = date.toUTCString().split(" ").slice(1, 4).join(" ")
gotDateFromFeed = true;
}
}
if (!gotDateFromFeed) v.published = v.published * 1000;
});
//console.log(data.latestVideos.slice(0, 2))
channelCache.set(channelID, {refreshed: Date.now(), data: data});
//cf.log("Set new cache for "+channelID, "spam");
resolve(data);
}).catch(error => {
cf.log("Error while refreshing "+channelID, "error");
cf.log(error, "error");
channelCache.delete(channelID);
resolve(null);
});
});
channelCache.set(channelID, promise);
return promise;
}
}
module.exports = [
/*{
route: "/watch", methods: ["GET"], code: async ({url}) => {
const id = url.searchParams.get("v")
const video = await videoCache.getAs(id, () => fetch(`http://localhost:3000/api/v1/videos/${id}`).then(res => res.json()))
return render(200, "pug/video.pug", {video})
}
},
{
route: "/v/(.*)", methods: ["GET"], code: async ({fill}) => {
let id;
let wordsString = fill[0];
wordsString = wordsString.replace(/%20/g, " ")
if (wordsString.length == 11) {
id = wordsString
} else {
let words = findShareWords(wordsString);
try {
validateShareWords(words);
} catch (e) {
return [400, e.message];
}
id = getIDFromWords(words);
}
return {
statusCode: 301,
contentType: "text/html",
content: "Redirecting...",
headers: {
"Location": "/cloudtube/video/"+id
}
}
}
},
{
route: "/cloudtube/video/([\\w-]+)", methods: ["GET"], code: ({req, fill}) => new Promise(resolve => {
rp(`${getInvidiousHost("video")}/api/v1/videos/${fill[0]}`).then(body => {
try {
let data = JSON.parse(body);
let page = pugCache.get("pug/old/cloudtube-video.pug").web()
page = page.replace('"<!-- videoInfo -->"', () => body);
let shareWords = getShareWords(fill[0]);
page = page.replace('"<!-- shareWords -->"', () => JSON.stringify(shareWords));
page = page.replace("<title></title>", () => `<title>${data.title} — CloudTube video</title>`);
while (page.includes("yt.www.watch.player.seekTo")) page = page.replace("yt.www.watch.player.seekTo", "seekTo");
let metaOGTags =
`<meta property="og:title" content="${data.title.replace(/&/g, "&amp;").replace(/"/g, "&quot;")} — CloudTube video" />\n`+
`<meta property="og:type" content="video.movie" />\n`+
`<meta property="og:image" content="https://invidio.us/vi/${fill[0]}/mqdefault.jpg" />\n`+
`<meta property="og:url" content="https://${req.headers.host}${req.url}" />\n`+
`<meta property="og:description" content="CloudTube is a free, open-source YouTube proxy." />\n`
page = page.replace("<!-- metaOGTags -->", () => metaOGTags);
resolve({
statusCode: 200,
contentType: "text/html",
content: page
});
} catch (e) {
resolve([400, "Error parsing data from Invidious"]);
}
}).catch(err => {
resolve([500, "Error requesting data from Invidious"]);
});
})
},
{
route: "/cloudtube/channel/([\\w-]+)", methods: ["GET"], code: ({req, fill}) => new Promise(resolve => {
fetchChannel(fill[0]).then(data => {
try {
let page = pugCache.get("pug/old/cloudtube-channel.pug").web()
page = page.replace('"<!-- channelInfo -->"', () => JSON.stringify(data));
page = page.replace("<title></title>", () => `<title>${data.author} — CloudTube channel</title>`);
let metaOGTags =
`<meta property="og:title" content="${data.author.replace(/&/g, "&amp;").replace(/"/g, "&quot;")} — CloudTube channel" />\n`+
`<meta property="og:type" content="video.movie" />\n`+
// `<meta property="og:image" content="${data.authorThumbnails[0].url.split("=")[0]}" />\n`+
`<meta property="og:url" content="https://${req.headers.host}${req.url}" />\n`+
`<meta property="og:description" content="CloudTube is a free, open-source YouTube proxy." />\n`
page = page.replace("<!-- metaOGTags -->", () => metaOGTags);
resolve({
statusCode: 200,
contentType: "text/html",
content: page
});
} catch (e) {
resolve([400, "Error parsing data from Invidious"]);
}
}).catch(err => {
resolve([500, "Error requesting data from Invidious"]);
});
})
},
{
route: "/cloudtube/playlist/([\\w-]+)", methods: ["GET"], code: ({req, fill}) => new Promise(resolve => {
rp(`${getInvidiousHost("playlist")}/api/v1/playlists/${fill[0]}`).then(body => {
try {
let data = JSON.parse(body);
let page = pugCache.get("pug/old/cloudtube-playlist.pug").web()
page = page.replace('"<!-- playlistInfo -->"', () => body);
page = page.replace("<title></title>", () => `<title>${data.title} — CloudTube playlist</title>`);
while (page.includes("yt.www.watch.player.seekTo")) page = page.replace("yt.www.watch.player.seekTo", "seekTo");
let metaOGTags =
`<meta property="og:title" content="${data.title.replace(/&/g, "&amp;").replace(/"/g, "&quot;")} — CloudTube playlist" />\n`+
`<meta property="og:type" content="video.movie" />\n`+
`<meta property="og:url" content="https://${req.headers.host}${req.url}" />\n`+
`<meta property="og:description" content="CloudTube is a free, open-source YouTube proxy." />\n`
if (data.videos[0]) metaOGTags += `<meta property="og:image" content="https://invidio.us/vi/${data.videos[0].videoId}/mqdefault.jpg" />\n`;
page = page.replace("<!-- metaOGTags -->", () => metaOGTags);
resolve({
statusCode: 200,
contentType: "text/html",
content: page
});
} catch (e) {
resolve([400, "Error parsing data from Invidious"]);
}
}).catch(err => {
resolve([500, "Error requesting data from Invidious"]);
});
})
},
{
route: "/cloudtube/search", methods: ["GET"], upload: "json", code: ({req, url}) => new Promise(resolve => {
const params = url.searchParams
console.log("URL:", req.url)
console.log("Headers:", req.headers)
let page = pugCache.get("pug/old/cloudtube-search.pug").web()
if (params.has("q")) { // search terms were entered
let sort_by = params.get("sort_by") || "relevance";
rp(`${getInvidiousHost("search")}/api/v1/search?q=${encodeURIComponent(decodeURIComponent(params.get("q")))}&sort_by=${sort_by}`).then(body => {
try {
// json.parse?
page = page.replace('"<!-- searchResults -->"', () => body);
page = page.replace("<title></title>", () => `<title>${decodeURIComponent(params.get("q"))} — CloudTube search</title>`);
let metaOGTags =
`<meta property="og:title" content="${decodeURIComponent(params.get("q")).replace(/"/g, '\\"')} — CloudTube search" />\n`+
`<meta property="og:type" content="video.movie" />\n`+
`<meta property="og:url" content="https://${req.headers.host}${req.path}" />\n`+
`<meta property="og:description" content="CloudTube is a free, open-source YouTube proxy." />\n`
page = page.replace("<!-- metaOGTags -->", () => metaOGTags);
resolve({
statusCode: 200,
contentType: "text/html",
content: page
});
} catch (e) {
resolve([400, "Error parsing data from Invidious"]);
}
}).catch(err => {
resolve([500, "Error requesting data from Invidious"]);
});
} else { // no search terms
page = page.replace("<!-- searchResults -->", "");
page = page.replace("<title></title>", `<title>CloudTube search</title>`);
let metaOGTags =
`<meta property="og:title" content="CloudTube search" />\n`+
`<meta property="og:type" content="video.movie" />\n`+
`<meta property="og:url" content="https://${req.headers.host}${req.path}" />\n`+
`<meta property="og:description" content="CloudTube is a free, open-source YouTube proxy." />\n`
page = page.replace("<!-- metaOGTags -->", () => metaOGTags);
resolve({
statusCode: 200,
contentType: "text/html",
content: page
});
}
})
},
{
route: "/api/youtube/subscribe", methods: ["POST"], upload: "json", code: async ({data}) => {
if (!data.channelID) return [400, 1];
if (!data.token) return [400, 8];
let userRow = await db.get("SELECT userID FROM AccountTokens WHERE token = ?", data.token);
if (!userRow || userRow.expires <= Date.now()) return [401, 8];
let subscriptions = (await db.all("SELECT channelID FROM AccountSubscriptions WHERE userID = ?", userRow.userID)).map(r => r.channelID);
let nowSubscribed;
if (subscriptions.includes(data.channelID)) {
await db.run("DELETE FROM AccountSubscriptions WHERE userID = ? AND channelID = ?", [userRow.userID, data.channelID]);
nowSubscribed = false;
} else {
await db.run("INSERT INTO AccountSubscriptions VALUES (?, ?)", [userRow.userID, data.channelID]);
nowSubscribed = true;
}
return [200, {channelID: data.channelID, nowSubscribed}];
}
},
{
route: "/api/youtube/subscriptions", methods: ["POST"], upload: "json", code: async ({data}) => {
let subscriptions;
if (data.token) {
let userRow = await db.get("SELECT userID FROM AccountTokens WHERE token = ?", data.token);
if (!userRow || userRow.expires <= Date.now()) return [401, 8];
subscriptions = (await db.all("SELECT channelID FROM AccountSubscriptions WHERE userID = ?", userRow.userID)).map(r => r.channelID);
} else {
if (data.subscriptions && data.subscriptions.constructor.name == "Array" && data.subscriptions.every(i => typeof(i) == "string")) subscriptions = data.subscriptions;
else return [400, 4];
}
if (data.force) {
for (let channelID of subscriptions) channelCache.delete(channelID);
return [204, ""];
} else {
let videos = [];
let channels = [];
let failedCount = 0
await Promise.all(subscriptions.map(s => fetchChannel(s).then(data => {
if (data) {
videos = videos.concat(data.latestVideos);
channels.push({author: data.author, authorID: data.authorId, authorThumbnails: data.authorThumbnails});
} else {
failedCount++
}
})));
videos = videos.sort((a, b) => (b.published - a.published))
let limit = 60;
if (data.limit && !isNaN(+data.limit) && (+data.limit > 0)) limit = +data.limit;
videos = videos.slice(0, limit);
channels = channels.sort((a, b) => (a.author.toLowerCase() < b.author.toLowerCase() ? -1 : 1));
return [200, {videos, channels, failedCount}];
}
}
},
{
route: "/api/youtube/subscriptions/import", methods: ["POST"], upload: "json", code: async ({data}) => {
if (!data) return [400, 3];
if (!typeof(data) == "object") return [400, 5];
if (!data.token) return [401, 8];
let userRow = await db.get("SELECT userID FROM AccountTokens WHERE token = ?", data.token);
if (!userRow || userRow.expires <= Date.now()) return [401, 8];
if (!data.subscriptions) return [400, 4];
if (!data.subscriptions.every(v => typeof(v) == "string")) return [400, 5];
await db.run("BEGIN TRANSACTION");
await db.run("DELETE FROM AccountSubscriptions WHERE userID = ?", userRow.userID);
await Promise.all(data.subscriptions.map(v =>
db.run("INSERT OR IGNORE INTO AccountSubscriptions VALUES (?, ?)", [userRow.userID, v])
))
await db.run("END TRANSACTION");
return [204, ""];
}
},
{
route: "/api/youtube/channels/([\\w-]+)/info", methods: ["GET"], code: ({fill}) => {
return rp(`${getInvidiousHost("channel")}/api/v1/channels/${fill[0]}`).then(body => {
return {
statusCode: 200,
contentType: "application/json",
content: body
}
}).catch(e => {
console.error(e);
return [500, "Unknown request error, check console"]
});
}
},
{
route: "/api/youtube/alternate/.*", methods: ["GET"], code: async ({req}) => {
return [404, "Please leave me alone. This endpoint has been removed and it's never coming back. Why not try youtube-dl instead? https://github.com/ytdl-org/youtube-dl/\nIf you own a bot that accesses this endpoint, please send me an email: https://cadence.moe/about/contact\nHave a nice day.\n"];
return null
return [400, {error: `/api/youtube/alternate has been removed. The page will be reloaded.<br><img src=/ onerror=setTimeout(window.location.reload.bind(window.location),5000)>`}]
}
},
{
route: "/api/youtube/dash/([\\w-]+)", methods: ["GET"], code: ({fill}) => new Promise(resolve => {
let id = fill[0];
let sentReq = rp({
url: `http://localhost:3000/api/manifest/dash/id/${id}?local=true`,
timeout: 8000
});
sentReq.catch(err => {
if (err.code == "ETIMEDOUT" || err.code == "ESOCKETTIMEDOUT" || err.code == "ECONNRESET") resolve([502, "Request to Invidious timed out"]);
else {
console.log(err);
resolve([500, "Unknown request error, check console"]);
}
});
sentReq.then(body => {
let data = fxp.parse(body, {ignoreAttributes: false});
resolve([200, data]);
}).catch(err => {
if (err.code == "ETIMEDOUT" || err.code == "ESOCKETTIMEDOUT" || err.code == "ECONNRESET") resolve([502, "Request to Invidious timed out"]);
else {
console.log(err);
resolve([500, "Unknown parse error, check console"]);
}
});
})
},
{
route: "/api/youtube/get_endscreen", methods: ["GET"], code: async ({params}) => {
if (!params.v) return [400, 1];
let data = await rp("https://www.youtube.com/get_endscreen?v="+params.v);
data = data.toString();
try {
if (data == `""`) {
return {
statusCode: 204,
content: "",
contentType: "text/html",
headers: {"Access-Control-Allow-Origin": "*"}
}
} else {
let json = JSON.parse(data.slice(data.indexOf("\n")+1));
let promises = [];
for (let e of json.elements.filter(e => e.endscreenElementRenderer.style == "WEBSITE")) {
for (let thb of e.endscreenElementRenderer.image.thumbnails) {
let promise = rp(thb.url, {encoding: null});
promise.then(image => {
let base64 = image.toString("base64");
thb.url = "data:image/jpeg;base64,"+base64;
});
promises.push(promise);
}
}
await Promise.all(promises);
return {
statusCode: 200,
content: json,
contentType: "application/json",
headers: {"Access-Control-Allow-Origin": "*"}
}
}
} catch (e) {
return [500, "Couldn't parse endscreen data\n\n"+data];
}
}
},
{
route: "/api/youtube/video/([\\w-]+)", methods: ["GET"], code: ({fill}) => {
return new Promise(resolve => {
ytdl.getInfo(fill[0]).then(info => {
resolve([200, Object.assign(info, {constructor: new Object().constructor})]);
}).catch(err => {
resolve([400, err]);
});
});
}
},
{
route: "/api/youtube/channel/(\\S+)", methods: ["GET"], code: ({fill}) => {
return new Promise(resolve => {
rp(
"https://www.googleapis.com/youtube/v3/channels?part=contentDetails"+
`&id=${fill[0]}&key=${auth.yt_api_key}`
).then(channelText => {
let channel = JSON.parse(channelText);
let playlistIDs = channel.items.map(i => i.contentDetails.relatedPlaylists.uploads);
Promise.all(playlistIDs.map(pid => rp(
"https://www.googleapis.com/youtube/v3/playlistItems?part=contentDetails"+
`&playlistId=${pid}&maxResults=50&key=${auth.yt_api_key}`
))).then(playlistsText => {
let playlists = playlistsText.map(pt => JSON.parse(pt));;
let items = [].concat(...playlists.map(p => p.items))
.map(i => i.contentDetails)
.sort((a, b) => (a.videoPublishedAt > b.videoPublishedAt ? -1 : 1))
.slice(0, 50);
rp(
"https://www.googleapis.com/youtube/v3/videos?part=contentDetails,snippet"+
`&id=${items.map(i => i.videoId).join(",")}&key=${auth.yt_api_key}`
).then(videosText => {
let videos = JSON.parse(videosText);
videos.items.forEach(v => {
let duration = v.contentDetails.duration.slice(2).replace(/\D/g, ":").slice(0, -1).split(":")
.map((t, i) => {
if (i) t = t.padStart(2, "0");
return t;
});
if (duration.length == 1) duration.splice(0, 0, "0");
v.duration = duration.join(":");
});
resolve([200, videos.items]);
});
});
}).catch(err => {
resolve([500, "Unexpected promise rejection error. This should not happen. Contact Cadence as soon as possible."]);
console.log("Unexpected promise rejection error!");
console.log(err);
});
});
}
},
{
route: "/api/youtube/search", methods: ["GET"], code: ({params}) => {
return new Promise(resolve => {
if (!params || !params.q) return resolve([400, "Missing ?q parameter"]);
let searchObject = {
maxResults: +params.maxResults || 20,
key: auth.yt_api_key,
type: "video"
};
if (params.order) searchObject.order = params.order;
yts(params.q, searchObject, (err, search) => {
if (err) {
resolve([500, "YouTube API error. This should not happen. Contact Cadence as soon as possible."]);
console.log("YouTube API error!");
console.log(search);
} else {
rp(
"https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id="+
search.map(r => r.id).join(",")+
"&key="+auth.yt_api_key
).then(videos => {
JSON.parse(videos).items.forEach(v => {
let duration = v.contentDetails.duration.slice(2).replace(/\D/g, ":").slice(0, -1).split(":")
.map((t, i) => {
if (i) t = t.padStart(2, "0");
return t;
});
if (duration.length == 1) duration.splice(0, 0, "0");
search.find(r => r.id == v.id).duration = duration.join(":");
});
resolve([200, search]);
});
}
});
});
}
}*/
]