2024-12-03 19:33:43 +00:00
|
|
|
import { Elysia, t } from 'elysia';
|
2025-11-16 09:57:18 +00:00
|
|
|
import { RedisRateLimiter } from 'rolling-rate-limiter'
|
2024-12-03 19:33:43 +00:00
|
|
|
import * as fs from 'node:fs'
|
|
|
|
|
|
|
|
|
|
import { db } from '@/utils/database'
|
|
|
|
|
import { validateVideo, validateChannel } from '@/utils/regex'
|
|
|
|
|
import { checkCaptcha, createDatabaseVideo } from '@/utils/common';
|
|
|
|
|
import { downloadVideo } from '@/utils/download';
|
|
|
|
|
import { uploadVideo } from '@/utils/upload';
|
2026-03-02 16:51:32 +00:00
|
|
|
import { getChannelVideos, getVideo } from '@/utils/metadata';
|
2025-11-01 21:42:00 +00:00
|
|
|
import { error } from '@/utils/html'
|
2024-12-03 20:29:03 +00:00
|
|
|
import redis from '@/utils/redis';
|
2026-03-02 16:51:32 +00:00
|
|
|
import { parseSlop } from '@/utils/slop';
|
2024-12-03 19:33:43 +00:00
|
|
|
|
|
|
|
|
const app = new Elysia()
|
|
|
|
|
const videoIds: Record<string, string> = {}
|
|
|
|
|
|
2025-11-16 09:57:18 +00:00
|
|
|
const limiter = new RedisRateLimiter({
|
|
|
|
|
client: redis,
|
|
|
|
|
namespace: 'save:',
|
|
|
|
|
interval: 24 * 60 * 60000, // 24h
|
2026-02-20 00:25:37 +00:00
|
|
|
maxInInterval: 50
|
2025-11-16 09:57:18 +00:00
|
|
|
})
|
|
|
|
|
|
2026-02-21 10:01:59 +00:00
|
|
|
const sendError = (ws: any, message: string, close: boolean = true) => {
|
2024-12-03 19:33:43 +00:00
|
|
|
ws.send(`ERROR - ${message}`);
|
2026-02-21 10:01:59 +00:00
|
|
|
if (close) ws.close();
|
2024-12-03 19:33:43 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const cleanup = async (ws: any, videoId: string) => {
|
|
|
|
|
delete videoIds[ws.id];
|
|
|
|
|
if (videoId) await redis.del(videoId);
|
|
|
|
|
await redis.del(ws.id);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleUpload = async (ws: any, videoId: string, isChannel: boolean = false) => {
|
2025-08-01 18:07:26 +00:00
|
|
|
// the pattern of files that have finished downloading is [videoid].mp4, but some extensions are also possible due to
|
|
|
|
|
// current youtube changes, so we need to make sure the other extensions are also covered
|
2025-08-01 18:12:44 +00:00
|
|
|
let filePath = fs.readdirSync('./videos/').find(f => f.includes(`${videoId}.`))
|
2025-08-01 18:07:26 +00:00
|
|
|
if (!filePath) {
|
2024-12-03 19:33:43 +00:00
|
|
|
ws.send(`DATA - Video file for ${videoId} not found. Skipping.`);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-01 18:12:44 +00:00
|
|
|
filePath = './videos/' + filePath
|
|
|
|
|
|
2024-12-03 19:33:43 +00:00
|
|
|
try {
|
|
|
|
|
ws.send('DATA - Uploading file...');
|
|
|
|
|
const videoUrl = await uploadVideo(filePath);
|
|
|
|
|
fs.unlinkSync(filePath);
|
|
|
|
|
|
|
|
|
|
const uploaded = await createDatabaseVideo(videoId, videoUrl);
|
|
|
|
|
if (uploaded !== 'success') {
|
|
|
|
|
ws.send(`DATA - Error while uploading - ${JSON.stringify(uploaded)}`);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isChannel) ws.send(`DONE - ${process.env.FRONTEND}/watch?v=${videoId}`);
|
|
|
|
|
return true;
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
ws.send(`ERROR - Upload failed for ${videoId}: ${error.message}`);
|
2024-12-04 08:09:34 +00:00
|
|
|
console.log(`upload failed for ${videoId}: ${error.message}`)
|
|
|
|
|
|
|
|
|
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
|
2024-12-03 19:33:43 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-21 10:01:59 +00:00
|
|
|
const getRateLimitKey = (ip: string): string => {
|
|
|
|
|
if (!ip || ip === '0.0.0.0') return ip;
|
|
|
|
|
if (ip.includes(':')) {
|
|
|
|
|
const parts = ip.split(':');
|
|
|
|
|
if (parts.length >= 3) {
|
|
|
|
|
return `${parts[0]}:${parts[1]}:${parts[2]}`;
|
|
|
|
|
}
|
|
|
|
|
return ip;
|
|
|
|
|
}
|
|
|
|
|
return ip;
|
|
|
|
|
};
|
|
|
|
|
|
2024-12-03 19:33:43 +00:00
|
|
|
app.ws('/save', {
|
|
|
|
|
query: t.Object({
|
|
|
|
|
url: t.String()
|
|
|
|
|
}),
|
|
|
|
|
body: t.String(),
|
|
|
|
|
open: async (ws) => {
|
|
|
|
|
console.log(`${ws.id} - ${ws.data.path} - ${JSON.stringify(ws.data.query)}`)
|
|
|
|
|
|
|
|
|
|
const videoId = validateVideo(ws.data.query.url)
|
|
|
|
|
if (!videoId) return sendError(ws, 'Invalid video URL.');
|
|
|
|
|
if (await redis.get(videoId)) return sendError(ws, 'Someone is already downloading this video...');
|
|
|
|
|
if (await redis.get(`blacklist:${videoId}`)) return sendError(ws, 'This video is blacklisted.');
|
|
|
|
|
|
|
|
|
|
const already = await db.selectFrom('videos')
|
|
|
|
|
.select('id')
|
|
|
|
|
.where('id', '=', videoId)
|
|
|
|
|
.executeTakeFirst()
|
|
|
|
|
|
|
|
|
|
if (already) {
|
|
|
|
|
ws.send(`DONE - ${process.env.FRONTEND}/watch?v=${videoId}`)
|
|
|
|
|
ws.close()
|
|
|
|
|
} else {
|
2026-02-21 10:01:59 +00:00
|
|
|
const hash = Bun.hash(getRateLimitKey(ws.data.headers['cf-connecting-ip'] || '0.0.0.0'))
|
2025-11-16 09:57:18 +00:00
|
|
|
const isLimited = await limiter.limit(hash.toString())
|
|
|
|
|
if (isLimited) {
|
|
|
|
|
return sendError(ws, 'You have been ratelimited. </br>Is this an urgent archive? Please email me: admin@preservetube.com');
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-03 19:33:43 +00:00
|
|
|
ws.send('DATA - This process is automatic. Your video will start archiving shortly.')
|
|
|
|
|
ws.send('CAPTCHA - Solving a cryptographic challenge before downloading.')
|
|
|
|
|
videoIds[ws.id] = videoId
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
message: async (ws, message) => {
|
|
|
|
|
if (message == 'alive') return
|
|
|
|
|
|
|
|
|
|
const videoId = videoIds[ws.id];
|
|
|
|
|
if (!videoId) return sendError(ws, 'No video ID associated with this session.');
|
|
|
|
|
|
|
|
|
|
if (await redis.get(videoId) !== 'downloading') {
|
|
|
|
|
await redis.set(videoId, 'downloading', 'EX', 300)
|
|
|
|
|
|
2026-02-20 21:53:03 +00:00
|
|
|
const captchaCheck = await checkCaptcha(message, ws.data.headers['cf-connecting-ip'] || '0.0.0.0')
|
|
|
|
|
if (!captchaCheck.success) {
|
2024-12-03 19:33:43 +00:00
|
|
|
await cleanup(ws, videoId);
|
2026-02-20 21:53:03 +00:00
|
|
|
console.log(`captcha failed for ${videoId} - ${JSON.stringify(captchaCheck)}`)
|
2024-12-03 19:33:43 +00:00
|
|
|
return sendError(ws, 'Captcha validation failed.');
|
2025-09-24 17:33:39 +00:00
|
|
|
} else {
|
|
|
|
|
ws.send('DATA - Captcha validated. Starting download...');
|
2024-12-03 19:33:43 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-02 16:51:32 +00:00
|
|
|
const data = await getVideo(videoId)
|
|
|
|
|
const slopScore = await parseSlop(videoId, data.videoDetails.title,
|
|
|
|
|
(data.microformat.playerMicroformatRenderer.description?.simpleText || '').replaceAll('\n', '<br>'))
|
|
|
|
|
|
|
|
|
|
if (slopScore >= 4) {
|
|
|
|
|
sendError(ws, 'Filters can always be wrong. Is the rating wrong? Email me at admin@preservetube.com');
|
|
|
|
|
return sendError(ws, 'Your download has been rejected by our slop filter.');
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-03 19:33:43 +00:00
|
|
|
const downloadResult = await downloadVideo(ws, videoId);
|
|
|
|
|
if (downloadResult.fail) {
|
|
|
|
|
await cleanup(ws, videoId);
|
|
|
|
|
return sendError(ws, downloadResult.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const uploadSuccess = await handleUpload(ws, videoId);
|
|
|
|
|
if (!uploadSuccess) await redis.del(videoId);
|
|
|
|
|
|
|
|
|
|
await cleanup(ws, videoId);
|
|
|
|
|
ws.close();
|
|
|
|
|
} else {
|
|
|
|
|
ws.send('DATA - Captcha already submitted.');
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
close: async (ws) => {
|
|
|
|
|
await cleanup(ws, videoIds[ws.id]);
|
|
|
|
|
console.log(`closed - ${ws.data.path} - ${JSON.stringify(ws.data.query)}`)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
app.ws('/savechannel', {
|
|
|
|
|
query: t.Object({
|
|
|
|
|
url: t.String()
|
|
|
|
|
}),
|
|
|
|
|
body: t.String(),
|
|
|
|
|
open: async (ws) => {
|
|
|
|
|
console.log(`${ws.id} - ${ws.data.path} - ${JSON.stringify(ws.data.query)}`)
|
|
|
|
|
|
|
|
|
|
const channelId = await validateChannel(ws.data.query.url);
|
|
|
|
|
if (!channelId) return sendError(ws, 'Invalid channel URL.');
|
2025-09-15 20:03:05 +00:00
|
|
|
if (typeof channelId !== 'string') return sendError(ws, `Failed to fetch channel ID - ${channelId.error}`)
|
2024-12-03 19:33:43 +00:00
|
|
|
|
|
|
|
|
ws.send('DATA - This process is automatic. Your video will start archiving shortly.')
|
|
|
|
|
ws.send('CAPTCHA - Solving a cryptographic challenge before downloading.')
|
|
|
|
|
videoIds[ws.id] = `captcha-${channelId}`;
|
|
|
|
|
},
|
|
|
|
|
message: async (ws, message) => {
|
|
|
|
|
if (message == 'alive') return
|
|
|
|
|
|
|
|
|
|
const status = videoIds[ws.id];
|
|
|
|
|
if (!status || !status.startsWith('captcha-')) return sendError(ws, 'No channel associated with this session.');
|
|
|
|
|
|
|
|
|
|
const channelId = status.replace('captcha-', '');
|
2026-02-20 21:53:03 +00:00
|
|
|
const captchaCheck = await checkCaptcha(message, ws.data.headers['cf-connecting-ip'] || '0.0.0.0')
|
|
|
|
|
|
|
|
|
|
if (!captchaCheck.success) {
|
2024-12-03 19:33:43 +00:00
|
|
|
await cleanup(ws, channelId);
|
2026-02-20 21:53:03 +00:00
|
|
|
console.log(`captcha failed for ${channelId} - ${JSON.stringify(captchaCheck)}`)
|
2024-12-03 19:33:43 +00:00
|
|
|
return sendError(ws, 'Captcha validation failed.');
|
2025-09-24 17:33:39 +00:00
|
|
|
} else {
|
|
|
|
|
ws.send('DATA - Captcha validated. Starting download...');
|
2024-12-03 19:33:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
videoIds[ws.id] = `downloading-${channelId}`;
|
|
|
|
|
const videos = await getChannelVideos(channelId);
|
|
|
|
|
|
|
|
|
|
for (const video of videos.slice(0, 5)) {
|
2025-03-31 06:34:50 +00:00
|
|
|
if (!video || (await redis.get(video.video_id)) || (await redis.get(`blacklist:${video.video_id}`))) continue;
|
2024-12-03 19:33:43 +00:00
|
|
|
|
|
|
|
|
const already = await db.selectFrom('videos')
|
|
|
|
|
.select('id')
|
2025-03-31 06:34:50 +00:00
|
|
|
.where('id', '=', video.video_id)
|
2024-12-03 19:33:43 +00:00
|
|
|
.executeTakeFirst()
|
|
|
|
|
if (already) continue
|
|
|
|
|
|
2026-02-21 10:01:59 +00:00
|
|
|
const hash = Bun.hash(getRateLimitKey(ws.data.headers['cf-connecting-ip'] || '0.0.0.0'))
|
|
|
|
|
const isLimited = await limiter.limit(hash.toString())
|
|
|
|
|
if (isLimited) {
|
|
|
|
|
sendError(ws, 'You have been ratelimited. </br>Is this an urgent archive? Please email me: admin@preservetube.com', false);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 16:51:32 +00:00
|
|
|
const slopScore = await parseSlop(video.video_id, video.title.text, video.description_snippet.text)
|
|
|
|
|
|
|
|
|
|
if (slopScore >= 4) {
|
|
|
|
|
sendError(ws, 'Filters can always be wrong. Is the rating wrong? Email me at admin@preservetube.com');
|
|
|
|
|
sendError(ws, 'Your download has been rejected by our slop filter.');
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-03 19:33:43 +00:00
|
|
|
ws.send(`DATA - Processing video: ${video.title.text}`);
|
2025-03-31 06:34:50 +00:00
|
|
|
await redis.set(video.video_id, 'downloading', 'EX', 300);
|
2024-12-03 19:33:43 +00:00
|
|
|
|
2025-03-31 06:34:50 +00:00
|
|
|
const downloadResult = await downloadVideo(ws, video.video_id);
|
|
|
|
|
if (!downloadResult.fail) await handleUpload(ws, video.video_id, true);
|
2024-12-03 19:33:43 +00:00
|
|
|
|
2025-03-31 06:34:50 +00:00
|
|
|
await redis.del(video.video_id);
|
2024-12-03 19:33:43 +00:00
|
|
|
ws.send(`DATA - Created video page for ${video.title.text}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await cleanup(ws, channelId);
|
|
|
|
|
ws.send(`DONE - ${process.env.FRONTEND}/channel/${channelId}`)
|
|
|
|
|
ws.close();
|
|
|
|
|
},
|
|
|
|
|
close: async (ws) => {
|
|
|
|
|
await cleanup(ws, videoIds[ws.id]);
|
|
|
|
|
console.log(`closed - ${ws.data.path} - ${JSON.stringify(ws.data.query)}`)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2025-11-01 21:42:00 +00:00
|
|
|
app.onError(error)
|
2024-12-03 19:33:43 +00:00
|
|
|
export default app
|