add files info for cold storage files
This commit is contained in:
parent
984d027035
commit
fbebe11850
|
|
@ -47,7 +47,7 @@ app.get('/watch', async ({ query: { v }, set, redirect, error }) => {
|
||||||
const json = videoVersions.find(video => video.id === v) || videoVersions[0];
|
const json = videoVersions.find(video => video.id === v) || videoVersions[0];
|
||||||
|
|
||||||
if (!json) {
|
if (!json) {
|
||||||
const html = await m(eta.render('./watch', {
|
const html = await m(eta.render('./watch', {
|
||||||
isMissing: true,
|
isMissing: true,
|
||||||
id: baseId,
|
id: baseId,
|
||||||
title: 'Video Not Found | PreserveTube',
|
title: 'Video Not Found | PreserveTube',
|
||||||
|
|
@ -68,6 +68,13 @@ app.get('/watch', async ({ query: { v }, set, redirect, error }) => {
|
||||||
.execute()
|
.execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const file = json.deletion_stage === 'cold_storage'
|
||||||
|
? await db.selectFrom('files')
|
||||||
|
.selectAll()
|
||||||
|
.where('videoId', '=', json.id)
|
||||||
|
.executeTakeFirst()
|
||||||
|
: null
|
||||||
|
|
||||||
DOMPurify.addHook('afterSanitizeAttributes', function (node) {
|
DOMPurify.addHook('afterSanitizeAttributes', function (node) {
|
||||||
if (node.tagName === 'A') {
|
if (node.tagName === 'A') {
|
||||||
const disallowedPatterns: RegExp[] = [
|
const disallowedPatterns: RegExp[] = [
|
||||||
|
|
@ -80,11 +87,11 @@ app.get('/watch', async ({ query: { v }, set, redirect, error }) => {
|
||||||
/\/@[^\/]/i
|
/\/@[^\/]/i
|
||||||
];
|
];
|
||||||
const href = node.getAttribute('href') || '';
|
const href = node.getAttribute('href') || '';
|
||||||
|
|
||||||
const shouldConvertToSpan = disallowedPatterns.some(pattern =>
|
const shouldConvertToSpan = disallowedPatterns.some(pattern =>
|
||||||
pattern.test(href)
|
pattern.test(href)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (shouldConvertToSpan) {
|
if (shouldConvertToSpan) {
|
||||||
const span = node.ownerDocument.createElement('span');
|
const span = node.ownerDocument.createElement('span');
|
||||||
span.innerHTML = node.innerHTML;
|
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,
|
transparency,
|
||||||
|
file,
|
||||||
versions: videoVersions.map(video => ({
|
versions: videoVersions.map(video => ({
|
||||||
id: video.id,
|
id: video.id,
|
||||||
archived: video.archived
|
archived: video.archived
|
||||||
|
|
@ -135,12 +143,23 @@ Please identify yourself with a User-Agent in the format: AppName/1.0 (a way for
|
||||||
.executeTakeFirst()
|
.executeTakeFirst()
|
||||||
|
|
||||||
if (!json) return error(404, { error: '404' })
|
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,
|
...json,
|
||||||
|
file,
|
||||||
description: DOMPurify.sanitize(json.description),
|
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 }) => {
|
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) {
|
if (!videos || !channel || videos.error || channel.error) {
|
||||||
const html = await m(eta.render('./channel', {
|
const html = await m(eta.render('./channel', {
|
||||||
failedToFetch: true,
|
failedToFetch: true,
|
||||||
id
|
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());
|
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,
|
name: channel.metadata.title,
|
||||||
avatar: channel.metadata.avatar[0].url,
|
avatar: channel.metadata.avatar[0].url,
|
||||||
verified: channel.header.author?.is_verified,
|
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`
|
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}:html`, html, 'EX', 3600)
|
||||||
|
|
||||||
set.headers['Content-Type'] = 'text/html; charset=utf-8'
|
set.headers['Content-Type'] = 'text/html; charset=utf-8'
|
||||||
return html
|
return html
|
||||||
})
|
})
|
||||||
|
|
@ -214,7 +233,7 @@ app.get('/channel/:id/videos', async ({ params: { id }, set }) => {
|
||||||
.orderBy('published desc')
|
.orderBy('published desc')
|
||||||
.execute()
|
.execute()
|
||||||
|
|
||||||
const html = await m(eta.render('./channel-videos', {
|
const html = await m(eta.render('./channel-videos', {
|
||||||
videos: archived,
|
videos: archived,
|
||||||
title: `${id} videos | PreserveTube`,
|
title: `${id} videos | PreserveTube`,
|
||||||
keywords: `${id} archive, ${id} channel archive, ${id} deleted video, ${id} video deleted`
|
keywords: `${id} archive, ${id} channel archive, ${id} deleted video, ${id} video deleted`
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,29 @@
|
||||||
<% layout('./layout') %>
|
<% 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) { %>
|
<% if (it.isMissing) { %>
|
||||||
<div class="error">
|
<div class="error">
|
||||||
<h2>Archive not found</h2>
|
<h2>Archive not found</h2>
|
||||||
|
|
@ -43,28 +67,39 @@
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<% if (it.deletion_stage === 'pending_delete') { %>
|
<% if (it.deletion_stage !== null) { %>
|
||||||
<div class="deletion-banner pending">
|
<div class="deletion-banner <%= it.deletion_stage %>">
|
||||||
<span class="deletion-title">Heads up — this video is scheduled for deletion.</span>
|
<% if (it.deletion_stage === 'pending_delete') { %>
|
||||||
<p>Got questions or think this is a mistake? Drop me an email at <a href="mailto:admin@preservetube.com">admin@preservetube.com</a>.</p>
|
<span class="deletion-title">Heads up: this video is scheduled for deletion.</span>
|
||||||
|
<p>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 <a href="mailto:admin@preservetube.com">admin@preservetube.com</a>.</p>
|
||||||
|
<% } else if (it.deletion_stage === 'soft_delete') { %>
|
||||||
|
<span class="deletion-title">This video has been moved to cold storage and will be permanently deleted soon.</span>
|
||||||
|
<p>If you'd like to retrieve it, email me at <a href="mailto:admin@preservetube.com">admin@preservetube.com</a>.</p>
|
||||||
|
<% } else if (it.deletion_stage === 'deleted') { %>
|
||||||
|
<span class="deletion-title">This video has been removed.</span>
|
||||||
|
<p>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.</p>
|
||||||
|
<% } else if (it.deletion_stage === 'cold_storage') { %>
|
||||||
|
<span class="deletion-title">This video has been moved to cold storage.</span>
|
||||||
|
<p>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 <a href="mailto:admin@preservetube.com">admin@preservetube.com</a>.</p>
|
||||||
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if (it.deletion_stage === 'cold_storage' && it.file) { %>
|
||||||
|
<div class="cold-storage-files">
|
||||||
|
<div class="cold-storage-file">
|
||||||
|
<span><strong>Size:</strong> <%= formatBytes(it.file.size_bytes) %></span>
|
||||||
|
<span><strong>Duration:</strong> <%= formatDuration(it.file.duration_seconds) %></span>
|
||||||
|
<span><strong>Resolution:</strong> <%= it.file.resolution %> (<%= it.file.fps %> FPS)</span>
|
||||||
|
<span><strong>Hash (<%= it.file.hash_algorithm %>):</strong> <code><%= it.file.hash %></code></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<div class="video-wrapper">
|
<div class="video-wrapper">
|
||||||
<% if (it.deletion_stage === 'soft_delete') { %>
|
<% if (it.deletion_stage !== null) { %>
|
||||||
<div class="video-placeholder soft-delete">
|
<div class="thumbnail-frame">
|
||||||
<p>This video has been moved to cold storage and will be permanently deleted soon.</p>
|
<img class="video-thumbnail" src="<%= it.thumbnail %>" alt="Thumbnail for <%= it.v_title %>" />
|
||||||
<p class="deletion-note">If you'd like to retrieve it, email me at <a href="mailto:admin@preservetube.com">admin@preservetube.com</a>.</p>
|
|
||||||
</div>
|
|
||||||
<% } else if (it.deletion_stage === 'deleted') { %>
|
|
||||||
<div class="video-placeholder deleted">
|
|
||||||
<p>This video has been removed.</p>
|
|
||||||
<p class="deletion-note">Storage is limited, so I occasionally clear out things like 10-hour blank screens and similar content.</p>
|
|
||||||
</div>
|
|
||||||
<% } else if (it.deletion_stage === 'cold_storage') { %>
|
|
||||||
<div class="video-placeholder coldstorage">
|
|
||||||
<p>This video has been moved to cold-storage.</p>
|
|
||||||
<p class="deletion-note">Videos that aren't watched often are moved to cold storage as a cost-saving measure. <br><br>They can still be retrieved. Please email me at <a href="mailto:admin@preservetube.com">admin@preservetube.com</a>.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="video-loading hidden" id="video-loading">Loading...</div>
|
<div class="video-loading hidden" id="video-loading">Loading...</div>
|
||||||
|
|
@ -95,6 +130,7 @@
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p class="description"><%~ it.description %></p>
|
<p class="description"><%~ it.description %></p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -180,6 +216,41 @@
|
||||||
margin-top: 1rem;
|
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 {
|
.verified {
|
||||||
height: 15px;
|
height: 15px;
|
||||||
content: url('https://api.iconify.design/ion/checkmark-circle.svg');
|
content: url('https://api.iconify.design/ion/checkmark-circle.svg');
|
||||||
|
|
@ -212,7 +283,7 @@
|
||||||
|
|
||||||
.video-wrapper {
|
.video-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 720px;
|
max-height: 720px;
|
||||||
}
|
}
|
||||||
|
|
@ -250,11 +321,26 @@
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deletion-banner.pending {
|
.deletion-banner.pending_delete {
|
||||||
background-color: #ffe0e0;
|
background-color: #ffe0e0;
|
||||||
border: 2px dashed #ff6b6b;
|
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 {
|
.deletion-title {
|
||||||
font-size: large;
|
font-size: large;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -272,46 +358,60 @@
|
||||||
text-decoration: underline;
|
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%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 400px;
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deletion-card {
|
||||||
|
width: min(16rem, 100%);
|
||||||
|
min-height: 16rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
text-align: left;
|
||||||
text-align: center;
|
padding: 1rem 1.25rem;
|
||||||
padding: 2rem;
|
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-placeholder.soft-delete {
|
.deletion-card.soft-delete {
|
||||||
background-color: #fbf6e3;
|
background-color: #fbf6e3;
|
||||||
border: 2px dashed #ffc107;
|
border: 2px dashed #ffc107;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-placeholder.deleted {
|
.deletion-card.deleted {
|
||||||
background-color: #f6e8e9;
|
background-color: #f6e8e9;
|
||||||
border: 2px dashed #dc3545;
|
border: 2px dashed #dc3545;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-placeholder.coldstorage {
|
.deletion-card.coldstorage {
|
||||||
background-color: #e8f4fd;
|
background-color: #e8f4fd;
|
||||||
border: 2px dashed #4da3d9;
|
border: 2px dashed #4da3d9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-placeholder p {
|
.deletion-card p {
|
||||||
font-size: 1.2rem;
|
font-size: 1rem;
|
||||||
margin: 0.5rem 0;
|
margin: 0;
|
||||||
max-width: 80%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-placeholder .deletion-note {
|
.deletion-card .deletion-note {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #666;
|
color: #666;
|
||||||
margin-top: 1rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
|
|
@ -403,4 +503,4 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
initVideoLoading();
|
initVideoLoading();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
20
src/types.ts
20
src/types.ts
|
|
@ -8,6 +8,7 @@ import type {
|
||||||
export interface Database {
|
export interface Database {
|
||||||
videos: VideosTable
|
videos: VideosTable
|
||||||
reports: ReportsTable
|
reports: ReportsTable
|
||||||
|
files: FilesTable
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideosTable {
|
export interface VideosTable {
|
||||||
|
|
@ -43,4 +44,21 @@ export interface ReportsTable {
|
||||||
|
|
||||||
export type Report = Selectable<ReportsTable>
|
export type Report = Selectable<ReportsTable>
|
||||||
export type NewReport = Insertable<ReportsTable>
|
export type NewReport = Insertable<ReportsTable>
|
||||||
export type UpdateReport = Updateable<ReportsTable>
|
export type UpdateReport = Updateable<ReportsTable>
|
||||||
|
|
||||||
|
export interface FilesTable {
|
||||||
|
uuid: Generated<string>
|
||||||
|
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<FilesTable>
|
||||||
|
export type NewFile = Insertable<FilesTable>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue