Compare commits

...

21 Commits

Author SHA1 Message Date
Olivier 'reivilibre' d08d6932e4 Merge branch 'main' into fork-master 2023-02-18 12:18:28 +00:00
Cadence Ember 9f7b1bbcae Update dependencies (now lockfile v3)
The new lockfile version appears to still be readable by old versions
of npm that only support version 1.
2023-01-15 22:07:29 +13:00
Cadence Ember 3d5927ab28 Optional no ssl flag using x-insecure header 2023-01-15 22:06:31 +13:00
jo! 8d292cc200 Add maskable icons for PWA manifest 2022-12-21 18:34:36 +13:00
jo! 17185c6b5e Add favicon and PWA manifest 2022-12-20 22:48:08 +13:00
Olivier 'reivilibre' 9970c5e92f Use YewTube as preferred fallback rather than YouTube 2022-09-28 19:00:20 +01:00
Olivier 'reivilibre' 6e87edad3b fixup merge: reinclude video embed sass 2022-09-28 19:00:20 +01:00
Olivier 'reivilibre' facb959cb8 Merge branch 'main' into rei/hacky_embed 2022-09-28 18:33:10 +01:00
Cadence Ember 61c8e205d8
Show subscribed properly on /user or /c pages 2022-09-16 17:18:51 +12:00
Cadence Ember a2dfeb2edc Fix calls to fetchChannel
Should fix subscribing to channels
2022-08-23 10:37:43 +12:00
Lomanic 6de9abd499 Partially fix #29: add routes for /c/ and /user/ paths for channels 2022-08-18 19:10:54 +12:00
Cadence Ember 5e6b2bf31c
Add /api/settings to get current settings as JSON 2022-07-27 18:54:45 +12:00
Cadence Ember f04be0d3f9
Add video durations to can't think page
Because why would I care when I instinctively click anyway?
2022-03-14 21:54:42 +13:00
Cadence Ember 25baf8c73b
Fix z-index on can't think page
Because why would I be able to test if I'm not able to think?
2022-03-14 21:48:33 +13:00
Cadence Ember 1333b990f6
Add recommended videos to the can't think page
this is probably some kind of sick irony yeah yeah
no, no thinking. content. consume the content. watch more videos.

https://tube.cadence.moe/cant-think
watch more videos.
https://tube.cadence.moe/cant-think
watch more videos.
https://tube.cadence.moe/cant-think
watch more videos.
https://tube.cadence.moe/cant-think
watch more videos.
https://tube.cadence.moe/cant-think

...

can't think?
2022-03-14 21:32:34 +13:00
Cadence Ember 893684c311
Fix data deletion checkbox styles 2022-01-13 18:19:56 +13:00
Cadence Ember 109dcd22de
Rework subscribing to deleted channels 2022-01-10 14:18:45 +13:00
Cadence Ember 15e3f06ad6 Fix theme on can't think page 2021-12-28 20:36:54 +13:00
Cadence Ember 0d23d66700 Add theme support, light theme, and edgeless light 2021-12-28 16:32:11 +13:00
Cadence Ember 4e1f2b3607 Update dependencies 2021-12-22 00:17:57 +13:00
Olivier 'reivilibre' 6d1f91b2fa Add experimental, hacky and ugly video embed player 2021-12-17 09:20:52 +00:00
72 changed files with 1931 additions and 722 deletions

View File

@ -1,18 +1,28 @@
const {render} = require("pinski/plugins")
const constants = require("../utils/constants")
const {fetchChannel} = require("../utils/youtube")
const {getUser} = require("../utils/getuser")
const converters = require("../utils/converters")
module.exports = [
{
route: `/channel/(${constants.regex.ucid})`, methods: ["GET"], code: async ({req, fill, url}) => {
const id = fill[0]
route: `/(c|channel|user)/(.+)`, methods: ["GET"], code: async ({req, fill, url}) => {
const path = fill[0]
const id = fill[1]
const user = getUser(req)
const settings = user.getSettingsOrDefaults()
const data = await fetchChannel(id, settings.instance)
const subscribed = user.isSubscribed(id)
const data = await fetchChannel(path, id, settings.instance)
const instanceOrigin = settings.instance
// problem with the channel? fetchChannel has collected the necessary information for us.
// we can render a skeleton page, display the message, and provide the option to unsubscribe.
if (data.error) {
const statusCode = data.missing ? 410 : 500
const subscribed = user.isSubscribed(id)
return render(statusCode, "pug/channel-error.pug", {req, settings, data, subscribed, instanceOrigin})
}
// everything is fine
// normalise info, apply watched status
if (!data.second__subCountText && data.subCount) {
data.second__subCountText = converters.subscriberCountToText(data.subCount)
@ -24,7 +34,8 @@ module.exports = [
video.watched = watchedVideos.includes(video.videoId)
})
}
return render(200, "pug/channel.pug", {url, data, subscribed, instanceOrigin})
const subscribed = user.isSubscribed(data.authorId)
return render(200, "pug/channel.pug", {req, settings, data, subscribed, instanceOrigin})
}
}
]

View File

