mirror of https://git.sr.ht/~cadence/cloudtube
First working video page
This commit is contained in:
parent
23a7da45d3
commit
cbc3a2bf67
|
@ -0,0 +1,8 @@
|
|||
# Editor crud files
|
||||
*~
|
||||
\#*#
|
||||
.#*
|
||||
.vscode
|
||||
|
||||
# Auto-generated files
|
||||
node_modules
|
|
@ -0,0 +1,596 @@
|
|||
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]);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}*/
|
||||
]
|
Binary file not shown.
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5.821 2.117" height="8" width="22">
|
||||
<path d="M1.587.53l.53.528.529-.529h.264v.265l-.793.793-.794-.793V.529z" fill="#bbb" paint-order="markers stroke fill"/>
|
||||
</svg>
|
After Width: | Height: | Size: 221 B |
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4.531 4.531" height="17.127" width="17.124">
|
||||
<g fill="#bbb">
|
||||
<path paint-order="markers stroke fill" d="M1.431 2.624l.477.477-1.43 1.43L0 4.055z"/>
|
||||
<path paint-order="markers stroke fill" d="M2.092 2.138l.302.302-1.43 1.43-.303-.302z"/>
|
||||
<path d="M4.117.413a1.417 1.417 0 0 0-2.003 2.003 1.419 1.419 0 0 0 2.003 0 1.419 1.419 0 0 0 0-2.003zm-.214.214c.436.437.436 1.14 0 1.576a1.112 1.112 0 0 1-1.575 0 1.11 1.11 0 0 1 0-1.574A1.11 1.11 0 0 1 3.904.628z" style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;shape-padding:0;isolation:auto;mix-blend-mode:normal" color="#000" font-weight="400" font-family="sans-serif" white-space="normal" overflow="visible" paint-order="markers stroke fill"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* Shortcut for querySelector.
|
||||
* @template {HTMLElement} T
|
||||
* @returns {T}
|
||||
*/
|
||||
const q = s => document.querySelector(s);
|
||||
/**
|
||||
* Shortcut for querySelectorAll.
|
||||
* @template {HTMLElement} T
|
||||
* @returns {T[]}
|
||||
*/
|
||||
const qa = s => document.querySelectorAll(s);
|
||||
|
||||
/**
|
||||
* An easier, chainable, object-oriented way to create and update elements
|
||||
* and children according to related data. Subclass ElemJS to create useful,
|
||||
* advanced data managers, or just use it inline to quickly make a custom element.
|
||||
*/
|
||||
class ElemJS {
|
||||
constructor(type) {
|
||||
if (type instanceof HTMLElement) {
|
||||
// If passed an existing element, bind to it
|
||||
this.bind(type);
|
||||
} else if (typeof type === "string") {
|
||||
// Otherwise, create a new detached element to bind to
|
||||
this.bind(document.createElement(type));
|
||||
} else {
|
||||
throw new Error("Cannot create an element of type ${type}")
|
||||
}
|
||||
this.children = [];
|
||||
}
|
||||
|
||||
/** Bind this construct to an existing element on the page. */
|
||||
bind(element) {
|
||||
this.element = element;
|
||||
this.element.js = this;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Add a class. */
|
||||
class() {
|
||||
for (let name of arguments) if (name) this.element.classList.add(name);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Remove a class. */
|
||||
removeClass() {
|
||||
for (let name of arguments) if (name) this.element.classList.remove(name);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Set a JS property on the element. */
|
||||
direct(name, value) {
|
||||
if (name) this.element[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Set an attribute on the element. */
|
||||
attribute(name, value) {
|
||||
if (name) this.element.setAttribute(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Set a style on the element. */
|
||||
style(name, value) {
|
||||
if (name) this.element.style[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Set the element's ID. */
|
||||
id(name) {
|
||||
if (name) this.element.id = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Attach a callback function to an event on the element. */
|
||||
on(name, callback) {
|
||||
this.element.addEventListener(name, callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Set the element's text. */
|
||||
text(name) {
|
||||
this.element.innerText = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Create a text node and add it to the element. */
|
||||
addText(name) {
|
||||
const node = document.createTextNode(name);
|
||||
this.element.appendChild(node);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Set the element's HTML content. */
|
||||
html(name) {
|
||||
this.element.innerHTML = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add children to the element.
|
||||
* Children can either be an instance of ElemJS, in
|
||||
* which case the element will be appended as a child,
|
||||
* or a string, in which case the string will be added as a text node.
|
||||
* Each child should be a parameter to this method.
|
||||
*/
|
||||
child(...children) {
|
||||
for (const toAdd of children) {
|
||||
if (typeof toAdd === "object" && toAdd !== null) {
|
||||
// Should be an instance of ElemJS, so append as child
|
||||
toAdd.parent = this;
|
||||
this.element.appendChild(toAdd.element);
|
||||
this.children.push(toAdd);
|
||||
} else if (typeof toAdd === "string") {
|
||||
// Is a string, so add as text node
|
||||
this.addText(toAdd);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all children from the element.
|
||||
*/
|
||||
clearChildren() {
|
||||
this.children.length = 0;
|
||||
while (this.element.lastChild) this.element.removeChild(this.element.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shortcut for `new ElemJS`. */
|
||||
function ejs(tag) {
|
||||
return new ElemJS(tag);
|
||||
}
|
||||
|
||||
export {q, qa, ElemJS, ejs};
|
|
@ -1,78 +0,0 @@
|
|||
/** @returns {HTMLElement} */
|
||||
export function q(s) {
|
||||
return document.querySelector(s)
|
||||
}
|
||||
|
||||
export class ElemJS {
|
||||
constructor(type) {
|
||||
if (type instanceof HTMLElement) this.bind(type)
|
||||
else this.bind(document.createElement(type))
|
||||
this.children = [];
|
||||
}
|
||||
bind(element) {
|
||||
/** @type {HTMLElement} */
|
||||
this.element = element
|
||||
// @ts-ignore
|
||||
this.element.js = this
|
||||
return this
|
||||
}
|
||||
class() {
|
||||
for (let name of arguments) if (name) this.element.classList.add(name);
|
||||
return this;
|
||||
}
|
||||
removeClass() {
|
||||
for (let name of arguments) if (name) this.element.classList.remove(name);
|
||||
return this;
|
||||
}
|
||||
direct(name, value) {
|
||||
if (name) this.element[name] = value;
|
||||
return this;
|
||||
}
|
||||
attribute(name, value) {
|
||||
if (name) this.element.setAttribute(name, value);
|
||||
return this;
|
||||
}
|
||||
style(name, value) {
|
||||
if (name) this.element.style[name] = value;
|
||||
return this;
|
||||
}
|
||||
id(name) {
|
||||
if (name) this.element.id = name;
|
||||
return this;
|
||||
}
|
||||
text(name) {
|
||||
this.element.innerText = name;
|
||||
return this;
|
||||
}
|
||||
addText(name) {
|
||||
const node = document.createTextNode(name)
|
||||
this.element.appendChild(node)
|
||||
return this
|
||||
}
|
||||
html(name) {
|
||||
this.element.innerHTML = name;
|
||||
return this;
|
||||
}
|
||||
event(name, callback) {
|
||||
this.element.addEventListener(name, event => callback(event))
|
||||
}
|
||||
child(toAdd, position) {
|
||||
if (typeof(toAdd) == "object") {
|
||||
toAdd.parent = this;
|
||||
if (typeof(position) == "number" && position >= 0) {
|
||||
this.element.insertBefore(toAdd.element, this.element.children[position]);
|
||||
this.children.splice(position, 0, toAdd);
|
||||
} else {
|
||||
this.element.appendChild(toAdd.element);
|
||||
this.children.push(toAdd);
|
||||
}
|
||||
} else if (typeof toAdd === "string") {
|
||||
this.text(toAdd)
|
||||
}
|
||||
return this;
|
||||
}
|
||||
clearChildren() {
|
||||
this.children.length = 0;
|
||||
while (this.element.lastChild) this.element.removeChild(this.element.lastChild);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
document.addEventListener("mousedown", () => {
|
||||
document.body.classList.remove("show-focus")
|
||||
})
|
||||
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key === "Tab") {
|
||||
document.body.classList.add("show-focus")
|
||||
}
|
||||
})
|
|
@ -0,0 +1,117 @@
|
|||
import {q, ElemJS} from "/static/js/elemjs/elemjs.js"
|
||||
|
||||
const video = q("#video")
|
||||
const audio = q("#audio")
|
||||
|
||||
const videoFormats = new Map()
|
||||
const audioFormats = new Map()
|
||||
for (const f of [].concat(
|
||||
data.formatStreams.map(f => (f.isAdaptive = false, f)),
|
||||
data.adaptiveFormats.map(f => (f.isAdaptive = true, f))
|
||||
)) {
|
||||
if (f.type.startsWith("video")) {
|
||||
videoFormats.set(f.itag, f)
|
||||
} else {
|
||||
audioFormats.set(f.itag, f)
|
||||
}
|
||||
}
|
||||
|
||||
function getBestAudioFormat() {
|
||||
let best = null
|
||||
for (const f of audioFormats.values()) {
|
||||
if (best === null || f.bitrate > best.bitrate) {
|
||||
best = f
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
class FormatLoader {
|
||||
constructor() {
|
||||
this.npv = videoFormats.get(q("#video").getAttribute("data-itag"))
|
||||
this.npa = null
|
||||
}
|
||||
|
||||
play(itag) {
|
||||
this.npv = videoFormats.get(itag)
|
||||
if (this.npv.isAdaptive) {
|
||||
this.npa = getBestAudioFormat()
|
||||
} else {
|
||||
this.npa = null
|
||||
}
|
||||
this.update()
|
||||
}
|
||||
|
||||
update() {
|
||||
const lastTime = video.currentTime
|
||||
video.src = this.npv.url
|
||||
video.currentTime = lastTime
|
||||
if (this.npa) {
|
||||
audio.src = this.npa.url
|
||||
audio.currentTime = lastTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatLoader = new FormatLoader()
|
||||
|
||||
class QualitySelect extends ElemJS {
|
||||
constructor() {
|
||||
super(q("#quality-select"))
|
||||
this.on("input", this.onInput.bind(this))
|
||||
}
|
||||
|
||||
onInput() {
|
||||
const itag = this.element.value
|
||||
formatLoader.play(itag)
|
||||
}
|
||||
}
|
||||
|
||||
const qualitySelect = new QualitySelect()
|
||||
|
||||
function playbackIntervention(event) {
|
||||
console.log(event.target.tagName.toLowerCase(), event.type)
|
||||
if (audio.src) {
|
||||
let target = event.target
|
||||
let targetName = target.tagName.toLowerCase()
|
||||
let other = (event.target === video ? audio : video)
|
||||
switch (event.type) {
|
||||
case "durationchange":
|
||||
target.ready = false;
|
||||
break;
|
||||
case "seeked":
|
||||
target.ready = false;
|
||||
target.pause();
|
||||
other.currentTime = target.currentTime;
|
||||
break;
|
||||
case "play":
|
||||
other.currentTime = target.currentTime;
|
||||
other.play();
|
||||
break;
|
||||
case "pause":
|
||||
other.currentTime = target.currentTime;
|
||||
other.pause();
|
||||
case "playing":
|
||||
other.currentTime = target.currentTime;
|
||||
break;
|
||||
case "ratechange":
|
||||
other.rate = target.rate;
|
||||
break;
|
||||
// case "stalled":
|
||||
// case "waiting":
|
||||
// target.pause();
|
||||
// break;
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore this does exist
|
||||
// if (event.type == "canplaythrough" && !video.manualPaused) video.play();
|
||||
}
|
||||
}
|
||||
|
||||
for (let eventName of ["pause", "play", "seeked"]) {
|
||||
video.addEventListener(eventName, playbackIntervention)
|
||||
}
|
||||
for (let eventName of ["canplaythrough", "waiting", "stalled"]) {
|
||||
video.addEventListener(eventName, playbackIntervention)
|
||||
audio.addEventListener(eventName, playbackIntervention)
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"checkJs": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,622 @@
|
|||
{
|
||||
"name": "cloudtube",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
"node-fetch": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
|
||||
"integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA=="
|
||||
},
|
||||
"pinski": {
|
||||
"version": "file:../pinski",
|
||||
"requires": {
|
||||
"mime": "^2.4.4",
|
||||
"pug": "^2.0.3",
|
||||
"sass": "^1.26.5",
|
||||
"ws": "^7.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/babel-types": {
|
||||
"version": "7.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel-types/-/babel-types-7.0.7.tgz",
|
||||
"integrity": "sha512-dBtBbrc+qTHy1WdfHYjBwRln4+LWqASWakLHsWHR2NWHIFkv4W3O070IGoGLEBrJBvct3r0L1BUPuvURi7kYUQ=="
|
||||
},
|
||||
"@types/babylon": {
|
||||
"version": "6.16.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babylon/-/babylon-6.16.5.tgz",
|
||||
"integrity": "sha512-xH2e58elpj1X4ynnKp9qSnWlsRTIs6n3tgLGNfwAGHwePw0mulHQllV34n0T25uYSu1k0hRKkWXF890B1yS47w==",
|
||||
"requires": {
|
||||
"@types/babel-types": "*"
|
||||
}
|
||||
},
|
||||
"acorn": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz",
|
||||
"integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo="
|
||||
},
|
||||
"acorn-globals": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-3.1.0.tgz",
|
||||
"integrity": "sha1-/YJw9x+7SZawBPqIDuXUZXOnMb8=",
|
||||
"requires": {
|
||||
"acorn": "^4.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"acorn": {
|
||||
"version": "4.0.13",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz",
|
||||
"integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c="
|
||||
}
|
||||
}
|
||||
},
|
||||
"align-text": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz",
|
||||
"integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=",
|
||||
"requires": {
|
||||
"kind-of": "^3.0.2",
|
||||
"longest": "^1.0.1",
|
||||
"repeat-string": "^1.5.2"
|
||||
}
|
||||
},
|
||||
"anymatch": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz",
|
||||
"integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==",
|
||||
"requires": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
}
|
||||
},
|
||||
"asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY="
|
||||
},
|
||||
"async-limiter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz",
|
||||
"integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg=="
|
||||
},
|
||||
"babel-runtime": {
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
|
||||
"integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
|
||||
"requires": {
|
||||
"core-js": "^2.4.0",
|
||||
"regenerator-runtime": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"babel-types": {
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz",
|
||||
"integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=",
|
||||
"requires": {
|
||||
"babel-runtime": "^6.26.0",
|
||||
"esutils": "^2.0.2",
|
||||
"lodash": "^4.17.4",
|
||||
"to-fast-properties": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"babylon": {
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz",
|
||||
"integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ=="
|
||||
},
|
||||
"binary-extensions": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz",
|
||||
"integrity": "sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow=="
|
||||
},
|
||||
"braces": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
|
||||
"requires": {
|
||||
"fill-range": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"center-align": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz",
|
||||
"integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=",
|
||||
"requires": {
|
||||
"align-text": "^0.1.3",
|
||||
"lazy-cache": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"character-parser": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz",
|
||||
"integrity": "sha1-x84o821LzZdE5f/CxfzeHHMmH8A=",
|
||||
"requires": {
|
||||
"is-regex": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"chokidar": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.0.tgz",
|
||||
"integrity": "sha512-aXAaho2VJtisB/1fg1+3nlLJqGOuewTzQpd/Tz0yTg2R0e4IGtshYvtjowyEumcBv2z+y4+kc75Mz7j5xJskcQ==",
|
||||
"requires": {
|
||||
"anymatch": "~3.1.1",
|
||||
"braces": "~3.0.2",
|
||||
"fsevents": "~2.1.2",
|
||||
"glob-parent": "~5.1.0",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.4.0"
|
||||
}
|
||||
},
|
||||
"clean-css": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.1.tgz",
|
||||
"integrity": "sha512-4ZxI6dy4lrY6FHzfiy1aEOXgu4LIsW2MhwG0VBKdcoGoH/XLFgaHSdLTGr4O8Be6A8r3MOphEiI8Gc1n0ecf3g==",
|
||||
"requires": {
|
||||
"source-map": "~0.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"constantinople": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/constantinople/-/constantinople-3.1.2.tgz",
|
||||
"integrity": "sha512-yePcBqEFhLOqSBtwYOGGS1exHo/s1xjekXiinh4itpNQGCu4KA1euPh1fg07N2wMITZXQkBz75Ntdt1ctGZouw==",
|
||||
"requires": {
|
||||
"@types/babel-types": "^7.0.0",
|
||||
"@types/babylon": "^6.16.2",
|
||||
"babel-types": "^6.26.0",
|
||||
"babylon": "^6.18.0"
|
||||
}
|
||||
},
|
||||
"core-js": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.9.tgz",
|
||||
"integrity": "sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A=="
|
||||
},
|
||||
"decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA="
|
||||
},
|
||||
"doctypes": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz",
|
||||
"integrity": "sha1-6oCxBqh1OHdOijpKWv4pPeSJ4Kk="
|
||||
},
|
||||
"esutils": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz",
|
||||
"integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs="
|
||||
},
|
||||
"fill-range": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
||||
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
|
||||
"requires": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"fsevents": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz",
|
||||
"integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==",
|
||||
"optional": true
|
||||
},
|
||||
"function-bind": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
||||
},
|
||||
"glob-parent": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz",
|
||||
"integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==",
|
||||
"requires": {
|
||||
"is-glob": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"has": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
|
||||
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
|
||||
"requires": {
|
||||
"function-bind": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"requires": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"is-buffer": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
|
||||
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
|
||||
},
|
||||
"is-expression": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-expression/-/is-expression-3.0.0.tgz",
|
||||
"integrity": "sha1-Oayqa+f9HzRx3ELHQW5hwkMXrJ8=",
|
||||
"requires": {
|
||||
"acorn": "~4.0.2",
|
||||
"object-assign": "^4.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"acorn": {
|
||||
"version": "4.0.13",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz",
|
||||
"integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c="
|
||||
}
|
||||
}
|
||||
},
|
||||
"is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI="
|
||||
},
|
||||
"is-glob": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
|
||||
"integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
|
||||
"requires": {
|
||||
"is-extglob": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
|
||||
},
|
||||
"is-promise": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz",
|
||||
"integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o="
|
||||
},
|
||||
"is-regex": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz",
|
||||
"integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=",
|
||||
"requires": {
|
||||
"has": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"js-stringify": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz",
|
||||
"integrity": "sha1-Fzb939lyTyijaCrcYjCufk6Weds="
|
||||
},
|
||||
"jstransformer": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz",
|
||||
"integrity": "sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM=",
|
||||
"requires": {
|
||||
"is-promise": "^2.0.0",
|
||||
"promise": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"kind-of": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
|
||||
"integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
|
||||
"requires": {
|
||||
"is-buffer": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"lazy-cache": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz",
|
||||
"integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4="
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.19",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
|
||||
"integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ=="
|
||||
},
|
||||
"longest": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz",
|
||||
"integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc="
|
||||
},
|
||||
"mime": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz",
|
||||
"integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA=="
|
||||
},
|
||||
"normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
|
||||
},
|
||||
"path-parse": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
|
||||
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
|
||||
},
|
||||
"picomatch": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
|
||||
"integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg=="
|
||||
},
|
||||
"promise": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
|
||||
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
|
||||
"requires": {
|
||||
"asap": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"pug": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pug/-/pug-2.0.4.tgz",
|
||||
"integrity": "sha512-XhoaDlvi6NIzL49nu094R2NA6P37ijtgMDuWE+ofekDChvfKnzFal60bhSdiy8y2PBO6fmz3oMEIcfpBVRUdvw==",
|
||||
"requires": {
|
||||
"pug-code-gen": "^2.0.2",
|
||||
"pug-filters": "^3.1.1",
|
||||
"pug-lexer": "^4.1.0",
|
||||
"pug-linker": "^3.0.6",
|
||||
"pug-load": "^2.0.12",
|
||||
"pug-parser": "^5.0.1",
|
||||
"pug-runtime": "^2.0.5",
|
||||
"pug-strip-comments": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"pug-attrs": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-2.0.4.tgz",
|
||||
"integrity": "sha512-TaZ4Z2TWUPDJcV3wjU3RtUXMrd3kM4Wzjbe3EWnSsZPsJ3LDI0F3yCnf2/W7PPFF+edUFQ0HgDL1IoxSz5K8EQ==",
|
||||
"requires": {
|
||||
"constantinople": "^3.0.1",
|
||||
"js-stringify": "^1.0.1",
|
||||
"pug-runtime": "^2.0.5"
|
||||
}
|
||||
},
|
||||
"pug-code-gen": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-2.0.2.tgz",
|
||||
"integrity": "sha512-kROFWv/AHx/9CRgoGJeRSm+4mLWchbgpRzTEn8XCiwwOy6Vh0gAClS8Vh5TEJ9DBjaP8wCjS3J6HKsEsYdvaCw==",
|
||||
"requires": {
|
||||
"constantinople": "^3.1.2",
|
||||
"doctypes": "^1.1.0",
|
||||
"js-stringify": "^1.0.1",
|
||||
"pug-attrs": "^2.0.4",
|
||||
"pug-error": "^1.3.3",
|
||||
"pug-runtime": "^2.0.5",
|
||||
"void-elements": "^2.0.1",
|
||||
"with": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"pug-error": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/pug-error/-/pug-error-1.3.3.tgz",
|
||||
"integrity": "sha512-qE3YhESP2mRAWMFJgKdtT5D7ckThRScXRwkfo+Erqga7dyJdY3ZquspprMCj/9sJ2ijm5hXFWQE/A3l4poMWiQ=="
|
||||
},
|
||||
"pug-filters": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-3.1.1.tgz",
|
||||
"integrity": "sha512-lFfjNyGEyVWC4BwX0WyvkoWLapI5xHSM3xZJFUhx4JM4XyyRdO8Aucc6pCygnqV2uSgJFaJWW3Ft1wCWSoQkQg==",
|
||||
"requires": {
|
||||
"clean-css": "^4.1.11",
|
||||
"constantinople": "^3.0.1",
|
||||
"jstransformer": "1.0.0",
|
||||
"pug-error": "^1.3.3",
|
||||
"pug-walk": "^1.1.8",
|
||||
"resolve": "^1.1.6",
|
||||
"uglify-js": "^2.6.1"
|
||||
}
|
||||
},
|
||||
"pug-lexer": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-4.1.0.tgz",
|
||||
"integrity": "sha512-i55yzEBtjm0mlplW4LoANq7k3S8gDdfC6+LThGEvsK4FuobcKfDAwt6V4jKPH9RtiE3a2Akfg5UpafZ1OksaPA==",
|
||||
"requires": {
|
||||
"character-parser": "^2.1.1",
|
||||
"is-expression": "^3.0.0",
|
||||
"pug-error": "^1.3.3"
|
||||
}
|
||||
},
|
||||
"pug-linker": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-3.0.6.tgz",
|
||||
"integrity": "sha512-bagfuHttfQOpANGy1Y6NJ+0mNb7dD2MswFG2ZKj22s8g0wVsojpRlqveEQHmgXXcfROB2RT6oqbPYr9EN2ZWzg==",
|
||||
"requires": {
|
||||
"pug-error": "^1.3.3",
|
||||
"pug-walk": "^1.1.8"
|
||||
}
|
||||
},
|
||||
"pug-load": {
|
||||
"version": "2.0.12",
|
||||
"resolved": "https://registry.npmjs.org/pug-load/-/pug-load-2.0.12.tgz",
|
||||
"integrity": "sha512-UqpgGpyyXRYgJs/X60sE6SIf8UBsmcHYKNaOccyVLEuT6OPBIMo6xMPhoJnqtB3Q3BbO4Z3Bjz5qDsUWh4rXsg==",
|
||||
"requires": {
|
||||
"object-assign": "^4.1.0",
|
||||
"pug-walk": "^1.1.8"
|
||||
}
|
||||
},
|
||||
"pug-parser": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-5.0.1.tgz",
|
||||
"integrity": "sha512-nGHqK+w07p5/PsPIyzkTQfzlYfuqoiGjaoqHv1LjOv2ZLXmGX1O+4Vcvps+P4LhxZ3drYSljjq4b+Naid126wA==",
|
||||
"requires": {
|
||||
"pug-error": "^1.3.3",
|
||||
"token-stream": "0.0.1"
|
||||
}
|
||||
},
|
||||
"pug-runtime": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-2.0.5.tgz",
|
||||
"integrity": "sha512-P+rXKn9un4fQY77wtpcuFyvFaBww7/91f3jHa154qU26qFAnOe6SW1CbIDcxiG5lLK9HazYrMCCuDvNgDQNptw=="
|
||||
},
|
||||
"pug-strip-comments": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-1.0.4.tgz",
|
||||
"integrity": "sha512-i5j/9CS4yFhSxHp5iKPHwigaig/VV9g+FgReLJWWHEHbvKsbqL0oP/K5ubuLco6Wu3Kan5p7u7qk8A4oLLh6vw==",
|
||||
"requires": {
|
||||
"pug-error": "^1.3.3"
|
||||
}
|
||||
},
|
||||
"pug-walk": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-1.1.8.tgz",
|
||||
"integrity": "sha512-GMu3M5nUL3fju4/egXwZO0XLi6fW/K3T3VTgFQ14GxNi8btlxgT5qZL//JwZFm/2Fa64J/PNS8AZeys3wiMkVA=="
|
||||
},
|
||||
"readdirp": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.4.0.tgz",
|
||||
"integrity": "sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ==",
|
||||
"requires": {
|
||||
"picomatch": "^2.2.1"
|
||||
}
|
||||
},
|
||||
"regenerator-runtime": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
|
||||
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
|
||||
},
|
||||
"repeat-string": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
|
||||
"integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc="
|
||||
},
|
||||
"resolve": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.11.1.tgz",
|
||||
"integrity": "sha512-vIpgF6wfuJOZI7KKKSP+HmiKggadPQAdsp5HiC1mvqnfp0gF1vdwgBWZIdrVft9pgqoMFQN+R7BSWZiBxx+BBw==",
|
||||
"requires": {
|
||||
"path-parse": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"right-align": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz",
|
||||
"integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=",
|
||||
"requires": {
|
||||
"align-text": "^0.1.1"
|
||||
}
|
||||
},
|
||||
"sass": {
|
||||
"version": "1.26.5",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.26.5.tgz",
|
||||
"integrity": "sha512-FG2swzaZUiX53YzZSjSakzvGtlds0lcbF+URuU9mxOv7WBh7NhXEVDa4kPKN4hN6fC2TkOTOKqiqp6d53N9X5Q==",
|
||||
"requires": {
|
||||
"chokidar": ">=2.0.0 <4.0.0"
|
||||
}
|
||||
},
|
||||
"to-fast-properties": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz",
|
||||
"integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc="
|
||||
},
|
||||
"to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"requires": {
|
||||
"is-number": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"token-stream": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/token-stream/-/token-stream-0.0.1.tgz",
|
||||
"integrity": "sha1-zu78cXp2xDFvEm0LnbqlXX598Bo="
|
||||
},
|
||||
"uglify-js": {
|
||||
"version": "2.8.29",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz",
|
||||
"integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=",
|
||||
"requires": {
|
||||
"source-map": "~0.5.1",
|
||||
"uglify-to-browserify": "~1.0.0",
|
||||
"yargs": "~3.10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"camelcase": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz",
|
||||
"integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk="
|
||||
},
|
||||
"cliui": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz",
|
||||
"integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=",
|
||||
"requires": {
|
||||
"center-align": "^0.1.1",
|
||||
"right-align": "^0.1.1",
|
||||
"wordwrap": "0.0.2"
|
||||
}
|
||||
},
|
||||
"source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
"integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
|
||||
},
|
||||
"yargs": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz",
|
||||
"integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=",
|
||||
"requires": {
|
||||
"camelcase": "^1.0.2",
|
||||
"cliui": "^2.1.0",
|
||||
"decamelize": "^1.0.0",
|
||||
"window-size": "0.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uglify-to-browserify": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz",
|
||||
"integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=",
|
||||
"optional": true
|
||||
},
|
||||
"void-elements": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz",
|
||||
"integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w="
|
||||
},
|
||||
"window-size": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz",
|
||||
"integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0="
|
||||
},
|
||||
"with": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/with/-/with-5.1.1.tgz",
|
||||
"integrity": "sha1-+k2qktrzLE6pTtRTyB8EaGtXXf4=",
|
||||
"requires": {
|
||||
"acorn": "^3.1.0",
|
||||
"acorn-globals": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"wordwrap": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz",
|
||||
"integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8="
|
||||
},
|
||||
"ws": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.1.1.tgz",
|
||||
"integrity": "sha512-o41D/WmDeca0BqYhsr3nJzQyg9NF5X8l/UdnFNux9cS3lwB+swm8qGWX5rn+aD6xfBU3rGmtHij7g7x6LxFU3A==",
|
||||
"requires": {
|
||||
"async-limiter": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "cloudtube",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.0",
|
||||
"pinski": "file:../pinski"
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
doctype html
|
|
@ -0,0 +1,15 @@
|
|||
doctype html
|
||||
html
|
||||
head
|
||||
meta(charset="utf-8")
|
||||
meta(name="viewport" value="width=device-width, initial-scale=1")
|
||||
link(rel="stylesheet" type="text/css" href=getStaticURL("sass", "/main.sass"))
|
||||
script(type="module" src=getStaticURL("html", "/static/js/focus.js"))
|
||||
block head
|
||||
|
||||
body.show-focus
|
||||
nav.main-nav
|
||||
a(href="/").link.home CloudTube
|
||||
input(type="text" placeholder="Search" name="q" autocomplete="off").search
|
||||
|
||||
block content
|
|
@ -0,0 +1,68 @@
|
|||
extends includes/layout.pug
|
||||
|
||||
block head
|
||||
title= video.title
|
||||
script(type="module" src=getStaticURL("html", "/static/js/player.js"))
|
||||
script const data = !{JSON.stringify(video)}
|
||||
|
||||
block content
|
||||
- const sortedFormatStreams = video.formatStreams.slice().sort((a, b) => b.second__height - a.second__height)
|
||||
- const sortedVideoAdaptiveFormats = video.adaptiveFormats.filter(f => f.type.startsWith("video")).sort((a, b) => b.second__height - a.second__height)
|
||||
main.video-page
|
||||
.main-video-section
|
||||
.video-container
|
||||
- const format = sortedFormatStreams[0]
|
||||
video(controls preload="auto" width=format.second__width height=format.second__height data-itag=format.itag)#video.video
|
||||
source(src=format.url type=format.type)
|
||||
|
||||
#current-time-container
|
||||
#end-cards-container
|
||||
.info
|
||||
header.info-main
|
||||
h1.title= video.title
|
||||
.author
|
||||
a(href=`/channel/${video.authorId}`).author-link= `Uploaded by ${video.author}`
|
||||
.info-secondary
|
||||
- const date = new Date(video.published*1000)
|
||||
- const month = new Intl.DateTimeFormat("en-US", {month: "short"}).format(date.getTime())
|
||||
div= `Uploaded ${date.getUTCDate()} ${month} ${date.getUTCFullYear()}`
|
||||
div= video.second__viewCountText
|
||||
div(style=`--rating: ${video.rating*20}%`)#rating-bar.rating-bar
|
||||
|
||||
audio(preload="auto")#audio
|
||||
#live-event-notice
|
||||
#audio-loading-display
|
||||
|
||||
.video-button-container
|
||||
button.border-look#subscribe Subscribe
|
||||
button.border-look#theatre Theatre
|
||||
select(autocomplete="off").border-look#quality-select
|
||||
each f in sortedFormatStreams
|
||||
option(value=f.itag)= `${f.qualityLabel} ${f.container}`
|
||||
each f in sortedVideoAdaptiveFormats
|
||||
option(value=f.itag)= `${f.qualityLabel} ${f.container} *`
|
||||
|
||||
.video-button-container
|
||||
a(href="/subscriptions").border-look
|
||||
img(src="/static/images/search.svg" width=17 height=17 alt="").button-icon
|
||||
| Search
|
||||
button.border-look#share Share
|
||||
a.border-look YouTube
|
||||
a.border-look Iv: Snopyta
|
||||
|
||||
.description!= video.descriptionHtml
|
||||
|
||||
aside.related-videos
|
||||
h2.related-header Related videos
|
||||
each r in video.recommendedVideos
|
||||
.related-video
|
||||
- let link = `/watch?v=${r.videoId}`
|
||||
a(href=link).thumbnail
|
||||
img(src=`https://i.ytimg.com/vi/${r.videoId}/mqdefault.jpg` width=320 height=180 alt="").image
|
||||
span.duration= r.second__lengthText
|
||||
.info
|
||||
div.title: a(href=link).title-link= r.title
|
||||
div.author-line
|
||||
a(href=`/channel/${authorId}`).author= r.author
|
||||
= ` • `
|
||||
span.views= r.viewCountText
|
|
@ -0,0 +1,34 @@
|
|||
@use "colors.sass" as c
|
||||
|
||||
body
|
||||
background-color: c.$bg-dark
|
||||
color: c.$fg-main
|
||||
font-family: "Bariol", sans-serif
|
||||
font-size: 18px
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
a
|
||||
color: c.$link
|
||||
|
||||
input, select, button
|
||||
font-family: inherit
|
||||
font-size: 16px
|
||||
|
||||
button
|
||||
cursor: pointer
|
||||
|
||||
:-moz-focusring
|
||||
outline: none
|
||||
|
||||
::-moz-focus-inner
|
||||
border: 0
|
||||
|
||||
select:-moz-focusring
|
||||
color: transparent
|
||||
text-shadow: 0 0 0 c.$fg-bright
|
||||
|
||||
body.show-focus
|
||||
a, select, button, input, video
|
||||
&:focus
|
||||
outline: 2px dotted #ddd
|
|
@ -0,0 +1,14 @@
|
|||
$bg-darkest: #202123
|
||||
$bg-darker: #303336
|
||||
$bg-dark: #36393f
|
||||
$bg-accent: #4f5359
|
||||
$bg-accent-x: #3f4247
|
||||
$bg-accent-area: #44474b
|
||||
|
||||
$fg-bright: #fff
|
||||
$fg-main: #ddd
|
||||
$fg-dim: #bbb
|
||||
|
||||
$edge-grey: #808080
|
||||
|
||||
$link: #72b4f6
|
|
@ -0,0 +1,117 @@
|
|||
@use "colors.sass" as c
|
||||
|
||||
.video-page
|
||||
display: grid
|
||||
grid-auto-flow: row
|
||||
padding: 20px
|
||||
grid-gap: 16px
|
||||
|
||||
@media screen and (min-width: 1000px)
|
||||
grid-template-columns: 1fr 400px
|
||||
|
||||
.main-video-section
|
||||
.video-container
|
||||
text-align: center
|
||||
|
||||
.video
|
||||
display: inline-block
|
||||
width: 100%
|
||||
height: auto
|
||||
max-height: 80vh
|
||||
|
||||
.info
|
||||
display: flex
|
||||
margin: 8px 4px 16px
|
||||
font-size: 17px
|
||||
|
||||
.info-main
|
||||
flex: 1
|
||||
|
||||
.title
|
||||
margin: 0px 0px 4px
|
||||
font-size: 30px
|
||||
font-weight: normal
|
||||
color: c.$fg-bright
|
||||
|
||||
.author-link
|
||||
color: c.$fg-main
|
||||
text-decoration: none
|
||||
|
||||
&:hover, &:active
|
||||
color: c.$fg-bright
|
||||
text-decoration: underline
|
||||
|
||||
.info-secondary
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: end
|
||||
margin-top: 6px
|
||||
margin-left: 6px
|
||||
|
||||
.rating-bar
|
||||
margin-top: 8px
|
||||
width: 140px
|
||||
height: 8px
|
||||
border-radius: 3px
|
||||
background: linear-gradient(to right, #1a1 var(--rating), #bbb var(--rating))
|
||||
|
||||
.description
|
||||
font-size: 17px
|
||||
margin: 16px 4px 4px 4px
|
||||
background-color: c.$bg-accent-area
|
||||
padding: 12px
|
||||
border-radius: 4px
|
||||
|
||||
.related-header
|
||||
margin: 4px 0px 12px 2px
|
||||
font-weight: normal
|
||||
font-size: 26px
|
||||
|
||||
.related-video
|
||||
display: grid
|
||||
grid-template-columns: 160px 1fr
|
||||
grid-gap: 8px
|
||||
align-items: start
|
||||
align-content: start
|
||||
margin-bottom: 16px
|
||||
|
||||
.thumbnail
|
||||
position: relative
|
||||
display: flex
|
||||
background: c.$bg-darkest
|
||||
|
||||
.image
|
||||
width: 160px
|
||||
height: 90px
|
||||
|
||||
.duration
|
||||
position: absolute
|
||||
bottom: 3px
|
||||
right: 3px
|
||||
color: c.$fg-bright
|
||||
font-size: 14px
|
||||
background: rgba(20, 20, 20, 0.85)
|
||||
line-height: 1
|
||||
padding: 3px 5px 4px
|
||||
border-radius: 4px
|
||||
|
||||
.title
|
||||
font-size: 15px
|
||||
line-height: 1.2
|
||||
|
||||
.title-link
|
||||
color: c.$fg-main
|
||||
text-decoration: none
|
||||
|
||||
.author-line
|
||||
margin-top: 4px
|
||||
font-size: 15px
|
||||
color: c.$fg-dim
|
||||
|
||||
.author
|
||||
color: c.$fg-dim
|
||||
text-decoration: none
|
||||
|
||||
&:hover, &:active
|
||||
color: c.$fg-bright
|
||||
text-decoration: underline
|
|
@ -0,0 +1,88 @@
|
|||
@use "includes/base.sass"
|
||||
@use "includes/colors.sass" as c
|
||||
@use "sass:selector"
|
||||
@use "includes/video-page.sass"
|
||||
|
||||
@font-face
|
||||
font-family: "Bariol"
|
||||
src: url(/static/fonts/bariol.woff)
|
||||
|
||||
@mixin button-base
|
||||
appearance: none
|
||||
-moz-appearance: none
|
||||
color: c.$fg-bright
|
||||
border: none
|
||||
border-radius: 4px
|
||||
padding: 8px
|
||||
margin: 0
|
||||
text-decoration: none
|
||||
line-height: 1.25
|
||||
|
||||
@at-root #{selector.unify(&, "select")}
|
||||
padding: 7px 27px 7px 8px
|
||||
background: url(/static/images/arrow-down-wide.svg) right 53% no-repeat c.$bg-accent-x
|
||||
|
||||
@at-root #{selector.unify(&, "a")}
|
||||
padding: 7px 8px
|
||||
|
||||
.button-icon
|
||||
position: relative
|
||||
top: 3px
|
||||
margin-right: 8px
|
||||
margin-left: 2px
|
||||
|
||||
@mixin button-bg
|
||||
@include button-base
|
||||
|
||||
background-color: c.$bg-accent-x
|
||||
|
||||
@mixin border-button
|
||||
@include button-bg
|
||||
|
||||
border: 1px solid c.$edge-grey
|
||||
|
||||
@mixin button-size
|
||||
margin: 4px
|
||||
font-size: 16px
|
||||
|
||||
@mixin button-hover
|
||||
&:hover
|
||||
background-color: c.$bg-accent
|
||||
|
||||
&:active
|
||||
background-color: c.$bg-dark
|
||||
|
||||
.border-look
|
||||
@include border-button
|
||||
@include button-size
|
||||
@include button-hover
|
||||
|
||||
.main-nav
|
||||
background-color: c.$bg-accent
|
||||
display: flex
|
||||
padding: 8px
|
||||
box-shadow: 0px 0px 20px 5px rgba(0, 0, 0, 0.1)
|
||||
|
||||
.link
|
||||
@include button-base
|
||||
text-decoration: none
|
||||
margin: 1px 8px 1px 0px
|
||||
font-size: 20px
|
||||
|
||||
&.home
|
||||
font-weight: bold
|
||||
|
||||
&, &:visited
|
||||
color: #fff
|
||||
|
||||
&:focus, &:hover
|
||||
background-color: c.$bg-accent-x
|
||||
|
||||
.search
|
||||
@include button-bg
|
||||
flex: 1
|
||||
margin: 1px
|
||||
|
||||
&:hover, &:focus
|
||||
border: 1px solid c.$edge-grey
|
||||
margin: 0px
|
|
@ -0,0 +1,26 @@
|
|||
const {Pinski} = require("pinski")
|
||||
const {setInstance} = require("pinski/plugins")
|
||||
|
||||
const server = new Pinski({
|
||||
port: 8080,
|
||||
relativeRoot: __dirname,
|
||||
filesDir: "html"
|
||||
})
|
||||
|
||||
setInstance(server)
|
||||
|
||||
server.addSassDir("sass", ["sass/includes"])
|
||||
server.addRoute("/static/css/main.css", "sass/main.sass", "sass")
|
||||
|
||||
server.addPugDir("pug", ["pug/includes"])
|
||||
|
||||
server.addStaticHashTableDir("html/static/js")
|
||||
server.addStaticHashTableDir("html/static/js/elemjs")
|
||||
|
||||
server.addAPIDir("api")
|
||||
|
||||
server.startServer()
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(server.staticFileTable, server.pageHandlers)
|
||||
}, 2000)
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue