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) { %> +
+ Thumbnail for <%= it.v_title %>
<% } else { %> @@ -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