@ -9,8 +9,7 @@ const {Matcher, PatternCompileError} = require("../utils/matcher")
const filterMaxLength = 160
const regexpEnabledText = constants.server_setup.allow_regexp_filters ? "" : "not"
function getCategories(req) {
const user = getUser(req)
function getCategories(user) {
const filters = user.getFilters()
// Sort filters into categories for display. Titles are already sorted.
@ -39,7 +38,9 @@ function getCategories(req) {
module.exports = [
{
route: "/filters", methods: ["GET"], code: async ({req, url}) => {
const categories = getCategories(req)
const user = getUser(req)
const categories = getCategories(user)
const settings = user.getSettingsOrDefaults()
let referrer = url.searchParams.get("referrer") || null
let type = null
@ -54,7 +55,7 @@ module.exports = [
label = url.searchParams.get("label")
}
return render(200, "pug/filters.pug", {categories, type, contents, label, referrer, filterMaxLength, regexpEnabledText})
return render(200, "pug/filters.pug", {req, settings, categories, type, contents, label, referrer, filterMaxLength, regexpEnabledText})
}
},
{
@ -100,8 +101,10 @@ module.exports = [
return true
}, state => {
const {type, contents, label, compileError} = state
const categories = getCategories(req)
return render(400, "pug/filters.pug", {categories, type, contents, label, compileError, filterMaxLength, regexpEnabledText})
const user = getUser(req)
const categories = getCategories(user)
const settings = user.getSettingsOrDefaults()
return render(400, "pug/filters.pug", {req, settings, categories, type, contents, label, compileError, filterMaxLength, regexpEnabledText})
})
.last(state => {
const {type, contents, label} = state

View File

@ -23,10 +23,9 @@ module.exports = [
const token = user.token
if (add) {
await fetchChannel(ucid, settings.instance)
await fetchChannel("channel", ucid, settings.instance)
db.prepare(
"INSERT INTO Subscriptions (token, ucid) VALUES (?, ?)"
+ " ON CONFLICT (token, ucid) DO UPDATE SET channel_missing = 0"
).run(token, ucid)
} else {
db.prepare("DELETE FROM Subscriptions WHERE token = ? AND ucid = ?").run(token, ucid)
@ -41,7 +40,6 @@ module.exports = [
}),
content: "Success, redirecting..."
}
return redirect(params.get("referrer"), 303)
} else {
return {
statusCode: 200,

View File

@ -1,16 +1,35 @@
const {render} = require("pinski/plugins")
const {getUser} = require("../utils/getuser")
module.exports = [
{
route: "/", methods: ["GET"], code: async ({req}) => {
const userAgent = req.headers["user-agent"] || ""
const mobile = userAgent.toLowerCase().includes("mobile")
return render(200, "pug/home.pug", {mobile})
const user = getUser(req)
const settings = user.getSettingsOrDefaults()
return render(200, "pug/home.pug", {req, settings, mobile})
}
},
{
route: "/js-licenses", methods: ["GET"], code: async () => {
return render(200, "pug/js-licenses.pug")
route: "/(?:js-)?licenses", methods: ["GET"], code: async ({req}) => {
const user = getUser(req)
const settings = user.getSettingsOrDefaults()
return render(200, "pug/licenses.pug", {req, settings})
}
},
{
route: "/cant-think", methods: ["GET"], code: async ({req}) => {
const user = getUser(req)
const settings = user.getSettingsOrDefaults()
return render(200, "pug/cant-think.pug", {req, settings})
}
},
{
route: "/privacy", methods: ["GET"], code: async ({req}) => {
const user = getUser(req)
const settings = user.getSettingsOrDefaults()
return render(200, "pug/privacy.pug", {req, settings})
}
}
]

View File

@ -26,7 +26,7 @@ module.exports = [
const filters = user.getFilters()
results = converters.applyVideoFilters(results, filters).videos
return render(200, "pug/search.pug", {url, query, results, instanceOrigin})
return render(200, "pug/search.pug", {req, settings, url, query, results, instanceOrigin})
}
}
]

View File

@ -7,12 +7,23 @@ const validate = require("../utils/validate")
const V = validate.V
module.exports = [
{
route: "/api/settings", methods: ["GET"], code: async ({req}) => {
const user = getUser(req)
const settings = user.getSettingsOrDefaults()
return {
statusCode: 200,
contentType: "application/json",
content: settings
}
}
},
{
route: "/settings", methods: ["GET"], code: async ({req}) => {
const user = getUser(req)
const settings = user.getSettings()
const instances = instancesList.get()
return render(200, "pug/settings.pug", {constants, user, settings, instances})
return render(200, "pug/settings.pug", {req, constants, user, settings, instances})
}
},
{

View File

@ -11,12 +11,14 @@ module.exports = [
let hasSubscriptions = false
let videos = []
let channels = []
let missingChannelCount = 0
let refreshed = null
if (user.token) {
// trigger a background refresh, needed if they came back from being inactive
refresher.skipWaiting()
// get channels
channels = db.prepare(`SELECT Channels.* FROM Channels INNER JOIN Subscriptions ON Channels.ucid = Subscriptions.ucid WHERE token = ? ORDER BY name`).all(user.token)
missingChannelCount = channels.reduce((a, c) => a + c.missing, 0)
// get refreshed status
refreshed = db.prepare(`SELECT min(refreshed) as min, max(refreshed) as max, count(refreshed) as count FROM Channels INNER JOIN Subscriptions ON Channels.ucid = Subscriptions.ucid WHERE token = ?`).get(user.token)
// get watched videos
@ -37,7 +39,7 @@ module.exports = [
}
const settings = user.getSettingsOrDefaults()
const instanceOrigin = settings.instance
return render(200, "pug/subscriptions.pug", {url, settings, hasSubscriptions, videos, channels, refreshed, timeToPastText, instanceOrigin})
return render(200, "pug/subscriptions.pug", {req, url, settings, hasSubscriptions, videos, channels, missingChannelCount, refreshed, timeToPastText, instanceOrigin})
}
}
]

View File

@ -3,8 +3,8 @@ const {render} = require("pinski/plugins")
module.exports = [
{
route: "/takedown", methods: ["GET"], code: async () => {
return render(200, "pug/takedown.pug", {constants})
route: "/takedown", methods: ["GET"], code: async ({req}) => {
return render(200, "pug/takedown.pug", {req, constants})
}
}
]

View File

@ -111,7 +111,7 @@ module.exports = [
// Check if playback is allowed
const videoTakedownInfo = db.prepare("SELECT id, org, url FROM TakedownVideos WHERE id = ?").get(id)
if (videoTakedownInfo) {
return render(451, "pug/takedown-video.pug", videoTakedownInfo)
return render(451, "pug/takedown-video.pug", Object.assign({req, settings}, videoTakedownInfo))
}
// Media fragment
@ -129,7 +129,7 @@ module.exports = [
// Work out how to fetch the video
if (req.method === "GET") {
if (settings.local) { // skip to the local fetching page, which will then POST video data in a moment
return render(200, "pug/local-video.pug", {id})
return render(200, "pug/local-video.pug", {req, settings, id})
}
var instanceOrigin = settings.instance
var outURL = `${instanceOrigin}/api/v1/videos/${id}`
@ -153,7 +153,7 @@ module.exports = [
// automatically add the entry to the videos list, so it won't be fetched again
const args = {id, ...channelTakedownInfo}
db.prepare("INSERT INTO TakedownVideos (id, org, url) VALUES (@id, @org, @url)").run(args)
return render(451, "pug/takedown-video.pug", channelTakedownInfo)
return render(451, "pug/takedown-video.pug", Object.assign({req, settings}, channelTakedownInfo))
}
// process stream list ordering
@ -199,7 +199,7 @@ module.exports = [
}
return render(200, "pug/video.pug", {
url, video, formats, subscribed, instanceOrigin, mediaFragment, autoplay, continuous,
req, url, video, formats, subscribed, instanceOrigin, mediaFragment, autoplay, continuous,
sessionWatched, sessionWatchedNext, settings
})
@ -225,7 +225,7 @@ module.exports = [
// Create appropriate formatted message
const message = render(0, `pug/errors/${errorType}.pug`, locals).content
return render(500, "pug/video.pug", {video: {videoId: id}, error: true, message})
return render(500, "pug/video.pug", {video: {videoId: id}, error: true, message, req, settings})
}
}
}

232
api/video_embed.js Normal file
View File

@ -0,0 +1,232 @@
const {request} = require("../utils/request")
/** @type {import("node-fetch").default} */
// @ts-ignore
const fetch = require("node-fetch")
const {render} = require("pinski/plugins")
const db = require("../utils/db")
const {getToken, getUser} = require("../utils/getuser")
const pug = require("pug")
const converters = require("../utils/converters")
const constants = require("../utils/constants")
class InstanceError extends Error {
constructor(error, identifier) {
super(error)
this.identifier = identifier
}
}
class MessageError extends Error {
}
function formatOrder(format) {
// most significant to least significant
// key, max, order, transform
// asc: lower number comes first, desc: higher number comes first
const spec = [
{key: "second__height", max: 8000, order: "desc", transform: x => x ? Math.floor(x/96) : 0},
{key: "fps", max: 100, order: "desc", transform: x => x ? Math.floor(x/10) : 0},
{key: "type", max: " ".repeat(60), order: "asc", transform: x => x.length}
]
let total = 0
for (let i = 0; i < spec.length; i++) {
const s = spec[i]
let diff = s.transform(format[s.key])
if (s.order === "asc") diff = s.transform(s.max) - diff
total += diff
if (i+1 < spec.length) { // not the last spec item?
const s2 = spec[i+1]
total *= s2.transform(s2.max)
}
}
return -total
}
function sortFormats(video, preference) {
// Add second__ extensions to format objects, required if Invidious was the extractor
let formats = video.formatStreams.concat(video.adaptiveFormats)
for (const format of formats) {
if (!format.second__height && format.resolution) format.second__height = +format.resolution.slice(0, -1)
if (!format.second__order) format.second__order = formatOrder(format)
format.cloudtube__label = `${format.qualityLabel} ${format.container}`
}
// Properly build and order format list
const standard = video.formatStreams.slice().sort((a, b) => b.second__height - a.second__height)
const adaptive = video.adaptiveFormats.filter(f => f.type.startsWith("video") && f.qualityLabel).sort((a, b) => a.second__order - b.second__order)
for (const format of adaptive) {
if (!format.cloudtube__label.endsWith("*")) format.cloudtube__label += " *"
}
formats = standard.concat(adaptive)
// Reorder fomats based on user preference
if (preference === 1) { // best dash
formats.sort((a, b) => {
const a1 = a.second__height + a.fps / 100
const b1 = b.second__height + b.fps / 100
return b1 - a1
})
} else if (preference === 2) { // best <=1080p
formats.sort((a, b) => {
const a1 = a.second__height + a.fps / 100
const b1 = b.second__height + b.fps / 100
if (b1 > 1081) {
if (a1 > 1081) return b1 - a1
return -1
}
if (a1 > 1081) return 1
return b1 - a1
})
} else if (preference === 3) { // best low-fps
formats.sort((a, b) => {
if (b.fps > 30) {
if (a.fps < 30) return b.second__height - a.second__height
return -1
}
if (a.fps > 30) return 1
return b.second__height - a.second__height
})
} else if (preference === 4) { // 360p only
formats.sort((a, b) => {
if (a.itag == 18) return -1
if (b.itag == 18) return 1
return 0
})
} else { // preference === 0, best combined
// should already be correct
}
return formats
}
module.exports = [
{
route: `/embed/(${constants.regex.video_id})`, methods: ["GET"], upload: false, code: async ({req, url, body, fill}) => {
// Prepare data needed to render video page
const user = getUser(req)
const settings = user.getSettingsOrDefaults()
const id = fill[0];
// Check if playback is allowed
const videoTakedownInfo = db.prepare("SELECT id, org, url FROM TakedownVideos WHERE id = ?").get(id)
if (videoTakedownInfo) {
return render(451, "pug/takedown-video.pug", videoTakedownInfo)
}
// Media fragment
const t = url.searchParams.get("t")
let mediaFragment = converters.tToMediaFragment(t)
// Continuous mode
const continuous = url.searchParams.get("continuous") === "1"
const autoplay = url.searchParams.get("autoplay") === "1"
const swp = url.searchParams.get("session-watched")
const sessionWatched = swp ? swp.split(" ") : []
const sessionWatchedNext = sessionWatched.concat([id]).join("+")
if (continuous) settings.quality = 0 // autoplay with synced streams does not work
// Work out how to fetch the video
if (req.method === "GET") {
if (settings.local) { // skip to the local fetching page, which will then POST video data in a moment
return render(200, "pug/local-video.pug", {id})
}
var instanceOrigin = settings.instance
var outURL = `${instanceOrigin}/api/v1/videos/${id}`
var videoFuture = request(outURL).then(res => res.json())
} else { // req.method === "POST"
var instanceOrigin = "http://localhost:3000"
var videoFuture = JSON.parse(new URLSearchParams(body.toString()).get("video"))
}
try {
// Fetch the video
const video = await videoFuture
// Error handling
if (!video) throw new MessageError("The instance returned null.")
if (video.error) throw new InstanceError(video.error, video.identifier)
// Check if channel playback is allowed
const channelTakedownInfo = db.prepare("SELECT ucid, org, url FROM TakedownChannels WHERE ucid = ?").get(video.authorId)
if (channelTakedownInfo) {
// automatically add the entry to the videos list, so it won't be fetched again
const args = {id, ...channelTakedownInfo}
db.prepare("INSERT INTO TakedownVideos (id, org, url) VALUES (@id, @org, @url)").run(args)
return render(451, "pug/takedown-video.pug", channelTakedownInfo)
}
// process stream list ordering
const formats = sortFormats(video, settings.quality)
// process length text and view count
for (const rec of video.recommendedVideos) {
converters.normaliseVideoInfo(rec)
}
// filter list
const {videos, filteredCount} = converters.applyVideoFilters(video.recommendedVideos, user.getFilters())
video.recommendedVideos = videos
// get subscription data
const subscribed = user.isSubscribed(video.authorId)
// process watched videos
user.addWatchedVideoMaybe(video.videoId)
const watchedVideos = user.getWatchedVideos()
if (watchedVideos.length) {
for (const rec of video.recommendedVideos) {
rec.watched = watchedVideos.includes(rec.videoId)
}
}
// normalise view count
if (!video.second__viewCountText && video.viewCount) {
video.second__viewCountText = converters.viewCountToText(video.viewCount)
}
// apply media fragment to all sources
for (const format of formats) {
format.url += mediaFragment
}
// rewrite description
video.descriptionHtml = converters.rewriteVideoDescription(video.descriptionHtml, id)
// rewrite captions urls so they are served on the same domain via the /proxy route
for (const caption of video.captions) {
caption.url = `/proxy?${new URLSearchParams({"url": caption.url})}`
}
return render(200, "pug/video_embed.pug", {
url, video, formats, subscribed, instanceOrigin, mediaFragment, autoplay, continuous,
sessionWatched, sessionWatchedNext, settings
})
} catch (error) {
// Something went wrong, somewhere! Find out where.
let errorType = "unrecognised-error"
const locals = {instanceOrigin, error}
// Sort error category
if (error instanceof fetch.FetchError) {
errorType = "fetch-error"
} else if (error instanceof MessageError) {
errorType = "message-error"
} else if (error instanceof InstanceError) {
if (error.identifier === "RATE_LIMITED_BY_YOUTUBE" || error.message === "Could not extract video info. Instance is likely blocked.") {
errorType = "rate-limited"
} else {
errorType = "instance-error"
}
}
// Create appropriate formatted message
const message = render(0, `pug/errors/${errorType}.pug`, locals).content
return render(500, "pug/video_embed.pug", {video: {videoId: id}, error: true, message})
}
}
}
]

View File

@ -15,8 +15,8 @@ const prepared = {
channel_refreshed_update: db.prepare(
"UPDATE Channels SET refreshed = ? WHERE ucid = ?"
),
unsubscribe_all_from_channel: db.prepare(
"UPDATE Subscriptions SET channel_missing = 1 WHERE ucid = ?"
channel_mark_as_missing: db.prepare(
"UPDATE Channels SET missing = 1, missing_reason = ? WHERE ucid = ?"
)
}
@ -35,7 +35,7 @@ class RefreshQueue {
// get the next set of scheduled channels to refresh
const afterTime = Date.now() - constants.caching.seen_token_subscriptions_eligible
const channels = db.prepare(
"SELECT DISTINCT Subscriptions.ucid FROM SeenTokens INNER JOIN Subscriptions ON SeenTokens.token = Subscriptions.token AND SeenTokens.seen > ? WHERE Subscriptions.channel_missing = 0 ORDER BY SeenTokens.seen DESC"
"SELECT DISTINCT Subscriptions.ucid FROM SeenTokens INNER JOIN Subscriptions ON SeenTokens.token = Subscriptions.token INNER JOIN Channels ON Channels.ucid = Subscriptions.ucid WHERE Channels.missing = 0 AND SeenTokens.seen > ? ORDER BY SeenTokens.seen DESC"
).pluck().all(afterTime)
this.addLast(channels)
this.lastLoadTime = Date.now()
@ -72,11 +72,12 @@ class Refresher {
this.refreshQueue = new RefreshQueue()
this.state = this.sym.ACTIVE
this.waitingTimeout = null
this.lastFakeNotFoundTime = 0
this.next()
}
refreshChannel(ucid) {
return fetch(`${constants.server_setup.local_instance_origin}/api/v1/channels/${ucid}/latest`).then(res => res.json()).then(root => {
return fetch(`${constants.server_setup.local_instance_origin}/api/v1/channels/${ucid}/latest`).then(res => res.json()).then(/** @param {any} root */ root => {
if (Array.isArray(root)) {
root.forEach(video => {
// organise
@ -89,11 +90,24 @@ class Refresher {
prepared.channel_refreshed_update.run(Date.now(), ucid)
// console.log(`updated ${root.length} videos for channel ${ucid}`)
} else if (root.identifier === "PUBLISHED_DATES_NOT_PROVIDED") {
return [] // nothing we can do. skip this iteration.
// nothing we can do. skip this iteration.
} else if (root.identifier === "NOT_FOUND") {
// the channel does not exist. we should unsubscribe all users so we don't try again.
// console.log(`channel ${ucid} does not exist, unsubscribing all users`)
prepared.unsubscribe_all_from_channel.run(ucid)
// YouTube sometimes returns not found for absolutely no reason.
// There is no way to distinguish between a fake missing channel and a real missing channel without requesting the real endpoint.
// These fake missing channels often happen in bursts, which is why there is a cooldown.
const timeSinceLastFakeNotFound = Date.now() - this.lastFakeNotFoundTime
if (timeSinceLastFakeNotFound >= constants.caching.subscriptions_refesh_fake_not_found_cooldown) {
// We'll request the real endpoint to verify.
fetch(`${constants.server_setup.local_instance_origin}/api/v1/channels/${ucid}`).then(res => res.json()).then(/** @param {any} root */ root => {
if (root.error && (root.identifier === "NOT_FOUND" || root.identifier === "ACCOUNT_TERMINATED")) {
// The channel is really gone, and we should mark it as missing for everyone.
prepared.channel_mark_as_missing.run(root.error, ucid)
} else {
// The channel is not actually gone and YouTube is trolling us.
this.lastFakeNotFoundTime = Date.now()
}
})
} // else youtube is currently trolling us, skip this until later.
} else {
throw new Error(root.error)
}

9
html/browserconfig.xml Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/static/images/mstile-150x150.png"/>
<TileColor>#2b5797</TileColor>
</tile>
</msapplication>
</browserconfig>

34
html/site.webmanifest Normal file
View File

@ -0,0 +1,34 @@
{
"name": "CloudTube",
"short_name": "CloudTube",
"icons": [
{
"src": "/static/images/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/images/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/images/maskable-icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/images/maskable-icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#36393f",
"background_color": "#36393f",
"start_url": "/",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

View File

Before

Width:  |  Height:  |  Size: 225 B

After

Width:  |  Height:  |  Size: 225 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="8" viewBox="0 0 5.821 2.117"><path d="M1.269.53l.767.793h.161L2.964.53h.211v.265L2.117 1.852 1.058.794V.529z" fill="#202020" paint-order="markers stroke fill"/></svg>

After

Width:  |  Height:  |  Size: 226 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

View File

@ -0,0 +1,33 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2667 4020 c-115 -29 -296 -105 -387 -162 -54 -34 -182 -133 -207
-159 -12 -14 -33 -49 -47 -79 -13 -30 -48 -80 -78 -110 -46 -47 -170 -221
-180 -252 -3 -7 -39 1 -96 21 -50 17 -147 43 -214 57 -111 23 -137 24 -258 18
-87 -4 -154 -13 -190 -24 -30 -10 -82 -24 -115 -31 -90 -17 -163 -59 -231
-131 -184 -197 -233 -305 -250 -554 -6 -90 -3 -115 31 -279 31 -152 44 -193
81 -265 24 -47 59 -120 78 -162 19 -43 56 -106 82 -140 66 -88 173 -196 214
-217 126 -65 482 -83 711 -35 68 14 156 36 194 49 95 32 317 43 420 22 39 -8
104 -18 145 -22 41 -3 109 -12 150 -20 166 -31 322 -37 825 -33 420 4 509 7
555 21 30 9 89 24 131 35 156 39 312 154 413 306 20 30 41 58 46 61 5 3 34 52
64 108 43 82 58 123 75 206 26 126 27 278 5 387 -18 86 -32 121 -74 174 -15
19 -34 49 -43 66 -34 67 -174 199 -262 248 -16 9 -52 32 -78 51 -27 19 -67 39
-90 45 -23 6 -71 24 -107 39 -64 28 -340 91 -397 91 -28 0 -30 4 -56 88 -15
48 -40 108 -55 134 -38 64 -224 269 -288 318 -63 48 -182 104 -250 118 -75 16
-224 22 -267 12z m-428 -984 c7 -8 33 -16 59 -19 183 -20 276 -46 447 -127 61
-29 128 -58 149 -66 22 -8 67 -32 100 -53 34 -21 84 -50 113 -65 29 -14 68
-40 88 -57 31 -27 35 -37 35 -78 0 -40 -5 -51 -32 -77 -53 -49 -130 -100 -171
-114 -21 -6 -57 -26 -80 -44 -49 -37 -70 -49 -192 -106 -49 -23 -99 -51 -111
-61 -11 -10 -45 -32 -75 -48 -30 -16 -88 -47 -129 -70 -95 -52 -134 -54 -177
-8 -74 76 -121 287 -133 592 -4 121 -13 236 -19 255 -19 60 -14 101 16 132 30
31 91 39 112 14z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="25" height="25" viewBox="0 0 6.615 6.615"><path d="M2.64 0s-.03.94-.424 1.215C1.823 1.49.885.894.885.894L.112 2.232.109 2.23l.003.002c.01.006.797.499.838.974.042.478-.944.992-.944.992l.77 1.34s.83-.442 1.265-.24c.435.204.387 1.314.387 1.314l1.546.003s.032-.94.425-1.215c.393-.275 1.331.321 1.331.321l.775-1.338s-.798-.496-.84-.974c-.041-.478.944-.993.944-.993l-.77-1.34s-.83.443-1.265.24C4.14 1.113 4.187.002 4.187.002zm.688 2.25a1.106 1.106 0 110 2.211 1.106 1.106 0 010-2.21z" fill="#c4c4c4" paint-order="fill markers stroke"/></svg>
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="25" height="25" viewBox="0 0 6.615 6.615"><title>Settings</title><path d="M2.64 0s-.03.94-.424 1.215C1.823 1.49.885.894.885.894L.112 2.232.109 2.23l.003.002c.01.006.797.499.838.974.042.478-.944.992-.944.992l.77 1.34s.83-.442 1.265-.24c.435.204.387 1.314.387 1.314l1.546.003s.032-.94.425-1.215c.393-.275 1.331.321 1.331.321l.775-1.338s-.798-.496-.84-.974c-.041-.478.944-.993.944-.993l-.77-1.34s-.83.443-1.265.24C4.14 1.113 4.187.002 4.187.002zm.688 2.25a1.106 1.106 0 110 2.211 1.106 1.106 0 010-2.21z" paint-order="fill markers stroke"/></svg>

Before

Width:  |  Height:  |  Size: 582 B

After

Width:  |  Height:  |  Size: 611 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="25" viewBox="0 0 7.937 6.615"><path d="M2.373 0h3.191a.52.52 0 01.521.521H1.852A.52.52 0 012.373 0zm-.91.794h5.011c.371 0 .67.298.67.67v.123l-6.35-.016v-.108c0-.37.298-.67.67-.67zm-.405 1.058C.472 1.852 0 2.184 0 2.77v2.868c0 .586.472.977 1.058.977H6.88c.586 0 1.059-.39 1.059-.977V2.77c0-.586-.473-.918-1.059-.918zM5.3 4.017c.167.099.19.276.012.366l-2.098.985c-.131.077-.304-.002-.302-.19V3.29c0-.203.18-.333.34-.245z" fill="#c4c4c4" paint-order="fill markers stroke"/></svg>
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="30" height="25" viewBox="0 0 7.937 6.615"><title>Subscriptions</title><path d="M2.373 0h3.191a.52.52 0 01.521.521H1.852A.52.52 0 012.373 0zm-.91.794h5.011c.371 0 .67.298.67.67v.123l-6.35-.016v-.108c0-.37.298-.67.67-.67zm-.405 1.058C.472 1.852 0 2.184 0 2.77v2.868c0 .586.472.977 1.058.977H6.88c.586 0 1.059-.39 1.059-.977V2.77c0-.586-.473-.918-1.059-.918zM5.3 4.017c.167.099.19.276.012.366l-2.098.985c-.131.077-.304-.002-.302-.19V3.29c0-.203.18-.333.34-.245z" paint-order="fill markers stroke"/></svg>

Before

Width:  |  Height:  |  Size: 535 B

After

Width:  |  Height:  |  Size: 569 B

1327
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,11 +10,11 @@
"author": "",
"license": "AGPL-3.0",
"dependencies": {
"better-sqlite3": "^7.1.0",
"better-sqlite3": "^7.4.5",
"cookie": "^0.4.1",
"denque": "^1.4.1",
"denque": "^1.5.1",
"mixin-deep": "^2.0.1",
"node-fetch": "^2.6.0",
"pinski": "git+https://git.sr.ht/~cadence/nodejs-pinski#210be3cfacbd93d44e104698a29abd39494a6271"
"node-fetch": "^2.6.6",
"pinski": "git+https://git.sr.ht/~cadence/nodejs-pinski#9653807f309aee34c8c63ce4e6ee760cccbfdf0d"
}
}

View File

@ -18,6 +18,7 @@ mixin leave(index, prefix="No more?", final)
a(href="/search?q=cats+being+cute") [BLISS]
block content
include includes/video-list-item
main.cant-think-page
- let src = constants.server_setup.cant_think_narration_url
if src
@ -71,3 +72,8 @@ block content
p You know what you must do.
p.ultimatum: a(href="#i-understand").border-look I know what I must do.
a#i-understand
p.the-end Well, looks like you reached the end of the content.#[br]You know what happens now.
h3 Recommended videos
+video_list_item("related-video", {videoId: "jKKCF_Bqtw4", title: "dopamine review", author: "Jreg", authorId: "UCGSGPehp0RWfca-kENgBJ9Q", viewCountText: "84,651 views", second__lengthText: "3:10"})
+video_list_item("related-video", {videoId: "gLYWLobR248", title: "I Watch My YouTube Videos At 2x Speed", author: "Jreg", authorId: "UCGSGPehp0RWfca-kENgBJ9Q", viewCountText: "125,625 views", second__lengthText: "2:24"})

27
pug/channel-error.pug Normal file
View File

@ -0,0 +1,27 @@
extends includes/layout
include includes/video-list-item
include includes/subscribe-button
block head
title= `${data.row ? data.row.name : "Deleted channel"} - CloudTube`
script(type="module" src=getStaticURL("html", "/static/js/channel.js"))
block content
main.channel-page
if data.row
.channel-data
.info
- const iconURL = data.row.icon_url
if iconURL
.logo
img(src=iconURL alt="").thumbnail-image
.about
h1.name= data.row.name
+subscribe_button(data.ucid, subscribed, `/channel/${data.ucid}`).subscribe-button.base-border-look
.channel-error
div= data.message
if data.missing && subscribed
.you-should-unsubscribe To remove this channel from your subscriptions list, click Unsubscribe.

View File

@ -3,8 +3,21 @@ html
head
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1")
link(rel="stylesheet" type="text/css" href=getStaticURL("sass", "/main.sass"))
- const theme = settings && ["dark", "light", "edgeless-light"][settings.theme] || "dark"
link(rel="stylesheet" type="text/css" href=getStaticURL("sass", `/${theme}.sass`))
script(type="module" src=getStaticURL("html", "/static/js/focus.js"))
link(rel="apple-touch-icon" sizes="180x180" href="/static/images/apple-touch-icon.png")
link(rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png")
link(rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png")
link(rel="manifest" href="/site.webmanifest")
link(rel="mask-icon" href="/static/images/safari-pinned-tab.svg" color="#5bbad5")
link(rel="shortcut icon" href="/static/images/favicon.ico")
meta(name="apple-mobile-web-app-title" content="CloudTube")
meta(name="application-name" content="CloudTube")
meta(name="msapplication-TileColor" content="#2b5797")
meta(name="msapplication-config" content="/browserconfig.xml")
meta(name="theme-color" content="#36393f")
block head
body.show-focus
@ -13,15 +26,14 @@ html
if showNav
nav.main-nav
.links
a(href="/").link.home CloudTube
if req && req.headers && "x-insecure" in req.headers
a(href="/").link.home CloudTube - Insecure
else
a(href="/").link.home CloudTube
a(href="/subscriptions" title="Subscriptions").link.icon-link
svg(width=30 height=25)
image(href=getStaticURL("html", "/static/images/subscriptions.svg") alt="Subscriptions.").icon
title Subscriptions
!= icons.get("subscriptions")
a(href="/settings" title="Settings").link.icon-link
svg(width=25 height=25)
image(href=getStaticURL("html", "/static/images/settings.svg") alt="Settings.").icon
title Settings
!= icons.get("settings")
form(method="get" action="/search").search-form
input(type="text" placeholder="Search" aria-label="Search a video" name="q" autocomplete="off" value=query).search

View File

@ -0,0 +1,12 @@
doctype html
html
head
meta(charset="utf-8")
meta(name="viewport" content="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
div
block content

View File

@ -36,6 +36,16 @@ block content
form(method="post" action="/settings")
+fieldset("Settings")
+select({
id: "theme",
label: "Theme",
options: [
{value: "0", text: "Standard dark"},
{value: "1", text: "Standard light"},
{value: "2", text: "Edgeless light"}
]
})
+input({
id: "instance",
label: "Instance",

View File

@ -11,12 +11,24 @@ block content
if hasSubscriptions
section
details.channels-details
summary #{channels.length} subscriptions
summary
| #{channels.length} subscriptions
if missingChannelCount === 1
= ` - ${missingChannelCount} channel is gone`
else if missingChannelCount > 1
= ` - ${missingChannelCount} channels are gone`
.channels-list
for channel in channels
a(href=`/channel/${channel.ucid}`).channel-item
img(src=channel.icon_url width=512 height=512 alt="").thumbnail
span.name= channel.name
div
div.name= channel.name
if channel.missing
div.missing-reason
if channel.missing_reason
= channel.missing_reason
else
| This channel appears to be deleted or terminated. Click to check it.
if refreshed
section
@ -31,8 +43,8 @@ block content
if settings.save_history
input(type="checkbox" id="watched-videos-display")
.watched-videos-display-container
label(for="watched-videos-display").watched-videos-display-label Hide watched videos
.checkbox-hider__container
label(for="watched-videos-display").checkbox-hider__label Hide watched videos
each video in videos
+video_list_item("subscriptions-video", video, instanceOrigin, {showMarkWatched: settings.save_history && !video.watched})

View File

@ -21,6 +21,7 @@ block content
noscript
meta(http-equiv="refresh" content=`${video.lengthSeconds+5};url=/watch?v=${first.videoId}&continuous=1&session-watched=${sessionWatchedNext}`)
.video-page(class={
"video-page--recommended-side": settings.recommended_mode === 0,
"video-page--recommended-below": settings.recommended_mode === 1,
"video-page--recommended-hidden": settings.recommended_mode === 2
})
@ -76,7 +77,7 @@ block content
img(src="/static/images/search.svg" width=17 height=17 alt="").button-icon
| Search
//- button.border-look#share Share
a(href=`https://www.youtube.com/watch?v=${video.videoId}#cloudtube`).border-look YouTube
a(href=`https://yewtu.be/watch?v=${video.videoId}`).border-look YewTube
a(href=`https://redirect.invidious.io/watch?v=${video.videoId}`).border-look Invidious
.description#description!= video.descriptionHtml

58
pug/video_embed.pug Normal file
View File

@ -0,0 +1,58 @@
extends includes/layout_embed
include includes/video-list-item
include includes/subscribe-button
block head
unless error
title= `${video.title} (embedded) - CloudTube`
else
title Error - CloudTube
script(type="module" src=getStaticURL("html", "/static/js/player.js"))
script const data = !{JSON.stringify({...video, continuous})}
block content
unless error
.video-embed-page(class={
"video-page--recommended-below": settings.recommended_mode === 1,
"video-page--recommended-hidden": settings.recommended_mode === 2
})
main.embed-video-section
- const format = formats[0]
if format
video(controls preload="auto" width=format.second__width height=format.second__height data-itag=format.itag autoplay=continuous||autoplay)#video.video
source(src=format.url type=format.type)
each t in video.captions
track(label=t.label kind="subtitles" srclang=t.languageCode src=t.url)
// fallback: flash player
- let flashvars = new URLSearchParams({skin: "/static/flash/skin.swf", video: format.url})
embed(type="application/x-shockwave-flash" src="/static/flash/player.swf" id="f4Player" width=1280 height=720 flashvars=flashvars.toString() allowscriptaccess="always" allowfullscreen="true" bgcolor="#000000")
else
video(src="")#video.video
.stream-notice The server provided no playback streams.
#current-time-container
#end-cards-container
audio(preload="auto")#audio
#live-event-notice
#audio-loading-display
.button-container
select(aria-label="Quality" autocomplete="off").border-look#quality-select
each f in formats
option(value=f.itag)= f.cloudtube__label
//-
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(target="_blank", href=`/watch?v=${video.videoId}`).border-look CloudTube (not embedded)
a(target="_blank", href=`https://yewtu.be/watch?v=${video.videoId}`).border-look YewTube
a(target="_blank", href=`https://redirect.invidious.io/watch?v=${video.videoId}`).border-look Invidious
else
//- error
main.video-error-page
h2 Error
!= message
p: a(href=`https://www.youtube.com/watch?v=${video.videoId}#cloudtube`) Watch on YouTube →

9
sass/dark.sass Normal file
View File

@ -0,0 +1,9 @@
@use "themes/dark" as *
@use "includes/main" with ($_theme: $theme)
@use "theme-modules/edgeless" with ($_theme: $theme)
// navigation shadow
.main-nav
position: relative // needed for box shadow to overlap related videos section
box-shadow: 0px 0px 20px 5px rgba(0, 0, 0, 0.1)

4
sass/edgeless-light.sass Normal file
View File

@ -0,0 +1,4 @@
@use "themes/edgeless-light" as *
@use "includes/main" with ($_theme: $theme)
@use "theme-modules/edgeless" with ($_theme: $theme)

View File

@ -1,8 +1,10 @@
@use "colors.sass" as c
$_theme: () !default
@use "sass:map"
body
background-color: c.$bg-dark
color: c.$fg-main
background-color: map.get($_theme, "bg-2")
color: map.get($_theme, "fg-main")
font-family: "Bariol", sans-serif
font-size: 18px
margin: 0
@ -13,13 +15,13 @@ body
flex-direction: column
a
color: c.$link
color: map.get($_theme, "link")
pre, code
font-size: 0.88em
code
background: c.$bg-darker
background: map.get($_theme, "bg-1")
padding: 3px 5px
border-radius: 4px
@ -32,7 +34,7 @@ button
cursor: pointer
::placeholder
color: #c4c4c4
color: map.get($_theme, "placeholder")
opacity: 1
// focus section
@ -48,19 +50,20 @@ button
select:-moz-focusring
color: transparent
text-shadow: 0 0 0 c.$fg-bright
text-shadow: 0 0 0 map.get($_theme, "fg-bright")
body.show-focus
a, select, button, input, video, summary
&:focus
outline: 2px dotted #ddd
outline: 2px dotted map.get($_theme, "fg-main")
video
background-color: black
details
background-color: c.$bg-accent-x
background-color: map.get($_theme, "bg-3")
padding: 12px
border: 1px solid map.get($_theme, "edge-grey")
border-radius: 8px
summary
@ -68,20 +71,22 @@ details
line-height: 1
margin-bottom: 0
user-select: none
color: c.$fg-main
color: map.get($_theme, "fg-main")
&[open] summary
margin-bottom: 16px
padding-bottom: 12px
border-bottom: 1px solid map.get($_theme, "edge-grey")
margin-bottom: 8px
table
background-color: c.$bg-darker
background-color: map.get($_theme, "bg-1")
table, td, th
border: 1px solid c.$edge-grey
border: 1px solid map.get($_theme, "edge-grey")
border-collapse: collapse
td, th
padding: 4px 8px
thead, tr:nth-child(even)
background-color: c.$bg-darkest
background-color: map.get($_theme, "bg-0")

View File

@ -1,10 +1,12 @@
$_theme: () !default
@use "sass:selector"
@use "colors.sass" as c
@use "sass:map"
@mixin button-base
-webkit-appearance: none
-moz-appearance: none
color: c.$fg-bright
color: map.get($_theme, "fg-bright")
border: none
border-radius: 4px
padding: 8px
@ -14,7 +16,7 @@
@at-root #{selector.unify(&, "select")}
padding: 8px 27px 8px 8px
background: url(/static/images/arrow-down-wide.svg) right 53% no-repeat c.$bg-accent-x
background: map.get($_theme, "image-dropdown") right 53% no-repeat map.get($_theme, "bg-4")
@at-root #{selector.unify(&, "a")}
padding: 7px 8px
@ -31,12 +33,12 @@
@mixin button-bg
@include button-base
background-color: c.$bg-accent-x
background-color: map.get($_theme, "bg-4")
@mixin border-button
@include button-bg
border: 1px solid c.$edge-grey
border: 1px solid map.get($_theme, "edge-grey")
@mixin button-size
margin: 4px
@ -44,10 +46,10 @@
@mixin button-hover
&:hover
background-color: c.$bg-accent
background-color: map.get($_theme, "bg-3")
&:active
background-color: c.$bg-dark
background-color: map.get($_theme, "bg-2")
.base-border-look
@include border-button
@ -62,13 +64,13 @@
@include button-size
-webkit-appearance: none
-moz-appearance: none
color: c.$fg-bright
color: map.get($_theme, "fg-bright")
text-decoration: none
line-height: 1.25
margin: 0
padding: 8px 20px
background: c.$bg-accent
border: solid c.$bg-darker
background: map.get($_theme, "bg-3")
border: solid map.get($_theme, "edge-grey")
border-width: 1px 0px 0px
text-align: left
@ -76,7 +78,7 @@
border-width: 1px 0px 1px
&:hover
background: c.$bg-accent-x
background: map.get($_theme, "bg-4")
&:active
background: c.$bg-darker
background: map.get($_theme, "bg-1")

View File

@ -1,5 +1,7 @@
$_theme: () !default
@use "sass:list"
@use "colors.sass" as c
@use "sass:map"
.cant-think-page
.main-nav
@ -9,13 +11,14 @@
text-align: left
max-width: 572px
padding-top: 40px
margin-bottom: 12vh
border-radius: 0px 0px 16px 16px
box-sizing: border-box
.page-narration
background-color: c.$bg-accent
border: 1px solid #aaa
color: #fff
background-color: map.get($_theme, "bg-3")
border: 1px solid map.get($_theme, "edge-grey")
color: map.get($_theme, "fg-bright")
border-radius: 0
padding: 16px
margin: 40px auto 60px
@ -28,7 +31,7 @@
.leave
margin: 26px 32px !important
color: #aaa
color: map.get($_theme, "fg-dim")
$sizes: 14px 16px 21px 30px 72px
@each $size in $sizes
@ -37,7 +40,7 @@
&.leave__final
font-weight: bold
color: #f2f2f2
color: map.get($_theme, "fg-bright")
text-align: center
.leave__actions
@ -54,6 +57,12 @@
.ultimatum
margin-top: 32px !important
.the-end
margin: 120px 0px 1em !important
.thumbnail__more
display: none
#i-understand
display: flex
height: 20px
@ -65,6 +74,7 @@
&:target
position: fixed
z-index: 1
top: 0
bottom: 0
left: 0

View File

@ -1,6 +1,8 @@
@use "colors.sass" as c
@use "video-list-item.sass" as *
@use "_dimensions.sass" as dimensions
$_theme: () !default
@use "sass:map"
@use "_dimensions" as dimensions
@use "video-list-item" as *
.channel-page
padding: 40px 20px 20px
@ -17,7 +19,7 @@
align-self: flex-start
.channel-data
background-color: c.$bg-darker
background-color: map.get($_theme, "bg-1")
padding: 24px
margin: 12px 0px 24px
border-radius: 8px
@ -44,11 +46,11 @@
.name
font-size: 30px
font-weight: normal
color: c.$fg-bright
color: map.get($_theme, "fg-bright")
margin: 0
.subscribers
color: c.$fg-main
color: map.get($_theme, "fg-main")
font-size: 18px
.subscribe-form
@ -61,7 +63,8 @@
line-height: 1
border-radius: 8px
font-size: 22px
background-color: c.$power-deep
background-color: map.get($_theme, "power-deep")
color: map.get($_theme, "power-fg")
border: none
.description
@ -71,6 +74,19 @@
.channel-video
@include channel-video
.channel-error
background-color: map.get($_theme, "bg-1")
padding: 24px
margin: 12px 0px 24px
border-radius: 8px
border: 1px solid map.get($_theme, "edge-grey")
font-size: 20px
color: map.get($_theme, "fg-warning")
.you-should-unsubscribe
margin-top: 20px
color: map.get($_theme, "fg-main")
.about-description // class provided by youtube
pre
font-size: inherit

View File

@ -1,10 +1,12 @@
@use "colors.sass" as c
$_theme: () !default
@use "sass:map"
@mixin filter-notice
margin-top: 24px
padding: 12px
border-radius: 8px
background-color: c.$bg-darker
background-color: map.get($_theme, "bg-1")
white-space: pre-line
.filters-page
@ -20,23 +22,23 @@
.filter-confirmation-notice
@include filter-notice
color: c.$fg-warning
color: map.get($_theme, "fg-warning")
.filter-compile-error
@include filter-notice
&__header
color: c.$fg-warning
color: map.get($_theme, "fg-warning")
&__trace
background-color: c.$bg-darkest
background-color: map.get($_theme, "bg-0")
padding: 6px
.save-filter
margin-top: 12px
.border-look
background-color: c.$bg-darker
background-color: map.get($_theme, "bg-1")
font-size: 22px
padding: 7px 16px 8px
font-size: 24px
@ -48,17 +50,17 @@
.filter
display: flex
padding: 5px 0
border-top: 1px solid c.$edge-grey
border-top: 1px solid map.get($_theme, "edge-grey")
&:last-child
border-bottom: 1px solid c.$edge-grey
border-bottom: 1px solid map.get($_theme, "edge-grey")
&__details
flex: 1
&__type
font-size: 15px
color: c.$fg-dim
color: map.get($_theme, "fg-dim")
&__remove
flex-shrink: 0

View File

@ -1,4 +1,6 @@
@use "./colors.sass" as c
$_theme: () !default
@use "sass:map"
.footer__container
flex: 1
@ -10,9 +12,10 @@
display: flex
flex-direction: column
align-items: center
background-color: c.$bg-darkest
background-color: map.get($_theme, "bg-dim")
margin: 40px 0 0
padding: 10px 10px 30px
border-top: 1px solid map.get($_theme, "edge-grey")
.footer__cols
display: flex

View File

@ -1,7 +1,9 @@
@use "colors.sass" as c
$_theme: () !default
@use "sass:map"
@mixin disabled
background-color: c.$bg-dark
background-color: map.get($_theme, "bg-2")
color: #808080
fieldset
@ -20,7 +22,7 @@ fieldset
font-size: 28px
font-weight: bold
padding: 0
border-bottom: 1px solid #333
border-bottom: 1px solid map.get($_theme, "edge-grey") // TODO: originally contrasted more
line-height: 1.56
@media screen and (max-width: 400px)
@ -36,7 +38,7 @@ fieldset
position: relative
padding-bottom: 5px
margin-bottom: 5px
border-bottom: 1px solid #999
border-bottom: 1px solid map.get($_theme, "edge-grey")
@media screen and (max-width: 400px)
flex-direction: column
@ -52,7 +54,7 @@ fieldset
&__label
grid-area: label
padding: 8px 8px 8px 0px
color: #fff
color: map.get($_theme, "fg-main")
&__input
grid-area: input
@ -63,7 +65,7 @@ fieldset
white-space: pre-line
margin: 12px 0px 18px
font-size: 16px
color: #ccc
color: map.get($_theme, "fg-dim")
line-height: 1.2
//
@ -79,7 +81,7 @@ fieldset
width: 16px
height: 16px
padding: 0px
border: 1px solid #666
border: 1px solid map.get($_theme, "edge-grey")
border-radius: 3px
margin-left: 8px
position: relative
@ -101,6 +103,20 @@ fieldset
@include acts-like-button
cursor: pointer
.checkbox-hider__container
position: relative
display: grid // why does the default not work???
top: -42px
background: map.get($_theme, "bg-3")
line-height: 1
border: 1px solid map.get($_theme, "edge-grey")
border-radius: 8px
margin-bottom: -18px
.checkbox-hider__label
padding: 12px 0px 12px 32px
cursor: pointer
@mixin checkbox-hider($base)
##{$base}
position: relative
@ -110,18 +126,17 @@ fieldset
height: 42px
margin: 0
.#{$base}-container
position: relative
display: grid // why does the default not work???
top: -42px
background: c.$bg-accent-x
line-height: 1
border-radius: 8px
margin-bottom: -18px
/*
automatically add these styles too
this means that components based off this can either add the .checkbox-hider__container class, or they can add the .base-name-container class,
depending on which one is more reasonable in the moment
for example, .delete-confirm-container takes advantage of the @extend here.
.#{$base}-label
padding: 12px 0px 12px 32px
cursor: pointer
.#{$base}-container
@extend .checkbox-hider__container
.#{$base}-label
@extend .checkbox-hider__label
@mixin single-button-form
display: inline-block

View File

@ -1,4 +1,6 @@
@use "colors.sass" as c
$_theme: () !default
@use "sass:map"
.home-page
padding: 40px
@ -18,8 +20,8 @@
padding: 16px
border-radius: 4px
font-size: 20px
background-color: c.$bg-darker
color: c.$fg-main
background-color: map.get($_theme, "bg-1")
color: map.get($_theme, "fg-main")
p
margin: 0 32px

View File

@ -1,3 +1,5 @@
$_theme: () !default
.js-licenses-page
max-width: 800px
margin: 0 auto

33
sass/includes/_main.sass Normal file
View File

@ -0,0 +1,33 @@
$_theme: () !default
@use "sass:selector"
// preload second-level includes with the theme (there will be conflicts due to reconfiguration they are loaded individually)
// this isn't _exactly_ what @forward is supposed to be used for, but it's the best option here
@forward "video-list-item" show _ with ($_theme: $_theme)
@forward "forms" show _ with ($_theme: $_theme)
@forward "buttons" show _ with ($_theme: $_theme)
@use "base" with ($_theme: $_theme)
@use "video-page" with ($_theme: $_theme)
@use "video-embed" with ($_theme: $_theme)
@use "search-page" with ($_theme: $_theme)
@use "home-page" with ($_theme: $_theme)
@use "channel-page" with ($_theme: $_theme)
@use "subscriptions-page" with ($_theme: $_theme)
@use "settings-page" with ($_theme: $_theme)
@use "cant-think-page" with ($_theme: $_theme)
@use "privacy-page" with ($_theme: $_theme)
@use "licenses-page" with ($_theme: $_theme)
@use "filters-page" with ($_theme: $_theme)
@use "takedown-page" with ($_theme: $_theme)
@use "nav" with ($_theme: $_theme)
@use "footer" with ($_theme: $_theme)
@font-face
font-family: "Bariol"
src: url(/static/fonts/bariol.woff?statichash=1)
.button-container
display: flex
flex-wrap: wrap

View File

@ -1,12 +1,14 @@
@use "colors.sass" as c
@use "buttons.sass" as *
@use "_dimensions.sass" as dimensions
$_theme: () !default
@use "sass:map"
@use "buttons" as *
@use "_dimensions" as dimensions
.main-nav
background-color: c.$bg-accent
background-color: map.get($_theme, "bg-nav")
display: flex
padding: 8px
box-shadow: 0px 0px 20px 5px rgba(0, 0, 0, 0.1)
border-bottom: 1px solid map.get($_theme, "edge-grey")
+dimensions.thin
display: block
@ -30,10 +32,16 @@
font-weight: bold
&, &:visited
color: #fff
color: map.get($_theme, "fg-bright")
&:focus, &:hover
background-color: c.$bg-accent-x
background-color: map.get($_theme, "bg-4")
&.icon-link
color: map.get($_theme, "fg-dim")
&:hover, &:focus
color: map.get($_theme, "fg-bright")
.search-form
display: flex
@ -44,8 +52,7 @@
@include button-bg
padding: 10px
flex: 1
margin: 1px
border: 1px solid map.get($_theme, "bg-nav")
&:hover, &:focus
border: 1px solid c.$edge-grey
margin: 0px
border-color: map.get($_theme, "edge-grey")

View File

@ -1,3 +1,5 @@
$_theme: () !default
.privacy-page
max-width: 600px
margin: 0 auto

View File

@ -1,5 +1,7 @@
@use "video-list-item.sass" as *
@use "colors.sass" as c
$_theme: () !default
@use "sass:map"
@use "video-list-item" as *
.search-page
padding: 40px 20px 20px

View File

@ -1,5 +1,7 @@
@use "forms.sass" as forms
@use "colors.sass" as c
$_theme: () !default
@use "sass:map"
@use "forms" as forms
.settings-page
padding: 40px 20px 20px
@ -19,8 +21,9 @@
.more-settings
margin-top: 24px
padding: 12px
border: 1px solid map.get($_theme, "edge-grey")
border-radius: 8px
background-color: c.$bg-accent-x
background-color: map.get($_theme, "bg-3")
&__list
margin: 0
@ -34,7 +37,7 @@
margin-top: 24px
.delete-confirm-container
background: c.$bg-darker
background: map.get($_theme, "bg-1")
margin-bottom: -36px
@include forms.checkbox-hider("delete-confirm")

View File

@ -1,6 +1,8 @@
@use "colors.sass" as c
@use "video-list-item.sass" as *
@use "forms.sass" as forms
$_theme: () !default
@use "sass:map"
@use "forms" as forms
@use "video-list-item" as *
.subscriptions-page
padding: 40px 20px 20px
@ -33,7 +35,11 @@
.name
font-size: 22px
color: c.$fg-main
color: map.get($_theme, "fg-main")
.missing-reason
font-size: 16px
color: map.get($_theme, "fg-warning")
@include forms.checkbox-hider("watched-videos-display")

View File

@ -0,0 +1,16 @@
$_theme: () !default
@use "sass:map"
.takedown-page
max-width: 700px
margin: 0 auto
.new-section
margin-top: 200px
.important-section
padding: 4px 20px
border: 1px solid map.get($_theme, "edge-grey")
color: map.get($_theme, "fg-bright")
background-color: map.get($_theme, "bg-1")

View File

@ -0,0 +1,22 @@
$_theme: () !default
@use "sass:map"
@use "video-list-item" as *
.video-embed-page
display: block
.embed-video-section
width: 100%
height: 100vh
display: flex
flex-direction: column
.video
flex-grow: 1
display: block
width: 100%
.stream-notice
background: map.get($_theme, "bg-0")
padding: 4px

View File

@ -1,5 +1,7 @@
@use "colors.sass" as c
@use "_dimensions.sass" as dimensions
$_theme: () !default
@use "sass:map"
@use "_dimensions" as dimensions
// navigator hacks
.thumbnail > .thumbnail__options-container
@ -30,6 +32,7 @@
&__show-more
display: block
height: $more-size
color: #fff
line-height: 16px
font-size: 25px
text-align: center
@ -52,7 +55,7 @@
&__options-list
pointer-events: auto
display: grid
background-color: c.$bg-accent
background-color: map.get($_theme, "bg-3")
padding: 8px 0px
border-radius: 8px
box-shadow: 0 2px 6px 2px #000
@ -67,7 +70,7 @@
right: 0
transform: translate(-6px, -1px) rotate(-45deg)
clip-path: polygon(-5% -20%, 120% -20%, 120% 125%)
background-color: c.$bg-accent
background-color: map.get($_theme, "bg-3")
box-shadow: 0px 0px 4px 0px #000
pointer-events: none
@ -80,7 +83,7 @@
margin-bottom: 12px
@at-root .video-list-item--watched#{&}
background: c.$bg-darker
background: map.get($_theme, "bg-dim")
padding: 4px 4px 0px
margin: -4px -4px 8px
@ -93,7 +96,7 @@
.thumbnail
position: relative
display: flex
background: c.$bg-darkest
background: map.get($_theme, "bg-0")
&__link
font-size: 0 // remove whitespace around the image
@ -106,7 +109,7 @@
position: absolute
bottom: 3px
right: 3px
color: c.$fg-bright
color: #fff
font-size: 14px
background: rgba(20, 20, 20, 0.85)
line-height: 1
@ -119,20 +122,20 @@
line-height: 1.2
.title-link
color: c.$fg-main
color: map.get($_theme, "fg-main")
text-decoration: none
.author-line
margin-top: 4px
font-size: 15px
color: c.$fg-dim
color: map.get($_theme, "fg-dim")
.author
color: c.$fg-dim
color: map.get($_theme, "fg-dim")
text-decoration: none
&:hover, &:active
color: c.$fg-bright
color: map.get($_theme, "fg-bright")
text-decoration: underline
@mixin recommendation-item
@ -176,15 +179,15 @@
.author-line
font-size: 15px
color: c.$fg-main
color: map.get($_theme, "fg-main")
.author
color: c.$fg-main
color: map.get($_theme, "fg-main")
.description
margin-top: 16px
font-size: 15px
color: c.$fg-dim
color: map.get($_theme, "fg-dim")
+dimensions.thin
.description
@ -195,7 +198,7 @@
.description b
font-weight: normal
color: c.$fg-main
color: map.get($_theme, "fg-main")
@mixin channel-video
@include large-item

View File

@ -1,11 +1,11 @@
@use "colors.sass" as c
@use "video-list-item.sass" as *
$_theme: () !default
@use "sass:map"
@use "video-list-item" as *
.video-page
display: grid
grid-auto-flow: row
padding: 20px
grid-gap: 16px
@media screen and (min-width: 1000px)
grid-template-columns: 1fr 400px
@ -13,10 +13,25 @@
&--recommended-below, &--recommended-hidden
grid-template-columns: none
&--recommended-hidden .related-videos
display: none
&--recommended-side
.related-videos
border-left: 1px solid map.get($_theme, "edge-grey")
padding-left: 12px
padding-right: 20px
background-color: map.get($_theme, "bg-4")
padding-top: 12px
&--recommended-below
.related-videos
padding: 20px
&--recommended-hidden
.related-videos
display: none
.main-video-section
padding: 20px
.video-container
text-align: center
@ -27,7 +42,7 @@
max-height: 80vh
.stream-notice
background: c.$bg-darkest
background: map.get($_theme, "bg-0")
padding: 4px
.info
@ -45,15 +60,15 @@
margin: 0px 0px 4px
font-size: 30px
font-weight: normal
color: c.$fg-bright
color: map.get($_theme, "fg-bright")
word-break: break-word
.author-link
color: c.$fg-main
color: map.get($_theme, "fg-main")
text-decoration: none
&:hover, &:active
color: c.$fg-bright
color: map.get($_theme, "fg-bright")
text-decoration: underline
.info-secondary
@ -75,7 +90,7 @@
margin: 16px 4px
padding: 12px
border-radius: 4px
background-color: c.$bg-darkest
background-color: map.get($_theme, "bg-0")
&__description
margin-left: 12px
@ -85,7 +100,7 @@
&__script-warning
font-size: 15px
color: c.$fg-warning
color: map.get($_theme, "fg-warning")
&__buttons
display: flex
@ -97,12 +112,12 @@
line-height: 1.4
word-break: break-word
margin: 16px 4px 4px 4px
background-color: c.$bg-accent-area
background-color: map.get($_theme, "bg-5")
padding: 12px
border-radius: 4px
--regular-background: #{c.$bg-accent-area}
--highlight-background: #{c.$bg-darker}
--regular-background: #{map.get($_theme, "bg-5")}
--highlight-background: #{map.get($_theme, "bg-1")}
.subscribe-form
display: inline-block

View File

@ -1,17 +0,0 @@
$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
$fg-warning: #fdca6d
$edge-grey: #a0a0a0
$link: #8ac2f9
$power-deep: #c62727

View File

@ -1,14 +0,0 @@
@use "colors.sass" as c
.takedown-page
max-width: 700px
margin: 0 auto
.new-section
margin-top: 200px
.important-section
padding: 4px 20px
border: 1px solid c.$edge-grey
color: c.$fg-bright
background-color: c.$bg-darker

2
sass/light.sass Normal file
View File

@ -0,0 +1,2 @@
@use "themes/light" as *
@use "includes/main" with ($_theme: $theme)

View File

@ -1,30 +0,0 @@
@use "includes/colors.sass" as c
@use "includes/base.sass"
@use "sass:selector"
@use "includes/video-page.sass"
@use "includes/search-page.sass"
@use "includes/home-page.sass"
@use "includes/channel-page.sass"
@use "includes/subscriptions-page.sass"
@use "includes/settings-page.sass"
@use "includes/cant-think-page.sass"
@use "includes/privacy-page.sass"
@use "includes/licenses-page.sass"
@use "includes/filters-page.sass"
@use "includes/takedown-page.sass"
@use "includes/forms.sass"
@use "includes/nav.sass"
@use "includes/footer.sass"
@font-face
font-family: "Bariol"
src: url(/static/fonts/bariol.woff?statichash=1)
.icon-link:hover, .icon-link:focus
.icon
filter: brightness(2)
.button-container
display: flex
flex-wrap: wrap

View File

@ -0,0 +1,27 @@
$_theme: () !default
@use "sass:map"
// remove separating edges
.main-nav, .footer__center, .video-page--recommended-side .related-videos
border: none
// no background change to recommended videos sidebar
.video-page--recommended-side .related-videos
background-color: map.get($_theme, "bg-2")
// navigation shadow
.main-nav
position: relative // needed for box shadow to overlap related videos section
box-shadow: 0px 0px 20px 5px rgba(0, 0, 0, 0.1)
// thumbnail dropdown menu dividers
.menu-look
border-color: map.get($_theme, "bg-0")
// details areas
details, .checkbox-hider__container, .more-settings
border: none
details[open] summary
border: none
margin-bottom: 4px

38
sass/themes/_dark.scss Normal file
View File

@ -0,0 +1,38 @@
// Defined in scss file instead of sass because indented syntax does not have multiline maps
// https://github.com/sass/sass/issues/216
@use "sass:map";
// This section is for colour shades
$theme: (
// darker
"bg-0": #252628,
"bg-1": #303336,
// regular
"bg-2": #36393f,
// lighter
"bg-3": #3f4247, // slightly
"bg-4": #44474b, // noticably
"bg-5": #4f5359, // brightly
"fg-bright": #fff,
"fg-main": #ddd,
"fg-dim": #bbb,
"fg-warning": #fdca6d,
"edge-grey": #a0a0a0,
"placeholder": #c4c4c4,
"link": #8ac2f9,
"power-deep": #c62727,
"power-fg": "#fff",
"image-dropdown": url(/static/images/arrow-down-wide-dark.svg)
);
// This section is for colour meanings
$theme: map.merge($theme, (
"bg-dim": map.get($theme, "bg-0"),
"bg-nav": map.get($theme, "bg-5"),
));

View File

@ -0,0 +1,8 @@
// extend regular light theme to change a couple of shades
@use "light";
@use "sass:map";
// this section is for colour meanings
$theme: map.merge(light.$theme, (
"edge-grey": #c0c0c0,
));

38
sass/themes/_light.scss Normal file
View File

@ -0,0 +1,38 @@
// Defined in scss file instead of sass because indented syntax does not have multiline maps
// https://github.com/sass/sass/issues/216
@use "sass:map";
// this section is for colour shades
$theme: (
// lighter
"bg-0": #fff,
"bg-1": #fff,
// regular
"bg-2": #f2f2f2,
// darker
"bg-3": #e8e8e8, // slightly
"bg-4": #dadada, // noticably
"bg-5": #d0d0d0, // brightly
"fg-bright": #000,
"fg-main": #202020,
"fg-dim": #454545,
"fg-warning": #ce8600,
"edge-grey": #909090,
"placeholder": #636363,
"link": #0b51d4,
"power-deep": #c62727,
"power-fg": #fff,
"image-dropdown": url(/static/images/arrow-down-wide-light.svg)
);
// this section is for colour meanings
$theme: map.merge($theme, (
"bg-dim": map.get($theme, "bg-4"),
"bg-nav": map.get($theme, "bg-0")
));

View File

@ -1,9 +1,11 @@
const {Pinski} = require("pinski")
const {setInstance} = require("pinski/plugins")
const constants = require("./utils/constants")
const iconLoader = require("./utils/icon-loader").icons
;(async () => {
await require("./utils/upgradedb")()
const icons = await iconLoader
const server = new Pinski({
port: 10412,
@ -13,19 +15,19 @@ const constants = require("./utils/constants")
setInstance(server)
server.pugDefaultLocals.constants = constants
server.pugDefaultLocals.icons = icons
server.muteLogsStartingWith("/vi/")
server.muteLogsStartingWith("/favicon")
server.muteLogsStartingWith("/static")
server.addSassDir("sass", ["sass/includes"])
server.addRoute("/static/css/main.css", "sass/main.sass", "sass")
server.addSassDir("sass", ["sass/includes", "sass/themes", "sass/theme-modules"])
server.addRoute("/static/css/dark.css", "sass/dark.sass", "sass")
server.addRoute("/static/css/light.css", "sass/light.sass", "sass")
server.addRoute("/static/css/edgeless-light.css", "sass/edgeless-light.sass", "sass")
server.addPugDir("pug", ["pug/includes"])
server.addPugDir("pug/errors")
server.addRoute("/cant-think", "pug/cant-think.pug", "pug")
server.addRoute("/privacy", "pug/privacy.pug", "pug")
server.addRoute("/licenses", "pug/licenses.pug", "pug")
server.addStaticHashTableDir("html/static/js")
server.addStaticHashTableDir("html/static/js/elemjs")

View File

@ -9,6 +9,10 @@ let constants = {
type: "string",
default: "http://localhost:3000"
},
theme: {
type: "integer",
default: 0
},
save_history: {
type: "boolean",
default: false
@ -46,6 +50,7 @@ let constants = {
csrf_time: 4*60*60*1000,
seen_token_subscriptions_eligible: 40*60*60*1000,
subscriptions_refresh_loop_min: 5*60*1000,
subscriptions_refesh_fake_not_found_cooldown: 10*60*1000,
},
// Pattern matching.

View File

@ -54,7 +54,7 @@ class User {
getSubscriptions() {
if (this.token) {
return db.prepare("SELECT ucid FROM Subscriptions WHERE token = ? AND channel_missing = 0").pluck().all(this.token)
return db.prepare("SELECT ucid FROM Subscriptions WHERE token = ?").pluck().all(this.token)
} else {
return []
}
@ -62,7 +62,7 @@ class User {
isSubscribed(ucid) {
if (this.token) {
return !!db.prepare("SELECT * FROM Subscriptions WHERE token = ? AND ucid = ? AND channel_missing = 0").get([this.token, ucid])
return !!db.prepare("SELECT * FROM Subscriptions WHERE token = ? AND ucid = ?").get([this.token, ucid])
} else {
return false
}

8
utils/icon-loader.js Normal file
View File

@ -0,0 +1,8 @@
const fs = require("fs").promises
const names = ["subscriptions", "settings"]
const icons = names.map(name => fs.readFile(`html/static/images/${name}.svg`, "utf8"))
module.exports.icons = Promise.all(icons).then(resolvedIcons => {
return new Map(names.map((name, index) => [name, resolvedIcons[index]]))
})

View File

@ -70,6 +70,31 @@ const deltas = [
.run()
db.prepare("CREATE TABLE TakedownChannels (ucid TEXT NOT NULL, org TEXT, url TEXT, PRIMARY KEY (ucid))")
.run()
},
// 11: Settings +theme
function() {
db.prepare("ALTER TABLE Settings ADD COLUMN theme INTEGER DEFAULT 0")
.run()
},
// 12: Channels +missing +missing_reason, Subscriptions -
// Better management for missing channels
// We totally discard the existing Subscriptions.channel_missing since it is unreliable.
function() {
db.prepare("ALTER TABLE Channels ADD COLUMN missing INTEGER NOT NULL DEFAULT 0")
.run()
db.prepare("ALTER TABLE Channels ADD COLUMN missing_reason TEXT")
.run()
// https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes
db.transaction(() => {
db.prepare("CREATE TABLE NEW_Subscriptions (token TEXT NOT NULL, ucid TEXT NOT NULL, PRIMARY KEY (token, ucid))")
.run()
db.prepare("INSERT INTO NEW_Subscriptions (token, ucid) SELECT token, ucid FROM Subscriptions")
.run()
db.prepare("DROP TABLE Subscriptions")
.run()
db.prepare("ALTER TABLE NEW_Subscriptions RENAME TO Subscriptions")
.run()
})()
}
]
@ -82,7 +107,7 @@ async function createBackup(entry) {
/**
* @param {number} entry
* @param {boolean} log
* @param {boolean} [log]
*/
function runDelta(entry, log) {
process.stdout.write(`Upgrading database to version ${entry}... `)

View File

@ -1,15 +1,59 @@
const {request} = require("./request")
const db = require("./db")
async function fetchChannel(ucid, instance) {
async function fetchChannel(path, ucid, instance) {
function updateGoodData(channel) {
const bestIcon = channel.authorThumbnails.slice(-1)[0]
const iconURL = bestIcon ? bestIcon.url : null
db.prepare("REPLACE INTO Channels (ucid, name, icon_url, missing, missing_reason) VALUES (?, ?, ?, 0, NULL)").run(channel.authorId, channel.author, iconURL)
}
function updateBadData(channel) {
if (channel.identifier === "NOT_FOUND" || channel.identifier === "ACCOUNT_TERMINATED") {
db.prepare("UPDATE Channels SET missing = 1, missing_reason = ? WHERE ucid = ?").run(channel.error, channel.authorId)
return {
missing: true,
message: channel.error
}
} else {
return {
missing: false,
message: channel.error
}
}
}
if (!instance) throw new Error("No instance parameter provided")
// fetch
const channel = await request(`${instance}/api/v1/channels/${ucid}`).then(res => res.json())
// update database
const bestIcon = channel.authorThumbnails.slice(-1)[0]
const iconURL = bestIcon ? bestIcon.url : null
db.prepare("REPLACE INTO Channels (ucid, name, icon_url) VALUES (?, ?, ?)").run([channel.authorId, channel.author, iconURL])
// return
const row = db.prepare("SELECT * FROM Channels WHERE ucid = ?").get(ucid)
// handle the case where the channel has a known error
if (row && row.missing_reason) {
return {
error: true,
ucid,
row,
missing: true,
message: row.missing_reason
}
}
/** @type {any} */
const channel = await request(`${instance}/api/v1/channels/${ucid}?second__path=${path}`).then(res => res.json())
// handle the case where the channel has a newly discovered error
if (channel.error) {
const missingData = updateBadData(channel)
return {
error: true,
ucid,
row,
...missingData
}
}
// handle the case where the channel returns good data (this is the only remaining scenario)
updateGoodData(channel)
return channel
}