diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..32c7cda --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Editor crud files +*~ +\#*# +.#* +.vscode + +# Auto-generated files +node_modules \ No newline at end of file diff --git a/api/youtube.js b/api/youtube.js new file mode 100644 index 0000000..2dafdc2 --- /dev/null +++ b/api/youtube.js @@ -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('""', () => body); + let shareWords = getShareWords(fill[0]); + page = page.replace('""', () => JSON.stringify(shareWords)); + page = page.replace("", () => `${data.title} — CloudTube video`); + while (page.includes("yt.www.watch.player.seekTo")) page = page.replace("yt.www.watch.player.seekTo", "seekTo"); + let metaOGTags = + `\n`+ + `\n`+ + `\n`+ + `\n`+ + `\n` + page = page.replace("", () => 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('""', () => JSON.stringify(data)); + page = page.replace("", () => `${data.author} — CloudTube channel`); + let metaOGTags = + `\n`+ + `\n`+ + // `\n`+ + `\n`+ + `\n` + page = page.replace("", () => 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('""', () => body); + page = page.replace("", () => `${data.title} — CloudTube playlist`); + while (page.includes("yt.www.watch.player.seekTo")) page = page.replace("yt.www.watch.player.seekTo", "seekTo"); + let metaOGTags = + `\n`+ + `\n`+ + `\n`+ + `\n` + if (data.videos[0]) metaOGTags += `\n`; + page = page.replace("", () => 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('""', () => body); + page = page.replace("", () => `${decodeURIComponent(params.get("q"))} — CloudTube search`); + let metaOGTags = + `\n`+ + `\n`+ + `\n`+ + `\n` + page = page.replace("", () => 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("", ""); + page = page.replace("", `CloudTube search`); + let metaOGTags = + `\n`+ + `\n`+ + `\n`+ + `\n` + page = page.replace("", () => 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.
`}] + } + }, + { + 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]); + }); + } + }); + }); + } + }*/ +] diff --git a/html/static/fonts/bariol.woff b/html/static/fonts/bariol.woff new file mode 100755 index 0000000..79981c2 Binary files /dev/null and b/html/static/fonts/bariol.woff differ diff --git a/html/static/images/arrow-down-wide.svg b/html/static/images/arrow-down-wide.svg new file mode 100644 index 0000000..495bd83 --- /dev/null +++ b/html/static/images/arrow-down-wide.svg @@ -0,0 +1,3 @@ + + + diff --git a/html/static/images/search.svg b/html/static/images/search.svg new file mode 100644 index 0000000..b00640b --- /dev/null +++ b/html/static/images/search.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/html/static/js/elemjs/elemjs.js b/html/static/js/elemjs/elemjs.js new file mode 100644 index 0000000..62be34e --- /dev/null +++ b/html/static/js/elemjs/elemjs.js @@ -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}; diff --git a/html/static/js/elemjs/elemjs.mjs b/html/static/js/elemjs/elemjs.mjs deleted file mode 100644 index 563d921..0000000 --- a/html/static/js/elemjs/elemjs.mjs +++ /dev/null @@ -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); - } -} diff --git a/html/static/js/focus.js b/html/static/js/focus.js new file mode 100644 index 0000000..12b2dc3 --- /dev/null +++ b/html/static/js/focus.js @@ -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") + } +}) diff --git a/html/static/js/player.js b/html/static/js/player.js new file mode 100644 index 0000000..69b016d --- /dev/null +++ b/html/static/js/player.js @@ -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) +} diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..834a9ea --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "checkJs": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d93bc22 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dd38fd1 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pug/includes/head.pug b/pug/includes/head.pug new file mode 100644 index 0000000..bc0fb7a --- /dev/null +++ b/pug/includes/head.pug @@ -0,0 +1 @@ +doctype html diff --git a/pug/includes/layout.pug b/pug/includes/layout.pug new file mode 100644 index 0000000..438574a --- /dev/null +++ b/pug/includes/layout.pug @@ -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 diff --git a/pug/video.pug b/pug/video.pug new file mode 100644 index 0000000..b16718f --- /dev/null +++ b/pug/video.pug @@ -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 diff --git a/sass/includes/base.sass b/sass/includes/base.sass new file mode 100644 index 0000000..33eeef1 --- /dev/null +++ b/sass/includes/base.sass @@ -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 diff --git a/sass/includes/colors.sass b/sass/includes/colors.sass new file mode 100644 index 0000000..b0eec04 --- /dev/null +++ b/sass/includes/colors.sass @@ -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 diff --git a/sass/includes/video-page.sass b/sass/includes/video-page.sass new file mode 100644 index 0000000..51afc9b --- /dev/null +++ b/sass/includes/video-page.sass @@ -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 diff --git a/sass/main.sass b/sass/main.sass new file mode 100644 index 0000000..787d6b7 --- /dev/null +++ b/sass/main.sass @@ -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 diff --git a/server.js b/server.js new file mode 100644 index 0000000..01551a4 --- /dev/null +++ b/server.js @@ -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) diff --git a/util/words.txt b/util/words.txt new file mode 100644 index 0000000..f78ccaf --- /dev/null +++ b/util/words.txt @@ -0,0 +1,2048 @@ +abandon +ability +able +about +above +absent +absorb +abstract +absurd +abuse +access +accident +account +accuse +achieve +acid +acoustic +acquire +across +act +action +actor +actress +actual +adapt +add +addict +address +adjust +admit +adult +advance +advice +aerobic +affair +afford +afraid +again +age +agent +agree +ahead +aim +air +airport +aisle +alarm +album +alcohol +alert +alien +all +alley +allow +almost +alone +alpha +already +also +alter +always +amateur +amazing +among +amount +amused +analyst +anchor +ancient +anger +angle +angry +animal +ankle +announce +annual +another +answer +antenna +antique +anxiety +any +apart +apology +appear +apple +approve +april +arch +arctic +area +arena +argue +arm +armed +armor +army +around +arrange +arrest +arrive +arrow +art +artefact +artist +artwork +ask +aspect +assault +asset +assist +assume +asthma +athlete +atom +attack +attend +attitude +attract +auction +audit +august +aunt +author +auto +autumn +average +avocado +avoid +awake +aware +away +awesome +awful +awkward +axis +baby +bachelor +bacon +badge +bag +balance +balcony +ball +bamboo +banana +banner +bar +barely +bargain +barrel +base +basic +basket +battle +beach +bean +beauty +because +become +beef +before +begin +behave +behind +believe +below +belt +bench +benefit +best +betray +better +between +beyond +bicycle +bid +bike +bind +biology +bird +birth +bitter +black +blade +blame +blanket +blast +bleak +bless +blind +blood +blossom +blouse +blue +blur +blush +board +boat +body +boil +bomb +bone +bonus +book +boost +border +boring +borrow +boss +bottom +bounce +box +boy +bracket +brain +brand +brass +brave +bread +breeze +brick +bridge +brief +bright +bring +brisk +broccoli +broken +bronze +broom +brother +brown +brush +bubble +buddy +budget +buffalo +build +bulb +bulk +bullet +bundle +bunker +burden +burger +burst +bus +business +busy +butter +buyer +buzz +cabbage +cabin +cable +cactus +cage +cake +call +calm +camera +camp +can +canal +cancel +candy +cannon +canoe +canvas +canyon +capable +capital +captain +car +carbon +card +cargo +carpet +carry +cart +case +cash +casino +castle +casual +cat +catalog +catch +category +cattle +caught +cause +caution +cave +ceiling +celery +cement +census +century +cereal +certain +chair +chalk +champion +change +chaos +chapter +charge +chase +chat +cheap +check +cheese +chef +cherry +chest +chicken +chief +child +chimney +choice +choose +chronic +chuckle +chunk +churn +cigar +cinnamon +circle +citizen +city +civil +claim +clap +clarify +claw +clay +clean +clerk +clever +click +client +cliff +climb +clinic +clip +clock +clog +close +cloth +cloud +clown +club +clump +cluster +clutch +coach +coast +coconut +code +coffee +coil +coin +collect +color +column +combine +come +comfort +comic +common +company +concert +conduct +confirm +congress +connect +consider +control +convince +cook +cool +copper +copy +coral +core +corn +correct +cost +cotton +couch +country +couple +course +cousin +cover +coyote +crack +cradle +craft +cram +crane +crash +crater +crawl +crazy +cream +credit +creek +crew +cricket +crime +crisp +critic +crop +cross +crouch +crowd +crucial +cruel +cruise +crumble +crunch +crush +cry +crystal +cube +culture +cup +cupboard +curious +current +curtain +curve +cushion +custom +cute +cycle +dad +damage +damp +dance +danger +daring +dash +daughter +dawn +day +deal +debate +debris +decade +december +decide +decline +decorate +decrease +deer +defense +define +defy +degree +delay +deliver +demand +demise +denial +dentist +deny +depart +depend +deposit +depth +deputy +derive +describe +desert +design +desk +despair +destroy +detail +detect +develop +device +devote +diagram +dial +diamond +diary +dice +diesel +diet +differ +digital +dignity +dilemma +dinner +dinosaur +direct +dirt +disagree +discover +disease +dish +dismiss +disorder +display +distance +divert +divide +divorce +dizzy +doctor +document +dog +doll +dolphin +domain +donate +donkey +donor +door +dose +double +dove +draft +dragon +drama +drastic +draw +dream +dress +drift +drill +drink +drip +drive +drop +drum +dry +duck +dumb +dune +during +dust +dutch +duty +dwarf +dynamic +eager +eagle +early +earn +earth +easily +east +easy +echo +ecology +economy +edge +edit +educate +effort +egg +eight +either +elbow +elder +electric +elegant +element +elephant +elevator +elite +else +embark +embody +embrace +emerge +emotion +employ +empower +empty +enable +enact +end +endless +endorse +enemy +energy +enforce +engage +engine +enhance +enjoy +enlist +enough +enrich +enroll +ensure +enter +entire +entry +envelope +episode +equal +equip +era +erase +erode +erosion +error +erupt +escape +essay +essence +estate +eternal +ethics +evidence +evil +evoke +evolve +exact +example +excess +exchange +excite +exclude +excuse +execute +exercise +exhaust +exhibit +exile +exist +exit +exotic +expand +expect +expire +explain +expose +express +extend +extra +eye +eyebrow +fabric +face +faculty +fade +faint +faith +fall +false +fame +family +famous +fan +fancy +fantasy +farm +fashion +fat +fatal +father +fatigue +fault +favorite +feature +february +federal +fee +feed +feel +female +fence +festival +fetch +fever +few +fiber +fiction +field +figure +file +film +filter +final +find +fine +finger +finish +fire +firm +first +fiscal +fish +fit +fitness +fix +flag +flame +flash +flat +flavor +flee +flight +flip +float +flock +floor +flower +fluid +flush +fly +foam +focus +fog +foil +fold +follow +food +foot +force +forest +forget +fork +fortune +forum +forward +fossil +foster +found +fox +fragile +frame +frequent +fresh +friend +fringe +frog +front +frost +frown +frozen +fruit +fuel +fun +funny +furnace +fury +future +gadget +gain +galaxy +gallery +game +gap +garage +garbage +garden +garlic +garment +gas +gasp +gate +gather +gauge +gaze +general +genius +genre +gentle +genuine +gesture +ghost +giant +gift +giggle +ginger +giraffe +girl +give +glad +glance +glare +glass +glide +glimpse +globe +gloom +glory +glove +glow +glue +goat +goddess +gold +good +goose +gorilla +gospel +gossip +govern +gown +grab +grace +grain +grant +grape +grass +gravity +great +green +grid +grief +grit +grocery +group +grow +grunt +guard +guess +guide +guilt +guitar +gun +gym +habit +hair +half +hammer +hamster +hand +happy +harbor +hard +harsh +harvest +hat +have +hawk +hazard +head +health +heart +heavy +hedgehog +height +hello +helmet +help +hen +hero +hidden +high +hill +hint +hip +hire +history +hobby +hockey +hold +hole +holiday +hollow +home +honey +hood +hope +horn +horror +horse +hospital +host +hotel +hour +hover +hub +huge +human +humble +humor +hundred +hungry +hunt +hurdle +hurry +hurt +husband +hybrid +ice +icon +idea +identify +idle +ignore +ill +illegal +illness +image +imitate +immense +immune +impact +impose +improve +impulse +inch +include +income +increase +index +indicate +indoor +industry +infant +inflict +inform +inhale +inherit +initial +inject +injury +inmate +inner +innocent +input +inquiry +insane +insect +inside +inspire +install +intact +interest +into +invest +invite +involve +iron +island +isolate +issue +item +ivory +jacket +jaguar +jar +jazz +jealous +jeans +jelly +jewel +job +join +joke +journey +joy +judge +juice +jump +jungle +junior +junk +just +kangaroo +keen +keep +ketchup +key +kick +kid +kidney +kind +kingdom +kiss +kit +kitchen +kite +kitten +kiwi +knee +knife +knock +know +lab +label +labor +ladder +lady +lake +lamp +language +laptop +large +later +latin +laugh +laundry +lava +law +lawn +lawsuit +layer +lazy +leader +leaf +learn +leave +lecture +left +leg +legal +legend +leisure +lemon +lend +length +lens +leopard +lesson +letter +level +liar +liberty +library +license +life +lift +light +like +limb +limit +link +lion +liquid +list +little +live +lizard +load +loan +lobster +local +lock +logic +lonely +long +loop +lottery +loud +lounge +love +loyal +lucky +luggage +lumber +lunar +lunch +luxury +lyrics +machine +mad +magic +magnet +maid +mail +main +major +make +mammal +man +manage +mandate +mango +mansion +manual +maple +marble +march +margin +marine +market +marriage +mask +mass +master +match +material +math +matrix +matter +maximum +maze +meadow +mean +measure +meat +mechanic +medal +media +melody +melt +member +memory +mention +menu +mercy +merge +merit +merry +mesh +message +metal +method +middle +midnight +milk +million +mimic +mind +minimum +minor +minute +miracle +mirror +misery +miss +mistake +mix +mixed +mixture +mobile +model +modify +mom +moment +monitor +monkey +monster +month +moon +moral +more +morning +mosquito +mother +motion +motor +mountain +mouse +move +movie +much +muffin +mule +multiply +muscle +museum +mushroom +music +must +mutual +myself +mystery +myth +naive +name +napkin +narrow +nasty +nation +nature +near +neck +need +negative +neglect +neither +nephew +nerve +nest +net +network +neutral +never +news +next +nice +night +noble +noise +nominee +noodle +normal +north +nose +notable +note +nothing +notice +novel +now +nuclear +number +nurse +nut +oak +obey +object +oblige +obscure +observe +obtain +obvious +occur +ocean +october +odor +off +offer +office +often +oil +okay +old +olive +olympic +omit +once +one +onion +online +only +open +opera +opinion +oppose +option +orange +orbit +orchard +order +ordinary +organ +orient +original +orphan +ostrich +other +outdoor +outer +output +outside +oval +oven +over +own +owner +oxygen +oyster +ozone +pact +paddle +page +pair +palace +palm +panda +panel +panic +panther +paper +parade +parent +park +parrot +party +pass +patch +path +patient +patrol +pattern +pause +pave +payment +peace +peanut +pear +peasant +pelican +pen +penalty +pencil +people +pepper +perfect +permit +person +pet +phone +photo +phrase +physical +piano +picnic +picture +piece +pig +pigeon +pill +pilot +pink +pioneer +pipe +pistol +pitch +pizza +place +planet +plastic +plate +play +please +pledge +pluck +plug +plunge +poem +poet +point +polar +pole +police +pond +pony +pool +popular +portion +position +possible +post +potato +pottery +poverty +powder +power +practice +praise +predict +prefer +prepare +present +pretty +prevent +price +pride +primary +print +priority +prison +private +prize +problem +process +produce +profit +program +project +promote +proof +property +prosper +protect +proud +provide +public +pudding +pull +pulp +pulse +pumpkin +punch +pupil +puppy +purchase +purity +purpose +purse +push +put +puzzle +pyramid +quality +quantum +quarter +question +quick +quit +quiz +quote +rabbit +raccoon +race +rack +radar +radio +rail +rain +raise +rally +ramp +ranch +random +range +rapid +rare +rate +rather +raven +raw +razor +ready +real +reason +rebel +rebuild +recall +receive +recipe +record +recycle +reduce +reflect +reform +refuse +region +regret +regular +reject +relax +release +relief +rely +remain +remember +remind +remove +render +renew +rent +reopen +repair +repeat +replace +report +require +rescue +resemble +resist +resource +response +result +retire +retreat +return +reunion +reveal +review +reward +rhythm +rib +ribbon +rice +rich +ride +ridge +rifle +right +rigid +ring +riot +ripple +risk +ritual +rival +river +road +roast +robot +robust +rocket +romance +roof +rookie +room +rose +rotate +rough +round +route +royal +rubber +rude +rug +rule +run +runway +rural +sad +saddle +sadness +safe +sail +salad +salmon +salon +salt +salute +same +sample +sand +satisfy +satoshi +sauce +sausage +save +say +scale +scan +scare +scatter +scene +scheme +school +science +scissors +scorpion +scout +scrap +screen +script +scrub +sea +search +season +seat +second +secret +section +security +seed +seek +segment +select +sell +seminar +senior +sense +sentence +series +service +session +settle +setup +seven +shadow +shaft +shallow +share +shed +shell +sheriff +shield +shift +shine +ship +shiver +shock +shoe +shoot +shop +short +shoulder +shove +shrimp +shrug +shuffle +shy +sibling +sick +side +siege +sight +sign +silent +silk +silly +silver +similar +simple +since +sing +siren +sister +situate +six +size +skate +sketch +ski +skill +skin +skirt +skull +slab +slam +sleep +slender +slice +slide +slight +slim +slogan +slot +slow +slush +small +smart +smile +smoke +smooth +snack +snake +snap +sniff +snow +soap +soccer +social +sock +soda +soft +solar +soldier +solid +solution +solve +someone +song +soon +sorry +sort +soul +sound +soup +source +south +space +spare +spatial +spawn +speak +special +speed +spell +spend +sphere +spice +spider +spike +spin +spirit +split +spoil +sponsor +spoon +sport +spot +spray +spread +spring +spy +square +squeeze +squirrel +stable +stadium +staff +stage +stairs +stamp +stand +start +state +stay +steak +steel +stem +step +stereo +stick +still +sting +stock +stomach +stone +stool +story +stove +strategy +street +strike +strong +struggle +student +stuff +stumble +style +subject +submit +subway +success +such +sudden +suffer +sugar +suggest +suit +summer +sun +sunny +sunset +super +supply +supreme +sure +surface +surge +surprise +surround +survey +suspect +sustain +swallow +swamp +swap +swarm +swear +sweet +swift +swim +swing +switch +sword +symbol +symptom +syrup +system +table +tackle +tag +tail +talent +talk +tank +tape +target +task +taste +tattoo +taxi +teach +team +tell +ten +tenant +tennis +tent +term +test +text +thank +that +theme +then +theory +there +they +thing +this +thought +three +thrive +throw +thumb +thunder +ticket +tide +tiger +tilt +timber +time +tiny +tip +tired +tissue +title +toast +tobacco +today +toddler +toe +together +toilet +token +tomato +tomorrow +tone +tongue +tonight +tool +tooth +top +topic +topple +torch +tornado +tortoise +toss +total +tourist +toward +tower +town +toy +track +trade +traffic +tragic +train +transfer +trap +trash +travel +tray +treat +tree +trend +trial +tribe +trick +trigger +trim +trip +trophy +trouble +truck +true +truly +trumpet +trust +truth +try +tube +tuition +tumble +tuna +tunnel +turkey +turn +turtle +twelve +twenty +twice +twin +twist +two +type +typical +ugly +umbrella +unable +unaware +uncle +uncover +under +undo +unfair +unfold +unhappy +uniform +unique +unit +universe +unknown +unlock +until +unusual +unveil +update +upgrade +uphold +upon +upper +upset +urban +urge +usage +use +used +useful +useless +usual +utility +vacant +vacuum +vague +valid +valley +valve +van +vanish +vapor +various +vast +vault +vehicle +velvet +vendor +venture +venue +verb +verify +version +very +vessel +veteran +viable +vibrant +vicious +victory +video +view +village +vintage +violin +virtual +virus +visa +visit +visual +vital +vivid +vocal +voice +void +volcano +volume +vote +voyage +wage +wagon +wait +walk +wall +walnut +want +warfare +warm +warrior +wash +wasp +waste +water +wave +way +wealth +weapon +wear +weasel +weather +web +wedding +weekend +weird +welcome +west +wet +whale +what +wheat +wheel +when +where +whip +whisper +wide +width +wife +wild +will +win +window +wine +wing +wink +winner +winter +wire +wisdom +wise +wish +witness +wolf +woman +wonder +wood +wool +word +work +world +worry +worth +wrap +wreck +wrestle +wrist +write +wrong +yard +year +yellow +you +young +youth +zebra +zero +zone +zoo \ No newline at end of file