beta testing the html serving via backend

This commit is contained in:
localhost 2025-10-14 20:53:40 +02:00
parent ce7bb54f8c
commit c673a4dbc9
27 changed files with 1712 additions and 49 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -14,9 +14,13 @@
"typescript": "^5.0.0"
},
"dependencies": {
"@elysiajs/static": "^1.4.0",
"@types/html-minifier-next": "^2.1.0",
"@types/pg": "^8.11.10",
"date-fns": "^4.1.0",
"elysia": "^1.1.25",
"eta": "^4.0.1",
"html-minifier-next": "^2.1.4",
"ioredis": "^5.4.1",
"isomorphic-dompurify": "^2.18.0",
"kysely": "^0.27.4",

1
public/favicon.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="white" d="M5 21q-.825 0-1.413-.588T3 19V6.5q0-.375.125-.675t.325-.575l1.4-1.7q.2-.275.5-.413T6 3h12q.35 0 .65.137t.5.413l1.4 1.7q.2.275.325.575T21 6.5V19q0 .825-.588 1.413T19 21H5Zm.4-15h13.2l-.85-1H6.25L5.4 6ZM12 17.575q.2 0 .375-.062t.325-.213l2.6-2.6q.275-.275.275-.7t-.275-.7q-.275-.275-.7-.275t-.7.275l-.9.9V11q0-.425-.288-.713T12 10q-.425 0-.713.288T11 11v3.2l-.9-.9q-.275-.275-.7-.275t-.7.275q-.275.275-.275.7t.275.7l2.6 2.6q.15.15.325.212t.375.063Z"/></svg>

After

Width:  |  Height:  |  Size: 560 B

52
public/pgp.txt Normal file
View File

@ -0,0 +1,52 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
cw3z0oKcZ3Irwfu2LQ==
=5zdM
-----END PGP PUBLIC KEY BLOCK-----

41
public/robots.txt Normal file
View File

@ -0,0 +1,41 @@
User-agent: *
Disallow: /playlist
Disallow: /hashtag/*
Disallow: /live/*
Disallow: /user/*
Disallow: /shorts/*
Disallow: /c/*
Disallow: /@*
# seo backlink bots are a waste of bandwidth
User-agent: dotbot
Disallow: /
User-agent: BLEXBot
Disallow: /
User-agent: SemrushBot
Disallow: /
User-agent: AhrefsBot
Disallow: /
User-agent: barkrowler
Disallow: /
User-agent: VelenPublicWebCrawler
Disallow: /
User-agent: AwarioRssBot
Disallow: /
User-agent: AwarioSmartBot
Disallow: /
User-agent: PetalBot
Disallow: /
User-Agent: ImagesiftBot
Disallow: /
Sitemap: https://api.preservetube.com/sitemap.xml

View File

@ -1,10 +1,12 @@
import { Elysia } from 'elysia';
import { staticPlugin } from '@elysiajs/static'
import latest from '@/router/latest'
import search from '@/router/search'
import transparency from '@/router/transparency'
import video from '@/router/video'
import websocket from '@/router/websocket'
import html from '@/router/html'
const app = new Elysia()
app.use(latest)
@ -12,11 +14,16 @@ app.use(search)
app.use(transparency)
app.use(video)
app.use(websocket)
app.use(html)
app.onRequest(({ set, path }: any) => {
set.headers['Onion-Location'] = 'http://tubey5btlzxkcjpxpj2c7irrbhvgu3noouobndafuhbw4i5ndvn4v7qd.onion' + path
})
process.on('uncaughtException', err => {
console.log(err)
})
app.use(staticPlugin({ prefix: '/' }))
app.listen(1337);
console.log(
`api is running at ${app.server?.hostname}:${app.server?.port}`

68
src/router/html.ts Normal file
View File

@ -0,0 +1,68 @@
import { Elysia } from 'elysia';
import { m, eta } from '@/utils/html'
const app = new Elysia()
app.get('/', async ({ set }) => {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return await m(eta.render('./index', {
title: 'Home | PreserveTube',
description: 'PreserveTube is a time capsule for YouTube videos! It allows you to preserve any YouTube video, creating a snapshot that will always be available even if the original video disappears or is taken down.',
keywords: 'youtube archive, youtube video history, youtube deleted, youtube deleted video, youtube downloader, youtube archiver'
}))
})
app.get('/save', async ({ query: { url }, set, error }) => {
if (!url) return error(400, 'No url provided.')
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return await m(eta.render('./save', {
title: 'Save Video | PreserveTube',
websocket: process.env.WEBSOCKET,
sitekey: process.env.SITEKEY,
url
}))
})
app.get('/savechannel', async ({ query: { url }, set, error }) => {
if (!url) return error(400, 'No url provided.')
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return await m(eta.render('./savechannel', {
title: 'Save Channel | PreserveTube',
websocket: process.env.WEBSOCKET,
sitekey: process.env.SITEKEY,
url
}))
})
app.get('/abuse', async ({ set }) => {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return await m(eta.render('./abuse', {
title: 'Abuse Report | PreserveTube',
}))
})
app.get('/dmca', async ({ set }) => {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return await m(eta.render('./dmca', {
title: 'DMCA Takedown | PreserveTube',
}))
})
app.get('/privacy', async ({ set }) => {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return await m(eta.render('./privacy', {
title: 'Privacy Policy | PreserveTube',
}))
})
app.get('/donate', async ({ set }) => {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return await m(eta.render('./donate', {
title: 'Donations | PreserveTube',
description: "Support our mission through donations.",
keywords: "preservetube donations, crypto"
}))
})
export default app;

View File

@ -1,24 +1,32 @@
import { Elysia } from 'elysia';
import { db } from '@/utils/database'
import { createSitemapXML, createSitemapIndexXML } from '@/utils/sitemap'
import { m, eta } from '@/utils/html'
import redis from '@/utils/redis';
const app = new Elysia()
app.get('/latest', async () => {
const cached = await redis.get('latest')
if (cached) return JSON.parse(cached)
app.get('/latest', async ({ set }) => {
const cached = await redis.get('latest:html')
if (cached) {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return cached
}
const json = await db.selectFrom('videos')
.select(['id', 'title', 'thumbnail', 'published', 'archived', 'channel', 'channelId', 'channelAvatar', 'channelVerified'])
.orderBy('archived desc')
.limit(50)
.limit(51)
.execute()
await redis.set('latest', JSON.stringify(json), 'EX', 3600)
const html = await m(eta.render('./latest', {
data: json,
title: 'Latest | PreserveTube',
}))
await redis.set('latest:html', html, 'EX', 3600)
return json
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return html
})
app.get('/sitemap-index.xml', async ({ set }) => {

View File

@ -3,6 +3,7 @@ import { RedisRateLimiter } from 'rolling-rate-limiter'
import { db } from '@/utils/database'
import { validateVideo, validateChannel } from '@/utils/regex'
import { m, eta } from '@/utils/html'
import redis from '@/utils/redis';
const app = new Elysia()
@ -14,20 +15,33 @@ const limiter = new RedisRateLimiter({
maxInInterval: 15
})
app.get('/search/video', async ({ headers, query: { search }, error }) => {
app.get('/search', async ({ headers, query: { search }, set, redirect, error }) => {
const hash = Bun.hash(headers['x-userip'] || headers['cf-connecting-ip'] || '0.0.0.0')
const isLimited = await limiter.limit(hash.toString())
if (isLimited) return error(429, 'error-You have been ratelimited.')
if (isLimited) return error(429, 'You have been ratelimited.')
const videoId = validateVideo(search)
if (videoId) return `redirect-${process.env.FRONTEND}/watch?v=${videoId}`
if (videoId) return redirect(`/watch?v=${videoId}`)
const cached = await redis.get(`search:${Bun.hash(search).toString()}:html`)
if (cached) {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return cached
}
const videos = await db.selectFrom('videos')
.selectAll()
.where('title', 'ilike', `%${search}%`)
.execute()
return videos
const html = await m(eta.render('./search', {
data: videos,
title: 'Search | PreserveTube',
}))
await redis.set(`search:${Bun.hash(search).toString()}:html`, html, 'EX', 3600)
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return html
}, {
query: t.Object({
search: t.String()

View File

@ -1,41 +1,55 @@
import { Elysia } from 'elysia';
import { db } from '@/utils/database'
import { m, eta } from '@/utils/html'
import redis from '@/utils/redis';
const app = new Elysia()
app.get('/transparency/list', async () => {
const cached = await redis.get('transparency')
if (cached) return JSON.parse(cached)
app.get('/transparency', async ({ set }) => {
const cached = await redis.get('transparency:html')
if (cached) {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return cached
}
const reports = await db.selectFrom('reports')
.selectAll()
.execute()
const json = reports.map(r => {
return {
...r,
details: (r.details).split('<').join('&lt;').split('>').join('&gt;'),
date: (r.date).toISOString().slice(0, 10)
const html = await m(eta.render('./transparency', {
data: reports,
title: 'Transparency | PreserveTube',
}))
await redis.set('transparency:html', html, 'EX', 3600)
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return html
})
app.get('/transparency/:id', async ({ params: { id }, set, error }) => {
const cached = await redis.get(`transparency:${id}:html`)
if (cached) {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return cached
}
})
await redis.set('transparency', JSON.stringify(json), 'EX', 3600)
return json
})
app.get('/transparency/:id', async ({ params: { id } }) => {
const cached = await redis.get(`transparency:${id}`)
if (cached) return JSON.parse(cached)
const json = await db.selectFrom('reports')
.selectAll()
.where('target', '=', id)
.execute()
.executeTakeFirst()
if (!json) return error(404, 'Report not found.')
await redis.set(`transparency:${id}`, JSON.stringify(json), 'EX', 3600)
return json
const html = await m(eta.render('./transparency-entry', {
title: `${json.title} | PreserveTube`,
t_title: json.title,
date: json.date.toISOString(),
details: json.details,
}))
await redis.set(`transparency:${id}:html`, html, 'EX', 3600)
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return html
})
export default app

View File

@ -4,6 +4,7 @@ import DOMPurify from 'isomorphic-dompurify'
import { db } from '@/utils/database'
import { getChannel, getChannelVideos } from '@/utils/metadata';
import { convertRelativeToDate } from '@/utils/common';
import { m, eta } from '@/utils/html'
import redis from '@/utils/redis';
const app = new Elysia()
@ -16,6 +17,58 @@ interface processedVideo {
deleted?: undefined;
}
app.get('/watch', async ({ query: { v }, set, redirect, error }) => {
if (!v) return error(404)
const cached = await redis.get(`watch:${v}:html`)
if (cached) {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return cached
}
if (!v.match(/[\w\-_]{11}/)) return error(404)
const json = await db.selectFrom('videos')
.selectAll()
.where('id', '=', v)
.executeTakeFirst()
if (!json) {
const html = await m(eta.render('./watch', {
isMissing: true,
id: v,
title: 'Video Not Found | PreserveTube'
}))
set.headers['cache-control'] = 'public, no-cache'
set.headers['content-type'] = 'text/html; charset=utf-8'
return error(404, html)
}
if (json.disabled) return redirect(`/transparency/${v}`)
let transparency: any[] = []
if (json.hasBeenReported) {
transparency = await db.selectFrom('reports')
.selectAll()
.where('target', '=', v)
.execute()
}
const html = await m(eta.render('./watch', {
transparency,
...json,
description: DOMPurify.sanitize(json.description),
title: `${json.title} | PreserveTube`,
v_title: json.title,
keywords: `${json.title} video archive, ${json.title} ${json.channel} archive`,
manualAnalytics: true
}))
await redis.set(`watch:${v}:html`, html, 'EX', 3600)
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return html
})
app.get('/video/:id', async ({ params: { id }, error }) => {
const cached = await redis.get(`video:${id}`)
if (cached) return JSON.parse(cached)
@ -34,16 +87,26 @@ app.get('/video/:id', async ({ params: { id }, error }) => {
}
})
app.get('/channel/:id', async ({ params: { id }, error }) => {
const cached = await redis.get(`channel:${id}`)
if (cached) return JSON.parse(cached)
app.get('/channel/:id', async ({ params: { id }, set }) => {
const cached = await redis.get(`channel:${id}:html`)
if (cached) {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return cached
}
const [videos, channel] = await Promise.all([
getChannelVideos(id),
getChannel(id)
])
if (!videos || !channel || videos.error || channel.error) return error(404, { error: '404' })
if (!videos || !channel || videos.error || channel.error) {
const html = await m(eta.render('./channel', {
failedToFetch: true,
id
}))
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return html
}
const archived = await db.selectFrom('videos')
.select(['id', 'title', 'thumbnail', 'published', 'archived'])
@ -68,20 +131,26 @@ app.get('/channel/:id', async ({ params: { id }, error }) => {
processedVideos.sort((a: any, b: any) => new Date(b.published).getTime() - new Date(a.published).getTime());
const json = {
const html = await m(eta.render('./channel', {
name: channel.metadata.title,
avatar: channel.metadata.avatar[0].url,
verified: channel.header.author?.is_verified,
videos: processedVideos
}
videos: processedVideos,
title: `${channel.metadata.title} | PreserveTube`,
keywords: `${channel.metadata.title} archive, ${channel.metadata.title} channel archive, ${channel.metadata.title} deleted video, ${channel.metadata.title} video deleted`
}))
await redis.set(`channel:${id}:html`, html, 'EX', 3600)
await redis.set(`channel:${id}`, JSON.stringify(json), 'EX', 3600)
return json
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return html
})
app.get('/channel/:id/videos', async ({ params: { id } }) => {
const cached = await redis.get(`channelVideos:${id}`)
if (cached) return JSON.parse(cached)
app.get('/channel/:id/videos', async ({ params: { id }, set }) => {
const cached = await redis.get(`channelVideos:${id}:html`)
if (cached) {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return cached
}
const archived = await db.selectFrom('videos')
.select(['id', 'title', 'thumbnail', 'published', 'archived'])
@ -89,11 +158,15 @@ app.get('/channel/:id/videos', async ({ params: { id } }) => {
.orderBy('published desc')
.execute()
const json = {
videos: archived
}
await redis.set(`channelVideos:${id}`, JSON.stringify(json), 'EX', 3600)
return json
const html = await m(eta.render('./channel-videos', {
videos: archived,
title: `${id} videos | PreserveTube`,
keywords: `${id} archive, ${id} channel archive, ${id} deleted video, ${id} video deleted`
}))
await redis.set(`channelVideos:${id}:html`, html, 'EX', 3600)
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return html
})
export default app

67
src/templates/abuse.eta Normal file
View File

@ -0,0 +1,67 @@
<% layout('./layout') %>
<div class="text">
<div class="reports">
When it comes to reports of child abuse, PLEASE report it to <a href="https://report.cybertip.org/">NCMEC</a> or your local law enforcement agency, who will then contact us. PreserveTube is not legally equipped to handle such reports.
</div>
Our top priority at PreserveTube is to provide a safe and respectful environment for all users of our service. If you come across any content on our website that you believe is inappropriate or violates our community guidelines, we urge you to report it immediately so we can take appropriate action.<br><br>
To file an abuse report, please follow these steps:
<ol>
<li>Identify the content that you believe is inappropriate or in violation of our community guidelines.</li>
<li>Take a screenshot or copy the URL of the content for reference.</li>
<li>Send an email to <a href="mailto:abuse@preservetube.com">abuse@preservetube.com</a> with the subject line "Abuse Report".</li>
<li>In the email, please provide the following information:
<ul>
<li>Your full name and contact information.</li>
<li>A detailed description of the content and why you believe it is inappropriate or in violation of our community guidelines.</li>
<li>The screenshot or URL of the content for reference.</li>
</ul>
</li>
</ol>
At Preservetube, we take a <i>Law of the Land</i> approach to content moderation. This means that we do not allow any content that is illegal or violates the laws of either The Netherlands or The United States of America.
</div>
<div class="pgp">
<a href="pgp.txt">Our PGP key</a> is available for secure communication.
</div>
<style>
a {
font-weight: bold;
}
.text {
margin-top: 5%;
margin-bottom: 5%;
margin-left: auto;
margin-right: auto;
width: 75%;
}
.pgp {
position: absolute;
margin-left: auto;
margin-right: auto;
left: 0;
right: 0;
bottom: 10px;
text-align: center;
}
.reports {
background-color: #ffd7cf;
border: 2px dashed #da7b5e;
padding: 10px;
margin-top: 5px;
margin-bottom: 1rem;
}
h2 {
margin-bottom: 2px;
}
</style>

View File

@ -0,0 +1,76 @@
<% layout('./layout') %>
<div class="grid">
<% it.videos.forEach(function(v){ %>
<div class="video">
<a href="/watch?v=<%= v.id %>">
<img class="thumbnail" src="<%= v.thumbnail %>" />
<div class="title"><%= v.title %></div>
<div class="date">
Published on <%= v.published %>
<% if (v.archived) { %>
| Archived on <%= v.archived %>
<% } %>
</div>
<% if (v.deleted) { %>
<div class="deleted">(the original video has been deleted)</div>
<% } %>
</a>
</div>
<% }) %>
</div>
<style>
.channel {
margin: 0 auto;
display: flex;
place-items: center;
justify-content: center;
}
.channel img {
border-radius: 50%;
margin-right: 10px;
display: inline-block;
}
.channel h1 {
display: inline-block;
}
.verified {
height: 20px;
margin-left: 5px;
content: url("https://api.iconify.design/ion/checkmark-circle.svg");
display: inline-block;
}
.grid {
margin-left: 10%;
margin-right: 10%;
margin-top: 1%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.video {
padding: 20px;
}
.video img {
width: 100%;
aspect-ratio: 16/9;
}
.title {
margin-top: 5px;
font-size: 20px;
font-weight: bold;
}
.deleted {
font-size: 12px;
}
</style>

92
src/templates/channel.eta Normal file
View File

@ -0,0 +1,92 @@
<% layout('./layout') %>
<% if (it.failedToFetch) { %>
<div class="channel">
<h2>
An error occurred while attempting to retrieve this channel from YouTube. <br/>
To access all videos without relying on YouTube data, please <a href="/channel/<%= it.id %>/videos">click here</a>.
</h2>
</div>
<% } else { %>
<div class="channel">
<img src="<%= it.avatar %>" />
<h1><%= it.name %></h1>
</div>
<div class="grid">
<% it.videos.forEach(function(v){ %>
<div class="video">
<a href="/watch?v=<%= v.id %>">
<img class="thumbnail" src="<%= v.thumbnail %>" />
<div class="title"><%= v.title %></div>
<div class="date">
Published on <%= v.published %>
<% if (v.archived) { %>
| Archived on <%= v.archived %>
<% } %>
</div>
<% if (v.deleted) { %>
<div class="deleted">(the original video has been deleted)</div>
<% } %>
</a>
</div>
<% }) %>
</div>
<% } %>
<style>
.channel {
margin: 0 auto;
display: flex;
place-items: center;
justify-content: center;
}
.channel img {
border-radius: 50%;
margin-right: 10px;
display: inline-block;
width: 48px;
height: 48px;
}
.channel h1 {
display: inline-block;
}
.verified {
height: 20px;
margin-left: 5px;
content: url("https://api.iconify.design/ion/checkmark-circle.svg");
display: inline-block;
}
.grid {
margin-left: 10%;
margin-right: 10%;
margin-top: 1%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.video {
padding: 20px;
}
.video img {
width: 100%;
aspect-ratio: 16/9;
}
.title {
margin-top: 5px;
font-size: 20px;
font-weight: bold;
}
.deleted {
font-size: 12px;
}
</style>

51
src/templates/dmca.eta Normal file
View File

@ -0,0 +1,51 @@
<% layout('./layout') %>
<div class="text">
To submit a DMCA takedown notice, please send an email to <a href="mailto:legal@preservetube.com">legal@preservetube.com</a> that includes the following information:
<ul>
<li>Identification of the copyrighted work that you claim has been infringed, or if multiple copyrighted works are covered by a single notification, provide a representative list of such works.</li>
<li>Identification of the material that you claim is infringing and information reasonably sufficient to permit us to locate the material, including the URL(s) where the infringing material is located on PreserveTube.</li>
<li>Your contact information, including your full legal name, mailing address, telephone number, and email address.</li>
<li>A statement that you have a good faith belief that the use of the copyrighted materials described above and contained on the service is not authorized by the copyright owner, its agent, or by law.</li>
<li>A statement that you hereby declare, under penalty of perjury, that the information in your notification is accurate and that you are the owner of the copyright or other intellectual property interest, or authorized to act on behalf of the owner of the copyright or other intellectual property interest.</li>
<li>An electronic or physical signature of the person authorized to act on behalf of the owner of the copyright or other intellectual property interest.</li>
</ul>
If we receive a valid DMCA takedown notice, we will add a notice to the video page indicating that the content has been stricken with a copyright claim. This notice will include the name of the person or entity that sent the copyright notice and the email logs associated with the notice. If the video is obviously copyrighted content, such as a movie or TV show, it will be taken down. Any other videos will remain online.<br><br>
Please note that submitting a false or misleading DMCA takedown notice may result in legal consequences. If you are unsure whether your copyrighted material has been infringed or if you need legal advice, we recommend that you consult with an attorney.<br><br>
P.S.: A DMCA takedown notice will not remove embarrassing videos that Preservetube has archived from the internet. We will ensure that the videos remain accessible to the public, even if that's not possible via our website.<br><br>
If you have any questions about this DMCA policy, please contact <a href="mailto:legal@preservetube.com">legal@preservetube.com</a>.
</div>
<div class="pgp">
<a href="pgp.txt">Our PGP key</a> is available for secure communication.
</div>
<style>
a {
font-weight: bold;
}
.text {
margin-top: 5%;
margin-bottom: 5%;
margin-left: auto;
margin-right: auto;
width: 75%;
}
.pgp {
position: absolute;
margin-left: auto;
margin-right: auto;
left: 0;
right: 0;
bottom: 10px;
text-align: center;
}
h2 {
margin-bottom: 2px;
}
</style>

49
src/templates/donate.eta Normal file
View File

@ -0,0 +1,49 @@
<% layout('./layout') %>
<div class="text">
Infrastructure costs money, and running PreserveTube costs $1,185 a year. If you love what we do, a little support would go a long way in keeping us safe and sound!
<h3>FIAT Donations</h3>
<p>
Unfortunately, PreserveTube only accepts cryptocurrency donations at this time.
Feeling extra generous? You can learn how to purchase Bitcoin here: <a style="font-weight: bold;" href="https://buybitcoinworldwide.com">buybitcoinworldwide.com</a>.
</p>
<h3>Cryptocurrency Donations</h3>
<p>We accept the following cryptocurrencies to support our cause:</p>
<p>
<span class="label">BTC</span>: <code>bc1qfa6uqp72a2hgg3ac06f2du2cvug4hadufd7sql</code><br>
<span class="label">ETH</span>: <code>0xF6923d9e17B32efF1D82200E82E3b9f500c6fAED</code><br>
<span class="label">LTC</span>: <code>LW7qhYnAfjqieNdySNeURhrpCzcCjcuj7U</code> <br>
<span class="label">SOL</span>: <code>GK5WTtJeZRuJheSbm2ZFYWHQ3QfZPgLaZ1Vq5EXi3e91</code> <br>
<span class="label">XMR</span>: <code>8BqeuL5iRzXccqc8wy8U2RgJF7RdTX52Pgpdc6QYcrHfLnoK9jtQ3MrDv7V6T14hHjMHfE28wsVqhCGiCeXsRF4651SnGNC</code> <br><br>
<span class="label">Or anything else:</span> <br>
<iframe src="https://trocador.app/anonpay/?ticker_to=xmr&network_to=Mainnet&address=8BqeuL5iRzXccqc8wy8U2RgJF7RdTX52Pgpdc6QYcrHfLnoK9jtQ3MrDv7V6T14hHjMHfE28wsVqhCGiCeXsRF4651SnGNC&donation=True&simple_mode=True&ticker_from=doge&network_from=Mainnet&bgcolor=" width="310" height="230" style="border:0" scrolling="no"></iframe>
</p>
</div>
<style>
.text {
margin-top: 5%;
margin-bottom: 5%;
margin-left: auto;
margin-right: auto;
width: 75%;
}
@media screen and (max-width: 768px) {
.text {
width: 90%;
}
}
.label {
font-weight: bold;
}
code {
word-break: break-all;
user-select: all;
-webkit-user-select: all;
}
</style>

225
src/templates/index.eta Normal file
View File

@ -0,0 +1,225 @@
<% layout('./layout') %>
<div class="container">
<form class="archive" action="save">
<div class="title">Archive a Youtube Video</div>
<input
class="url"
name="url"
type="text"
placeholder="https://www.youtube.com/watch?v=smVU37xAhY8"
tabindex="1"
required
/>
<input class="submit" type="submit" value="save" tabindex="-1" />
</form>
<!-- <form class="playlist" action="saveplaylist">
<div class="title">Archive a Youtube Playlist</div>
<input
class="url"
name="url"
type="text"
placeholder="https://www.youtube.com/playlist?list=PLhixgUqwRTjwvBI-hmbZ2rpkAl4lutnJG"
tabindex="1"
required
/>
<input class="submit" type="submit" value="save" tabindex="-1" />
</form> -->
<form class="channel" action="savechannel">
<div class="title">Archive a Youtube Channel</div>
<input
class="url"
name="url"
type="text"
placeholder="https://www.youtube.com/channel/UCyjKBSGMcxdGbSD86iq_SBw"
tabindex="1"
required
/>
<input class="submit" type="submit" value="save" tabindex="-1" />
</form>
<!-- <form class="channel" action="autodownload">
<div class="title">
Automatically download new videos from channel
</div>
<input
class="url"
name="url"
type="text"
placeholder="https://www.youtube.com/channel/UCyjKBSGMcxdGbSD86iq_SBw"
tabindex="1"
required
/>
<input class="submit" type="submit" value="save" tabindex="-1" />
</form> -->
<div class="about">
PreserveTube is a time capsule for YouTube videos! It allows you to
"preserve" any YouTube video, creating a snapshot that will always
be available even if the original video disappears or is taken down. <br
/><br />
It saves not only the original video, but also descriptions, titles and
other interesting metadata.<br /><br /><br />
PreserveTube is dedicated to preserving the legacy of YouTube, one
video at a time.
</div>
<form class="search" action="/search">
<div class="title">Search for the archive of an Youtube Video</div>
<input
class="url"
name="search"
type="text"
placeholder="The Chris Chan Interview or https://www.youtube.com/watch?v=mA80W7qVcSM"
tabindex="1"
required
/>
<input class="submit" type="submit" value="search" tabindex="-1" />
</form>
<!-- <form class="search" action={`${backend}/search/playlist`}>
<div class="title">
Search for the archive of an Youtube Playlist
</div>
<input
class="url"
name="url"
type="text"
placeholder="https://www.youtube.com/playlist?list=PLhixgUqwRTjwvBI-hmbZ2rpkAl4lutnJG"
tabindex="1"
required
/>
<input class="submit" type="submit" value="search" tabindex="-1" />
</form> -->
<form class="search" action="/search/channel">
<div class="title">
Search for the archive of an Youtube Channel
</div>
<input
class="url"
name="url"
type="text"
placeholder="https://www.youtube.com/channel/UCyjKBSGMcxdGbSD86iq_SBw"
tabindex="1"
required
/>
<input class="submit" type="submit" value="search" tabindex="-1" />
</form>
</div>
<div class="footer">
<a href="transparency">[TRANSPARENCY]</a><div class="space"></div>
<a href="dmca">[DMCA]</a><div class="space"></div>
<a href="abuse">[REPORT ABUSE]</a><div class="space"></div>
<a href="privacy">[PRIVACY POLICY]</a><div class="space"></div>
<a href="https://status.preservetube.com">[STATUS]</a><div class="space"></div>
<a href="https://github.com/PreserveTube">[GITHUB]</a>
</div>
<style>
form {
margin-left: auto;
margin-right: auto;
padding-top: 5px;
padding-left: 10px;
padding-right: 10px;
padding-bottom: 10px;
background-color: #515359;
color: white;
width: 50%;
}
.title {
font-weight: bold;
font-size: 16px;
}
.archive {
margin-top: 2%;
}
.playlist,
.channel {
margin-top: 20px;
}
.url {
width: 85%;
display: inline-block;
}
.submit {
width: 13%;
display: inline-block;
}
.search {
margin-top: 20px;
}
.about {
margin-top: 2%;
margin-bottom: 2%;
margin-left: auto;
margin-right: auto;
width: 50%;
}
.footer {
position: fixed;
color: white;
background-color: #1b1c1f;
margin-left: auto;
margin-right: auto;
left: 0;
right: 0;
padding-top: 5px;
padding-bottom: 5px;
bottom: 0px;
text-align: center;
display: inline-block;
}
.container {
padding-bottom: 50px;
}
@media only screen and (max-width: 768px) {
form {
width: 85%;
}
.url {
width: 80%;
}
.submit {
width: 15%;
}
.about {
width: 85%;
}
.footer {
display: none;
}
}
.space {
display: inline-block;
width: 3%;
}
</style>

80
src/templates/latest.eta Normal file
View File

@ -0,0 +1,80 @@
<% layout('./layout') %>
<div class="grid">
<% it.data.forEach(function(v){ %>
<div class="video">
<a href="/watch?v=<%= v.id %>">
<img class="thumbnail" src="<%= v.thumbnail %>" />
<div class="title"><%= v.title %></div>
<div class="date">Published on <%= v.published %> | Archived on <%= v.archived %></div>
</a>
<div class="channel-profile">
<img src="<%= v.channelAvatar %>" />
<span class="channel-name">
<a href="/channel/<%= v.channelId %>">
<%= v.channel %>
<% if (it.channelVerified) { %>
<div class="verified"></div>
<% } %>
</a>
</span>
</div>
</div>
<% }) %>
</div>
<style>
.grid {
margin-left: 10%;
margin-right: 10%;
margin-top: 1%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media screen and (max-width: 768px) {
.grid {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
}
.video {
padding: 20px;
}
.video img {
width: 100%;
aspect-ratio: 16/9;
}
.title {
margin-top: 5px;
font-size: 20px;
font-weight: bold;
}
.channel-profile {
margin-top: 2px;
display: flex;
align-items: center;
}
.channel-profile img {
border-radius: 50%;
display: inline-block;
width: 20px;
height: 20px;
}
.channel-name {
padding-left: 10px;
font-size: 15px;
}
.verified {
height: 12px;
margin-left: 2px;
content: url('https://api.iconify.design/ion/checkmark-circle.svg');
display: inline-block;
}
</style>

144
src/templates/layout.eta Normal file
View File

@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<% if (it.keywords) { %>
<meta name="keywords" content="<%= it.keywords %>"/>
<% } %>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta property="og:title" content="<%= it.title %>"/>
<% if (it.description) { %>
<meta name="og:description" content="<%= it.description %>">
<% } else { %>
<meta name="og:description" content="PreserveTube is a time capsule for YouTube videos! It allows you to preserve any YouTube video, creating a snapshot that will always be available even if the original video disappears or is taken down.">
<% } %>
<meta property="og:type" content="website"/>
<meta property="og:url" content="https://preservetube.com"/>
<meta property="og:site_name" content="PreserveTube"/>
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<%= it.title %>">
<% if (it.description) { %>
<meta name="twitter:description" content="<%= it.description %>">
<% } else { %>
<meta name="twitter:description" content="PreserveTube is a time capsule for YouTube videos! It allows you to preserve any YouTube video, creating a snapshot that will always be available even if the original video disappears or is taken down.">
<% } %>
<link href="https://cdn.jsdelivr.net/gh/preservetube/fonts@main/fonts.css" rel="stylesheet" type="text/css">
<% if (it.manualAnalytics) { %>
<script defer data-domain="preservetube.com" src="https://a.gloe.net/js/script.manual.js"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
<script>
function prepareUrl(params) {
const url = new URL(location.href);
return params.reduce((customUrl, param) => {
const value = url.searchParams.get(param);
return value ? `${customUrl}/${value}` : customUrl;
}, `${url.origin}${url.pathname.replace(/\/$/, '')}`);
}
plausible('pageview', { u: prepareUrl(["v"]) + window.location.search });
</script>
<% } else { %>
<script defer data-domain="preservetube.com" src="https://a.gloe.net/js/script.js"></script>
<% } %>
<title><%= it.title %></title>
</head>
<body>
<div class="header">
<div class="logo">
<a href="/">PreserveTube</a>
</div>
|
<div class="buttons">
<a href="/latest">Latest</a>
</div>
</div>
<div class="donate">
<span>PreserveTube is dedicated to preserving internet history, and we need your help! Every donation, big or small, makes a difference.</span>
<a href="/donate" style="font-weight: bold;">Donate now.</a>
</div>
<main>
<%~ it.body %>
</main>
<footer>
page rendered by <code><%= hostname %></code>
</footer>
</body>
</html>
<style>
html,
body {
margin: 0;
padding: 0;
background-color: #fffeff;
font-family: "Proxima Nova", sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
.header {
position: relative;
background-color: #1b1c1f;
color: white;
height: 30px;
padding-top: 5px;
padding-left: 17.5%;
padding-right: 17.5%;
}
.donate {
position: relative;
background-color: #fff2cf;
border-bottom: 2px dashed #dab75e;
padding-top: 5px;
padding-bottom: 5px;
text-align: center;
}
@media (max-width: 1100px) {
.donate {
padding-left: 5%;
padding-right: 5%;
}
}
.logo {
padding-right: 12px;
font-size: 20px;
font-weight: bold;
display: inline-block;
}
.buttons {
padding-left: 12px;
font-size: 15px;
display: inline-block;
}
@media only screen and (max-width: 767px) {
.header {
padding-left: 5%;
padding-right: 5%;
}
}
footer {
padding: 10px;
text-align: center;
font-size: 12px;
color: gray;
}
footer > code {
padding: 2px 4px;
font-size: 12px;
}
</style>

61
src/templates/privacy.eta Normal file
View File

@ -0,0 +1,61 @@
<% layout('./layout') %>
<div class="text">
<div class="part">
<strong>Last updated:</strong> 2025-08-16
</div>
<div class="part">
<div class="title">Anonymous Analytics</div>
PreserveTube uses <a href="https://plausible.io/">Plausible</a> to get an aproximate number of active users, and the most watched videos.
No identifiable information, such as IPs, is stored. This Plausible instance is selfhosted, and not proxied through Cloudflare.
<br><br>
<div class="title">Reverse Proxying</div>
We use <a href="https://caddyserver.com/">Caddy</a> to reverse proxy our services. On certain ocassions, such as when errors occur in requests,
Caddy might log these. These logs include headers, client IPs, and paths. Otherwise, our Caddy setup does not log any requests. <br><br>
Logs are purged automatically once the file size reaches 50MB.
<br><br>
<div class="title">DDoS Protection</div>
We use a combination of Cloudflare (preservetube.com), BasedFlare (preservetube.net), and selfhosted solutions (preservetube.org) for our DDoS protection. We additionally use Cloudflare Turnstile as a captcha
to avoid automated bot traffic on the /save page. <br><br>
Cloudflare's privacy policy can be found here: <a href="https://www.cloudflare.com/en-gb/privacypolicy/">https://www.cloudflare.com/en-gb/privacypolicy/</a>. <br>
BasedFlare has no privacy policy. It should be assumed everything is logged. Avoid visiting preservetube.net if you're uncomfortable with this.
<br><br>
<div class="title">DNS</div>
Besides Cloudflare and BasedFlare, PreserveTube also relies on ClouDNS for DNS services. ClouDNS's privacy policy can be found here: <a href="https://www.cloudns.net/privacy-policy/">https://www.cloudns.net/privacy-policy/</a>
<br><br>
<div class="title">Video Saving</div>
Saving videos is completly anonymous. We do not log who saved a video.
<br><br>
<div class="title">Questions?</div>
I'm reachable at <a href="mailto:admin@preservetube.com">admin@preservetube.com</a> (<a style="text-decoration: none;" href="/pgp.txt">🔑</a>).
</div>
</div>
<style>
a {
text-decoration: underline;
}
.text {
margin-top: 2%;
margin-left: auto;
margin-right: auto;
width: 65%;
}
.part {
margin-bottom: 3%;
}
.title {
font-size: large;
margin-bottom: 2px;
font-weight: bold;
}
</style>

57
src/templates/save.eta Normal file
View File

@ -0,0 +1,57 @@
<% layout('./layout') %>
<div id="logs" class="logs">
<h1 id="title"></h1>
<div class="captcha" id="captcha"></div>
<h3 id="bottom">
<noscript>This feature doesen't function properly unless JavaScript is enabled.</noscript>
</h3>
</div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script>
const url = "<%= it.url %>"
const websocket = "<%= it.websocket %>"
const sitekey = "<%= it.sitekey %>"
const ws = new WebSocket(`${websocket}/save?url=${encodeURIComponent(url)}`)
const h1 = document.getElementById("title")
const h3 = document.getElementById("bottom")
const captcha = document.getElementById("captcha")
ws.onmessage = function (msg) {
const text = msg.data.split(' - ').slice(1).join(' - ')
if (msg.data == 'ERROR - Missing URL' || msg.data == 'ERROR - Whoops! What is that? That is not a Youtube url.') {
h1.innerHTML = text
return
} else if (msg.data.startsWith('CAPTCHA -')) {
captcha.style.display = 'block'
turnstile.render('#captcha', {
sitekey: sitekey,
callback: function(token) {
captcha.remove()
ws.send(token)
},
})
} else if (msg.data.startsWith('DONE -')) {
window.location.href = text
}
h3.innerHTML = `${h1.innerHTML}<br>${h3.innerHTML}`
h1.innerHTML = text
}
ws.onclose = function (event) {
h3.innerHTML = `Websocket connection was closed: ${event.reason} (${event.code})<br>${h3.innerHTML}`
}
setInterval(() => {
ws.send('alive')
}, 2000)
</script>
<style>
.logs {
text-align: center;
}
</style>

View File

@ -0,0 +1,57 @@
<% layout('./layout') %>
<div id="logs" class="logs">
<h1 id="title"></h1>
<div class="captcha" id="captcha"></div>
<h3 id="bottom">
<noscript>This feature doesen't function properly unless JavaScript is enabled.</noscript>
</h3>
</div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script>
const url = "<%= it.url %>"
const websocket = "<%= it.websocket %>"
const sitekey = "<%= it.sitekey %>"
const ws = new WebSocket(`${websocket}/savechannel?url=${encodeURIComponent(url)}`)
const h1 = document.getElementById("title")
const h3 = document.getElementById("bottom")
const captcha = document.getElementById("captcha")
ws.onmessage = function (msg) {
const text = msg.data.split(' - ').slice(1).join(' - ')
if (msg.data == 'ERROR - Missing URL' || msg.data == 'ERROR - Whoops! What is that? That is not a Youtube url.') {
h1.innerHTML = text
return
} else if (msg.data.startsWith('CAPTCHA -')) {
captcha.style.display = 'block'
turnstile.render('#captcha', {
sitekey: sitekey,
callback: function(token) {
captcha.remove()
ws.send(token)
},
})
} else if (msg.data.startsWith('DONE -')) {
window.location.href = text
}
h3.innerHTML = `${h1.innerHTML}<br>${h3.innerHTML}`
h1.innerHTML = text
}
ws.onclose = function (event) {
h3.innerHTML = `Websocket connection was closed: ${event.reason} (${event.code})<br>${h3.innerHTML}`
}
setInterval(() => {
ws.send('alive')
}, 2000)
</script>
<style>
.logs {
text-align: center;
}
</style>

74
src/templates/search.eta Normal file
View File

@ -0,0 +1,74 @@
<% layout('./layout') %>
<div class="grid">
<% it.data.forEach(function(v){ %>
<div class="video">
<a href="/watch?v=<%= v.id %>">
<img class="thumbnail" src="<%= v.thumbnail %>" />
<div class="title"><%= v.title %></div>
<div class="date">Published on <%= v.published %> | Archived on <%= v.archived %></div>
</a>
<div class="channel-profile">
<img src="<%= v.channelAvatar %>" />
<span class="channel-name">
<a href="/channel/<%= v.channelId %>">
<%= v.channel %>
<% if (it.channelVerified) { %>
<div class="verified"></div>
<% } %>
</a>
</span>
</div>
</div>
<% }) %>
</div>
<style>
.grid {
margin-left: 10%;
margin-right: 10%;
margin-top: 1%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.video {
padding: 20px;
}
.video img {
width: 100%;
aspect-ratio: 16/9;
}
.title {
margin-top: 5px;
font-size: 20px;
font-weight: bold;
}
.channel-profile {
margin-top: 2px;
display: flex;
align-items: center;
}
.channel-profile img {
border-radius: 50%;
display: inline-block;
width: 20px;
height: 20px;
}
.channel-name {
padding-left: 10px;
font-size: 15px;
}
.verified {
height: 12px;
margin-left: 2px;
content: url("https://api.iconify.design/ion/checkmark-circle.svg");
display: inline-block;
}
</style>

View File

@ -0,0 +1,46 @@
<% layout('./layout') %>
<div class="report">
<div class="t-header">
<div class="title"><%= it.t_title %></div>
<div class="date"><%= it.date %></div>
</div>
<pre class="details"><%= it.details %></pre>
</div>
<style>
.report {
margin-top: 5%;
margin-left: auto;
margin-right: auto;
width: 75%;
}
.t-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
font-size: 25px;
font-weight: bold;
text-align: left;
}
.date {
font-size: 15px;
text-align: right;
}
.details {
margin-top: 10px;
width: 100%;
font-family: monospace;
font-size: 14px;
white-space: pre-wrap;
word-wrap: break-word;
border: 1px solid #ccc;
padding: 8px;
}
</style>

View File

@ -0,0 +1,21 @@
<% layout('./layout') %>
<div class="grid">
<% it.data.forEach(function(r){ %>
<a class="title" href="/transparency/<%= r.target %>"><%= r.title %></a>
<% }) %>
</div>
<style>
.grid {
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}
.title {
font-size: larger;
font-weight: 600;
display: inline-block;
}
</style>

268
src/templates/watch.eta Normal file
View File

@ -0,0 +1,268 @@
<% layout('./layout') %>
<% if (it.isMissing) { %>
<div class="error">
<h2>Archive not found</h2>
<button onclick="window.location.href='/save?url=${encodeURIComponent(`https://www.youtube.com/watch?v=<%= it.id %>`)}'">
Archive Me!
</button>
</div>
<% } else { %>
<div class="content-wrapper">
<div class="main-content">
<div class="report">
<a href="abuse">[report abuse]</a>
<div class="space"></div>
<a href="dmca">[dmca]</a>
</div>
<% if (it.transparency.length != 0) { %>
<div class="reports">
<span class="reports-title">Somebody has complained about this video...</span> <br/>
<% it.transparency.forEach(function(t){ %>
<a href="<%= t.details %>"><%= t.title %></a>
<% }) %>
</div>
<% } %>
<div class="video-wrapper">
<div class="video-loading hidden" id="video-loading">Loading...</div>
<video id="video-player" src="<%= it.source %>" poster="<%= it.thumbnail %>" controls preload="metadata"></video>
</div>
<h1><%= it.v_title %></h1>
<div class="channel-profile">
<img src="<%= it.channelAvatar %>" />
<span class="channel-name">
<a href="/channel/<%= it.channelId %>">
<%= it.channel %>
<% if (it.channelVerified) { %>
<div class="verified"></div>
<% } %>
</a>
</span>
</div>
<div class="metadata">
<p class="date">
Published on <%= it.published %> | Archived on <%= it.archived %>
<a href="<%= it.source %>" target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</a>
</p>
<p class="description"><%~ it.description %></p>
</div>
</div>
</div>
<% } %>
<style>
.content-wrapper {
position: relative;
min-height: 100vh;
}
.main-content {
width: 65%;
margin: 0 auto;
}
.icon {
width: 1.25rem;
height: 1.25rem;
margin-left: 0.5em;
}
.report {
text-align: right;
margin-top: 5px;
}
.space {
display: inline-block;
width: 0.5%;
}
video {
width: 100%;
max-height: 720px;
display: block;
margin-top: 5px;
}
h1 {
position: relative;
margin: 1rem 0;
}
.error {
text-align: center;
}
.error h2 {
font-size: 30px;
display: flex;
place-items: center;
justify-content: center;
}
.channel-profile {
display: flex;
align-items: center;
}
.channel-profile img {
border-radius: 50%;
display: inline-block;
width: 3em;
height: 3em;
}
.channel-name {
padding-left: 10px;
font-size: 1.2em;
}
.date {
font-size: 17px;
font-weight: bold;
display: flex;
}
.description {
margin-top: 1rem;
}
.verified {
height: 15px;
content: url('https://api.iconify.design/ion/checkmark-circle.svg');
display: inline-block;
}
.reports {
background-color: #fff2cf;
border: 2px dashed #dab75e;
padding: 10px;
margin-top: 5px;
margin-bottom: 1rem;
}
.reports-title {
font-size: large;
font-weight: 600;
}
.video-wrapper {
position: relative;
display: inline-block;
width: 100%;
max-height: 720px;
}
.video-wrapper video {
width: 100%;
max-height: 720px;
display: block;
}
.video-loading {
position: absolute;
top: 0;
left: 0;
color: white;
font-size: 1em;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
border-radius: 0;
z-index: 2;
transition: opacity 0.3s ease;
font-size: large;
max-width: 500px;
}
.video-loading.hidden {
opacity: 0;
pointer-events: none;
}
@media (max-width: 1100px) {
h1 {
font-size: 1.65em;
}
.date {
font-size: 1em;
}
.channel-name {
font-size: 1em
}
.icon {
display: none;
}
.channel-profile img {
width: 2.5em;
height: 2.5em;
}
.main-content {
width: 90%
}
}
</style>
<script is:inline>
function initVideoLoading() {
const video = document.getElementById('video-player');
const loading = document.getElementById('video-loading');
if (!video || !loading) return;
loading?.classList.remove("hidden");
const hide = () => {
if (!loading.classList.contains('hidden')) {
loading.classList.add('hidden');
loading.addEventListener('transitionend', () => loading.remove(), { once: true });
}
};
// bunch of events that show readyness
['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'playing', 'play'].forEach(evt =>
video.addEventListener(evt, hide)
);
// weird firefox something
video.addEventListener('click', hide);
video.addEventListener('keydown', hide);
video.addEventListener('pointerdown', hide);
video.addEventListener('error', () => {
loading.innerHTML = 'Video failed to load.';
loading.innerHTML += '<p style="font-size: medium;">This is not supposed to happen. Please email me at admin@preservetube.com with as much debugging information.</p>' +
'<p style="font-size: medium;">Please include your browser console logs. <a style="font-style: italic;" href="https://support.happyfox.com/kb/article/882-accessing-the-browser-console-and-network-logs/">' +
'See here on how to access them.</a> Please note you might have to refresh the page for anything to show up.</p>'
});
// video already has data (i.e. cached)
if (video.readyState > 0) hide();
// add an extra message after 10s
const FALLBACK_MS = 10000;
const fallbackTimer = setTimeout(() => {
if (!loading.classList.contains('hidden')) {
loading.innerHTML = 'Taking longer than usual to load...';
loading.innerHTML += '<p style="font-size: medium;">This might be due one of multiple reasons:</p>'
+ '<ol style="font-size: medium;"><li>This video is large. Depending on your browser, it might try loading the full thing at one time, which will slow things down.</li>'
+ '<li>Preservetube servers are overloaded. This happens once in a while, please be patient.</li><li>You\'re in an unfavourable region, which has a bad connection to our servers.</li></ol>'
+ '<p style="font-size: medium;">If this takes an unusual amount of time, even after considering the above mentioned reasons, please email me at admin@preservetube.com. Don\'t forget to include your aproximate region, your browser and OS.</p>';
}
}, FALLBACK_MS);
// clear fallback once overlay removed
loading.addEventListener('transitionend', () => clearTimeout(fallbackTimer), { once: true });
}
initVideoLoading();
</script>

13
src/utils/html.ts Normal file
View File

@ -0,0 +1,13 @@
import { minify } from 'html-minifier-next';
import { Eta } from 'eta';
import path from 'path';
export const m = async (html: string) => {
return await minify(html, {
collapseWhitespace: true,
removeComments: true,
minifyCSS: true
})
}
export const eta = new Eta({ views: path.join(__dirname, '../templates'), functionHeader: `const hostname = "${process.env.SERVER_NICKNAME}"` })