mirror of
https://git.sr.ht/~cadence/cloudtube
synced 2024-11-14 04:17:29 +00:00
Move utils folder and fix published text
This commit is contained in:
parent
643f1e0889
commit
2e69dfc4b7
@ -1,7 +1,7 @@
|
|||||||
const {render} = require("pinski/plugins")
|
const {render} = require("pinski/plugins")
|
||||||
const constants = require("./utils/constants")
|
const constants = require("../utils/constants")
|
||||||
const {fetchChannel} = require("./utils/youtube")
|
const {fetchChannel} = require("../utils/youtube")
|
||||||
const {getUser} = require("./utils/getuser")
|
const {getUser} = require("../utils/getuser")
|
||||||
|
|
||||||
module.exports = [
|
module.exports = [
|
||||||
{
|
{
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
const {redirect} = require("pinski/plugins")
|
const {redirect} = require("pinski/plugins")
|
||||||
const db = require("./utils/db")
|
const db = require("../utils/db")
|
||||||
const constants = require("./utils/constants")
|
const constants = require("../utils/constants")
|
||||||
const {getUser} = require("./utils/getuser")
|
const {getUser} = require("../utils/getuser")
|
||||||
const validate = require("./utils/validate")
|
const validate = require("../utils/validate")
|
||||||
const V = validate.V
|
const V = validate.V
|
||||||
const {fetchChannel} = require("./utils/youtube")
|
const {fetchChannel} = require("../utils/youtube")
|
||||||
|
|
||||||
module.exports = [
|
module.exports = [
|
||||||
{
|
{
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
const {render, redirect} = require("pinski/plugins")
|
const {render, redirect} = require("pinski/plugins")
|
||||||
const db = require("./utils/db")
|
const db = require("../utils/db")
|
||||||
const {getToken, getUser} = require("./utils/getuser")
|
const {getToken, getUser} = require("../utils/getuser")
|
||||||
const constants = require("./utils/constants")
|
const constants = require("../utils/constants")
|
||||||
const validate = require("./utils/validate")
|
const validate = require("../utils/validate")
|
||||||
const V = validate.V
|
const V = validate.V
|
||||||
|
|
||||||
module.exports = [
|
module.exports = [
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
const {render} = require("pinski/plugins")
|
const {render} = require("pinski/plugins")
|
||||||
const db = require("./utils/db")
|
const db = require("../utils/db")
|
||||||
const {fetchChannelLatest} = require("./utils/youtube")
|
const {fetchChannelLatest} = require("../utils/youtube")
|
||||||
const {getUser} = require("./utils/getuser")
|
const {getUser} = require("../utils/getuser")
|
||||||
|
const converters = require("../utils/converters")
|
||||||
|
|
||||||
module.exports = [
|
module.exports = [
|
||||||
{
|
{
|
||||||
@ -22,6 +23,10 @@ module.exports = [
|
|||||||
hasSubscriptions = true
|
hasSubscriptions = true
|
||||||
const template = Array(subscriptions.length).fill("?").join(", ")
|
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)
|
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})
|
return render(200, "pug/subscriptions.pug", {hasSubscriptions, videos, channels})
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
const fetch = require("node-fetch")
|
const fetch = require("node-fetch")
|
||||||
const {render} = require("pinski/plugins")
|
const {render} = require("pinski/plugins")
|
||||||
const db = require("./utils/db")
|
const db = require("../utils/db")
|
||||||
const {getToken, getUser} = require("./utils/getuser")
|
const {getToken, getUser} = require("../utils/getuser")
|
||||||
const pug = require("pug")
|
const pug = require("pug")
|
||||||
|
|
||||||
class InstanceError extends Error {
|
class InstanceError extends Error {
|
||||||
|
596
api/youtube.js
596
api/youtube.js
@ -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, "&").replace(/"/g, """)} — 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, "&").replace(/"/g, """)} — 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, "&").replace(/"/g, """)} — 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]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
]
|
|
@ -1,14 +1,14 @@
|
|||||||
const Denque = require("denque")
|
const Denque = require("denque")
|
||||||
const fetch = require("node-fetch")
|
const fetch = require("node-fetch")
|
||||||
const constants = require("../api/utils/constants")
|
const constants = require("../utils/constants")
|
||||||
const db = require("../api/utils/db")
|
const db = require("../utils/db")
|
||||||
|
|
||||||
const prepared = {
|
const prepared = {
|
||||||
video_insert: db.prepare(
|
video_insert: db.prepare(
|
||||||
"INSERT OR IGNORE INTO Videos"
|
"INSERT OR IGNORE INTO Videos"
|
||||||
+ " ( videoId, title, author, authorId, published, publishedText, viewCountText, descriptionHtml)"
|
+ " ( videoId, title, author, authorId, published, viewCountText, descriptionHtml)"
|
||||||
+ " VALUES"
|
+ " VALUES"
|
||||||
+ " (@videoId, @title, @author, @authorId, @published, @publishedText, @viewCountText, @descriptionHtml)"
|
+ " (@videoId, @title, @author, @authorId, @published, @viewCountText, @descriptionHtml)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,6 +48,10 @@ class RefreshQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
next() {
|
next() {
|
||||||
|
if (this.isEmpty()) {
|
||||||
|
throw new Error("Cannot get next of empty refresh queue")
|
||||||
|
}
|
||||||
|
|
||||||
const item = this.queue.shift()
|
const item = this.queue.shift()
|
||||||
this.set.delete(item)
|
this.set.delete(item)
|
||||||
return item
|
return item
|
||||||
|
@ -2,7 +2,7 @@ const {Pinski} = require("pinski")
|
|||||||
const {setInstance} = require("pinski/plugins")
|
const {setInstance} = require("pinski/plugins")
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
await require("./api/utils/upgradedb")()
|
await require("./utils/upgradedb")()
|
||||||
|
|
||||||
const server = new Pinski({
|
const server = new Pinski({
|
||||||
port: 10412,
|
port: 10412,
|
||||||
|
21
utils/converters.js
Normal file
21
utils/converters.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
function timeToPastText(timestamp) {
|
||||||
|
const difference = Date.now() - timestamp
|
||||||
|
return [
|
||||||
|
["year", 365 * 24 * 60 * 60 * 1000],
|
||||||
|
["month", 30 * 24 * 60 * 60 * 1000],
|
||||||
|
["week", 7 * 24 * 60 * 60 * 1000],
|
||||||
|
["day", 24 * 60 * 60 * 1000],
|
||||||
|
["hour", 60 * 60 * 1000],
|
||||||
|
["minute", 60 * 1000],
|
||||||
|
["second", 1 * 1000]
|
||||||
|
].reduce((acc, [unitName, unitValue]) => {
|
||||||
|
if (acc) return acc
|
||||||
|
if (difference > unitValue) {
|
||||||
|
const number = Math.floor(difference / unitValue)
|
||||||
|
const pluralUnit = unitName + (number == 1 ? "" : "s")
|
||||||
|
return `${number} ${pluralUnit} ago`
|
||||||
|
}
|
||||||
|
}, null) || "just now"
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.timeToPastText = timeToPastText
|
@ -2,7 +2,7 @@ const sqlite = require("better-sqlite3")
|
|||||||
const pj = require("path").join
|
const pj = require("path").join
|
||||||
const fs = require("fs")
|
const fs = require("fs")
|
||||||
|
|
||||||
const dir = pj(__dirname, "../../db")
|
const dir = pj(__dirname, "../db")
|
||||||
fs.mkdirSync(pj(dir, "backups"), {recursive: true})
|
fs.mkdirSync(pj(dir, "backups"), {recursive: true})
|
||||||
const db = new sqlite(pj(dir, "cloudtube.db"))
|
const db = new sqlite(pj(dir, "cloudtube.db"))
|
||||||
module.exports = db
|
module.exports = db
|
Loading…
Reference in New Issue
Block a user