diff --git a/src/router/video.ts b/src/router/video.ts
index bbf1d7e..2acf9e3 100644
--- a/src/router/video.ts
+++ b/src/router/video.ts
@@ -47,7 +47,7 @@ app.get('/watch', async ({ query: { v }, set, redirect, error }) => {
const json = videoVersions.find(video => video.id === v) || videoVersions[0];
if (!json) {
- const html = await m(eta.render('./watch', {
+ const html = await m(eta.render('./watch', {
isMissing: true,
id: baseId,
title: 'Video Not Found | PreserveTube',
@@ -68,6 +68,13 @@ app.get('/watch', async ({ query: { v }, set, redirect, error }) => {
.execute()
}
+ const file = json.deletion_stage === 'cold_storage'
+ ? await db.selectFrom('files')
+ .selectAll()
+ .where('videoId', '=', json.id)
+ .executeTakeFirst()
+ : null
+
DOMPurify.addHook('afterSanitizeAttributes', function (node) {
if (node.tagName === 'A') {
const disallowedPatterns: RegExp[] = [
@@ -80,11 +87,11 @@ app.get('/watch', async ({ query: { v }, set, redirect, error }) => {
/\/@[^\/]/i
];
const href = node.getAttribute('href') || '';
-
- const shouldConvertToSpan = disallowedPatterns.some(pattern =>
+
+ const shouldConvertToSpan = disallowedPatterns.some(pattern =>
pattern.test(href)
);
-
+
if (shouldConvertToSpan) {
const span = node.ownerDocument.createElement('span');
span.innerHTML = node.innerHTML;
@@ -96,8 +103,9 @@ app.get('/watch', async ({ query: { v }, set, redirect, error }) => {
}
})
- const html = await m(eta.render('./watch', {
+ const html = await m(eta.render('./watch', {
transparency,
+ file,
versions: videoVersions.map(video => ({
id: video.id,
archived: video.archived
@@ -135,12 +143,23 @@ Please identify yourself with a User-Agent in the format: AppName/1.0 (a way for
.executeTakeFirst()
if (!json) return error(404, { error: '404' })
- await redis.set(`video:${id}`, JSON.stringify(json), 'EX', 3600)
- return {
+ const file = json.deletion_stage === 'cold_storage'
+ ? await db.selectFrom('files')
+ .selectAll()
+ .where('videoId', '=', json.id)
+ .executeTakeFirst()
+ : null
+
+ const payload = {
...json,
+ file,
description: DOMPurify.sanitize(json.description),
}
+
+ await redis.set(`video:${id}`, JSON.stringify(payload), 'EX', 3600)
+
+ return payload
})
app.get('/channel/:id', async ({ params: { id }, set }) => {
@@ -156,7 +175,7 @@ app.get('/channel/:id', async ({ params: { id }, set }) => {
])
if (!videos || !channel || videos.error || channel.error) {
- const html = await m(eta.render('./channel', {
+ const html = await m(eta.render('./channel', {
failedToFetch: true,
id
}))
@@ -187,7 +206,7 @@ app.get('/channel/:id', async ({ params: { id }, set }) => {
processedVideos.sort((a: any, b: any) => new Date(b.published).getTime() - new Date(a.published).getTime());
- const html = await m(eta.render('./channel', {
+ const html = await m(eta.render('./channel', {
name: channel.metadata.title,
avatar: channel.metadata.avatar[0].url,
verified: channel.header.author?.is_verified,
@@ -196,7 +215,7 @@ app.get('/channel/:id', async ({ params: { id }, set }) => {
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)
-
+
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return html
})
@@ -214,7 +233,7 @@ app.get('/channel/:id/videos', async ({ params: { id }, set }) => {
.orderBy('published desc')
.execute()
- const html = await m(eta.render('./channel-videos', {
+ 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`
diff --git a/src/templates/watch.eta b/src/templates/watch.eta
index 2c579d1..46323e6 100644
--- a/src/templates/watch.eta
+++ b/src/templates/watch.eta
@@ -1,5 +1,29 @@
<% layout('./layout') %>
+<%
+ const formatBytes = (bytes) => {
+ if (bytes === 0) return '0 B'
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
+ const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
+ const value = bytes / Math.pow(1024, exponent)
+ return `${value >= 10 || exponent === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[exponent]}`
+ }
+
+ const formatDuration = (seconds) => {
+ const totalSeconds = Math.max(0, Math.floor(seconds))
+ const hours = Math.floor(totalSeconds / 3600)
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
+ const secs = totalSeconds % 60
+ const parts = []
+
+ if (hours) parts.push(`${hours}h`)
+ if (minutes) parts.push(`${minutes}m`)
+ if (secs || parts.length === 0) parts.push(`${secs}s`)
+
+ return parts.join(' ')
+ }
+%>
+
<% if (it.isMissing) { %>
Archive not found
@@ -43,28 +67,39 @@
<% } %>
- <% if (it.deletion_stage === 'pending_delete') { %>
-
-
Heads up — this video is scheduled for deletion.
-
Got questions or think this is a mistake? Drop me an email at admin@preservetube.com.
+ <% if (it.deletion_stage !== null) { %>
+
+ <% if (it.deletion_stage === 'pending_delete') { %>
+
Heads up: this video is scheduled for deletion.
+
It is still available right now, but it has been flagged for removal. Got questions or think this is a mistake? Drop me an email at admin@preservetube.com.
+ <% } else if (it.deletion_stage === 'soft_delete') { %>
+
This video has been moved to cold storage and will be permanently deleted soon.
+
If you'd like to retrieve it, email me at admin@preservetube.com.
+ <% } else if (it.deletion_stage === 'deleted') { %>
+
This video has been removed.
+
Storage is limited, so I occasionally clear out things like 10-hour blank screens and similar content. The thumbnail and metadata remain here so there is still a public record of what used to be preserved.
+ <% } else if (it.deletion_stage === 'cold_storage') { %>
+
This video has been moved to cold storage.
+
Videos that aren't watched often are moved to cold storage as a cost-saving measure. They can still be retrieved. Please email me at admin@preservetube.com.
+ <% } %>
+
+ <% if (it.deletion_stage === 'cold_storage' && it.file) { %>
+
+
+ Size: <%= formatBytes(it.file.size_bytes) %>
+ Duration: <%= formatDuration(it.file.duration_seconds) %>
+ Resolution: <%= it.file.resolution %> (<%= it.file.fps %> FPS)
+ Hash (<%= it.file.hash_algorithm %>): <%= it.file.hash %>
+
+
+ <% } %>
<% } %>
- <% if (it.deletion_stage === 'soft_delete') { %>
-
-
This video has been moved to cold storage and will be permanently deleted soon.
-
If you'd like to retrieve it, email me at admin@preservetube.com.
-
- <% } else if (it.deletion_stage === 'deleted') { %>
-
-
This video has been removed.
-
Storage is limited, so I occasionally clear out things like 10-hour blank screens and similar content.
-
- <% } else if (it.deletion_stage === 'cold_storage') { %>
-
-
This video has been moved to cold-storage.
-
Videos that aren't watched often are moved to cold storage as a cost-saving measure.
They can still be retrieved. Please email me at admin@preservetube.com.
+ <% if (it.deletion_stage !== null) { %>
+
+
<% } else { %>
Loading...
@@ -95,6 +130,7 @@
<%~ it.description %>
+
@@ -180,6 +216,41 @@
margin-top: 1rem;
}
+ .cold-storage-files {
+ margin-top: 0.5rem;
+ margin-bottom: 0.6rem;
+ }
+
+ .cold-storage-file {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: stretch;
+ }
+
+ .cold-storage-file span {
+ white-space: nowrap;
+ background: #fff;
+ border: 1px solid #d7d7d7;
+ padding: 0.35rem 0.55rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ }
+
+ .cold-storage-file code {
+ word-break: break-all;
+ white-space: normal;
+ }
+
+ @media (max-width: 900px) {
+ .cold-storage-file {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+ }
+
.verified {
height: 15px;
content: url('https://api.iconify.design/ion/checkmark-circle.svg');
@@ -212,7 +283,7 @@
.video-wrapper {
position: relative;
- display: inline-block;
+ display: block;
width: 100%;
max-height: 720px;
}
@@ -250,11 +321,26 @@
margin-bottom: 0.5rem;
}
- .deletion-banner.pending {
+ .deletion-banner.pending_delete {
background-color: #ffe0e0;
border: 2px dashed #ff6b6b;
}
+ .deletion-banner.soft_delete {
+ background-color: #fbf6e3;
+ border: 2px dashed #ffc107;
+ }
+
+ .deletion-banner.deleted {
+ background-color: #f6e8e9;
+ border: 2px dashed #dc3545;
+ }
+
+ .deletion-banner.cold_storage {
+ background-color: #e8f4fd;
+ border: 2px dashed #4da3d9;
+ }
+
.deletion-title {
font-size: large;
font-weight: 600;
@@ -272,46 +358,60 @@
text-decoration: underline;
}
- .video-placeholder {
+ .thumbnail-frame {
+ width: 100%;
+ max-height: 720px;
+ aspect-ratio: 16 / 9;
+ margin-top: 5px;
+ overflow: hidden;
+ background: #111;
+ }
+
+ .video-thumbnail {
width: 100%;
height: 100%;
- min-height: 400px;
+ display: block;
+ object-fit: cover;
+ }
+
+ .deletion-card {
+ width: min(16rem, 100%);
+ min-height: 16rem;
display: flex;
flex-direction: column;
justify-content: center;
- align-items: center;
- text-align: center;
- padding: 2rem;
+ text-align: left;
+ padding: 1rem 1.25rem;
margin-top: 5px;
+ margin-bottom: 0.75rem;
box-sizing: border-box;
border-radius: 4px;
}
- .video-placeholder.soft-delete {
+ .deletion-card.soft-delete {
background-color: #fbf6e3;
border: 2px dashed #ffc107;
}
- .video-placeholder.deleted {
+ .deletion-card.deleted {
background-color: #f6e8e9;
border: 2px dashed #dc3545;
}
- .video-placeholder.coldstorage {
+ .deletion-card.coldstorage {
background-color: #e8f4fd;
border: 2px dashed #4da3d9;
}
- .video-placeholder p {
- font-size: 1.2rem;
- margin: 0.5rem 0;
- max-width: 80%;
+ .deletion-card p {
+ font-size: 1rem;
+ margin: 0;
}
- .video-placeholder .deletion-note {
+ .deletion-card .deletion-note {
font-size: 1rem;
color: #666;
- margin-top: 1rem;
+ margin-top: 0.75rem;
}
@media (max-width: 1100px) {
@@ -403,4 +503,4 @@
}
initVideoLoading();
-
\ No newline at end of file
+
diff --git a/src/types.ts b/src/types.ts
index d52353c..5b33890 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -8,6 +8,7 @@ import type {
export interface Database {
videos: VideosTable
reports: ReportsTable
+ files: FilesTable
}
export interface VideosTable {
@@ -43,4 +44,21 @@ export interface ReportsTable {
export type Report = Selectable
export type NewReport = Insertable
-export type UpdateReport = Updateable
\ No newline at end of file
+export type UpdateReport = Updateable
+
+export interface FilesTable {
+ uuid: Generated
+ videoId: string
+ filename: string
+ hash: string
+ hash_algorithm: string
+ size_bytes: number
+ duration_seconds: number
+ video_codec: string
+ audio_codec: string
+ resolution: string
+ fps: number
+}
+
+export type File = Selectable
+export type NewFile = Insertable