first commit
This commit is contained in:
+210
@@ -0,0 +1,210 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const root = process.cwd();
|
||||
const dbDir = path.join(root, 'db');
|
||||
const dbFile = path.join(dbDir, 'newtube.db');
|
||||
const schemaFile = path.join(dbDir, 'schema.sql');
|
||||
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create DB and enable FKs
|
||||
const db = new Database(dbFile);
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// Run schema if present
|
||||
if (fs.existsSync(schemaFile)) {
|
||||
const ddl = fs.readFileSync(schemaFile, 'utf8');
|
||||
if (ddl && ddl.trim().length) {
|
||||
db.exec(ddl);
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
export function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function getUserByUsername(username) {
|
||||
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
}
|
||||
|
||||
export function getUserById(id) {
|
||||
return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
export function insertUser({ id, username, email, passwordHash }) {
|
||||
const ts = nowIso();
|
||||
db.prepare(`INSERT INTO users (id, username, email, password_hash, is_active, created_at, updated_at)
|
||||
VALUES (@id, @username, @email, @passwordHash, 1, @ts, @ts)`).run({ id, username, email, passwordHash, ts });
|
||||
// Default preferences row
|
||||
db.prepare(`INSERT INTO user_preferences (user_id, language, default_provider, theme, video_quality, region, version, updated_at)
|
||||
VALUES (@id, 'en', 'youtube', 'system', 'auto', 'US', 1, @ts)`).run({ id, ts });
|
||||
}
|
||||
|
||||
export function upsertPreferences(userId, patch) {
|
||||
const current = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(userId);
|
||||
if (!current) {
|
||||
const merged = {
|
||||
language: patch.language ?? 'en',
|
||||
default_provider: patch.defaultProvider ?? 'youtube',
|
||||
theme: patch.theme ?? 'system',
|
||||
video_quality: patch.videoQuality ?? 'auto',
|
||||
region: patch.region ?? 'US',
|
||||
version: 1,
|
||||
};
|
||||
db.prepare(`INSERT INTO user_preferences (user_id, language, default_provider, theme, video_quality, region, version, updated_at)
|
||||
VALUES (@userId, @language, @default_provider, @theme, @video_quality, @region, @version, @updated_at)`)
|
||||
.run({ userId, ...merged, updated_at: nowIso() });
|
||||
} else {
|
||||
const merged = {
|
||||
language: patch.language ?? current.language,
|
||||
default_provider: patch.defaultProvider ?? current.default_provider,
|
||||
theme: patch.theme ?? current.theme,
|
||||
video_quality: patch.videoQuality ?? current.video_quality,
|
||||
region: patch.region ?? current.region,
|
||||
version: (current.version ?? 1) + 1,
|
||||
updated_at: nowIso(),
|
||||
};
|
||||
db.prepare(`UPDATE user_preferences
|
||||
SET language=@language, default_provider=@default_provider, theme=@theme,
|
||||
video_quality=@video_quality, region=@region, version=@version, updated_at=@updated_at
|
||||
WHERE user_id=@userId`)
|
||||
.run({ userId, ...merged });
|
||||
}
|
||||
}
|
||||
|
||||
export function getPreferences(userId) {
|
||||
return db.prepare('SELECT language, default_provider AS defaultProvider, theme, video_quality AS videoQuality, region, version, updated_at FROM user_preferences WHERE user_id = ?').get(userId);
|
||||
}
|
||||
|
||||
export function insertSession({ id, userId, refreshTokenHash, isRemember, userAgent, deviceInfo, ip, expiresAt }) {
|
||||
const ts = nowIso();
|
||||
db.prepare(`INSERT INTO sessions (id, user_id, refresh_token_hash, user_agent, device_info, ip_address, is_remember, created_at, last_seen_at, expires_at)
|
||||
VALUES (@id, @userId, @refreshTokenHash, @userAgent, @deviceInfo, @ip, @isRemember, @ts, @ts, @expiresAt)`)
|
||||
.run({ id, userId, refreshTokenHash, userAgent, deviceInfo, ip, isRemember: isRemember ? 1 : 0, ts, expiresAt });
|
||||
}
|
||||
|
||||
export function getSessionById(id) {
|
||||
return db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
export function updateSessionToken(id, refreshTokenHash, expiresAt) {
|
||||
const ts = nowIso();
|
||||
db.prepare('UPDATE sessions SET refresh_token_hash = ?, last_seen_at = ?, expires_at = ?, revoked_at = NULL WHERE id = ?')
|
||||
.run(refreshTokenHash, ts, expiresAt, id);
|
||||
}
|
||||
|
||||
export function revokeSession(id) {
|
||||
const ts = nowIso();
|
||||
db.prepare('UPDATE sessions SET revoked_at = ? WHERE id = ?').run(ts, id);
|
||||
}
|
||||
|
||||
export function revokeAllUserSessions(userId) {
|
||||
const ts = nowIso();
|
||||
db.prepare('UPDATE sessions SET revoked_at = ? WHERE user_id = ?').run(ts, userId);
|
||||
}
|
||||
|
||||
export function listUserSessions(userId) {
|
||||
return db.prepare('SELECT id, user_agent AS userAgent, device_info AS deviceInfo, ip_address AS ip, is_remember AS isRemember, created_at AS createdAt, last_seen_at AS lastSeenAt, expires_at AS expiresAt, revoked_at AS revokedAt FROM sessions WHERE user_id = ? ORDER BY created_at DESC').all(userId);
|
||||
}
|
||||
|
||||
export function setUserLastLogin(userId) {
|
||||
const ts = nowIso();
|
||||
db.prepare('UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?').run(ts, ts, userId);
|
||||
}
|
||||
|
||||
export function insertLoginAudit({ userId, username, ip, userAgent, success, reason }) {
|
||||
const id = cryptoRandomId();
|
||||
const ts = nowIso();
|
||||
db.prepare(`INSERT INTO login_audit (id, user_id, username, ip_address, user_agent, success, reason, created_at)
|
||||
VALUES (@id, @userId, @username, @ip, @userAgent, @success, @reason, @ts)`)
|
||||
.run({ id, userId, username, ip, userAgent, success: success ? 1 : 0, reason: reason || null, ts });
|
||||
}
|
||||
|
||||
export function cryptoRandomId() {
|
||||
// simple URL-safe base64 16 bytes id
|
||||
return randomBytes(16).toString('base64url');
|
||||
}
|
||||
|
||||
export function cryptoRandomUUID() {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
export default db;
|
||||
|
||||
// -------------------- Search History --------------------
|
||||
export function insertSearchHistory({ userId, query, filters }) {
|
||||
const id = cryptoRandomId();
|
||||
const created_at = nowIso();
|
||||
const filters_json = filters ? JSON.stringify(filters) : null;
|
||||
db.prepare(`INSERT INTO search_history (id, user_id, query, filters_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(id, userId, query, filters_json, created_at);
|
||||
return { id, created_at };
|
||||
}
|
||||
|
||||
export function listSearchHistory({ userId, limit = 50, before }) {
|
||||
const rows = before
|
||||
? db.prepare(`SELECT * FROM search_history WHERE user_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT ?`).all(userId, before, limit)
|
||||
: db.prepare(`SELECT * FROM search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`).all(userId, limit);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function deleteSearchHistoryById(userId, id) {
|
||||
db.prepare(`DELETE FROM search_history WHERE id = ? AND user_id = ?`).run(id, userId);
|
||||
}
|
||||
|
||||
export function deleteAllSearchHistory(userId) {
|
||||
db.prepare(`DELETE FROM search_history WHERE user_id = ?`).run(userId);
|
||||
}
|
||||
|
||||
// -------------------- Watch History --------------------
|
||||
export function upsertWatchHistory({ userId, provider, videoId, title, watchedAt, progressSeconds = 0, durationSeconds = 0, lastPositionSeconds = 0 }) {
|
||||
const now = nowIso();
|
||||
const watched_at = watchedAt || now;
|
||||
// Insert or update on unique (user_id, provider, video_id)
|
||||
db.prepare(`INSERT INTO watch_history (id, user_id, provider, video_id, title, watched_at, progress_seconds, duration_seconds, last_position_seconds, last_watched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, provider, video_id) DO UPDATE SET
|
||||
title=COALESCE(excluded.title, title),
|
||||
progress_seconds=MAX(excluded.progress_seconds, watch_history.progress_seconds),
|
||||
duration_seconds=MAX(excluded.duration_seconds, watch_history.duration_seconds),
|
||||
last_position_seconds=excluded.last_position_seconds,
|
||||
last_watched_at=excluded.last_watched_at`).run(
|
||||
cryptoRandomId(), userId, provider, videoId, title || null, watched_at, progressSeconds, durationSeconds, lastPositionSeconds, now
|
||||
);
|
||||
// Return the row id
|
||||
const row = db.prepare(`SELECT * FROM watch_history WHERE user_id = ? AND provider = ? AND video_id = ?`).get(userId, provider, videoId);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function updateWatchHistoryById(id, { progressSeconds, lastPositionSeconds }) {
|
||||
const now = nowIso();
|
||||
const row = db.prepare(`SELECT * FROM watch_history WHERE id = ?`).get(id);
|
||||
if (!row) return null;
|
||||
const nextProgress = (typeof progressSeconds === 'number') ? Math.max(progressSeconds, row.progress_seconds || 0) : row.progress_seconds;
|
||||
const nextLastPos = (typeof lastPositionSeconds === 'number') ? lastPositionSeconds : row.last_position_seconds;
|
||||
db.prepare(`UPDATE watch_history SET progress_seconds = ?, last_position_seconds = ?, last_watched_at = ? WHERE id = ?`)
|
||||
.run(nextProgress, nextLastPos, now, id);
|
||||
return db.prepare(`SELECT * FROM watch_history WHERE id = ?`).get(id);
|
||||
}
|
||||
|
||||
export function listWatchHistory({ userId, limit = 50, before }) {
|
||||
const rows = before
|
||||
? db.prepare(`SELECT * FROM watch_history WHERE user_id = ? AND watched_at < ? ORDER BY watched_at DESC LIMIT ?`).all(userId, before, limit)
|
||||
: db.prepare(`SELECT * FROM watch_history WHERE user_id = ? ORDER BY watched_at DESC LIMIT ?`).all(userId, limit);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function deleteWatchHistoryById(userId, id) {
|
||||
db.prepare(`DELETE FROM watch_history WHERE id = ? AND user_id = ?`).run(id, userId);
|
||||
}
|
||||
|
||||
export function deleteAllWatchHistory(userId) {
|
||||
db.prepare(`DELETE FROM watch_history WHERE user_id = ?`).run(userId);
|
||||
}
|
||||
+1067
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
||||
import express from 'express';
|
||||
import * as cheerio from 'cheerio';
|
||||
import axios from 'axios';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Rate limiter for Rumble scraping to prevent being blocked
|
||||
const rumbleLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 min
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests to Rumble API. Please try again later.' }
|
||||
});
|
||||
|
||||
router.use(rumbleLimiter);
|
||||
|
||||
// Simple in-memory cache with TTL
|
||||
const cache = new Map();
|
||||
const TTL_MS = 60 * 1000; // 60s
|
||||
|
||||
function cacheKey(path, params) {
|
||||
return `${path}?${new URLSearchParams(params).toString()}`;
|
||||
}
|
||||
|
||||
function setCache(key, data) {
|
||||
cache.set(key, { data, expires: Date.now() + TTL_MS });
|
||||
}
|
||||
|
||||
function getCache(key) {
|
||||
const hit = cache.get(key);
|
||||
if (!hit) return null;
|
||||
if (Date.now() > hit.expires) { cache.delete(key); return null; }
|
||||
return hit.data;
|
||||
}
|
||||
|
||||
async function httpGet(url) {
|
||||
const resp = await axios.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.8'
|
||||
},
|
||||
timeout: 15000
|
||||
});
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
async function scrapeRumbleVideo(videoId) {
|
||||
try {
|
||||
const html = await httpGet(`https://rumble.com/${videoId}`);
|
||||
const $ = cheerio.load(html);
|
||||
const title = $('h1.video-title, .video-title h1').first().text().trim() || $('meta[property="og:title"]').attr('content') || '';
|
||||
const thumbnail = $('meta[property="og:image"]').attr('content') || '';
|
||||
const uploaderName = $('.media-by--a, .channel-name').first().text().trim() || '';
|
||||
const viewsText = $('.rumbles-views, .video-views').first().text().trim();
|
||||
const views = parseInt((viewsText || '').replace(/[^0-9]/g, '')) || 0;
|
||||
const durationText = $('meta[property="video:duration"]').attr('content');
|
||||
const duration = durationText ? parseInt(durationText) : 0;
|
||||
const uploadedDate = $('meta[property="article:published_time"]').attr('content') || '';
|
||||
const description = $('meta[property="og:description"]').attr('content') || '';
|
||||
return { videoId, title: title || 'Untitled Video', thumbnail, uploaderName: uploaderName || 'Unknown Uploader', views, duration, uploadedDate, description, url: `https://rumble.com/${videoId}`, type: 'video' };
|
||||
} catch (e) {
|
||||
console.error('scrapeRumbleVideo error:', e.message);
|
||||
return { videoId, error: 'Scraping failed' };
|
||||
}
|
||||
}
|
||||
|
||||
async function scrapeRumbleList({ q, page = 1, limit = 24, sort = 'viral' }) {
|
||||
try {
|
||||
const url = q
|
||||
? `https://rumble.com/search/video?q=${encodeURIComponent(q)}&page=${page}`
|
||||
: `https://rumble.com/videos?sort=${encodeURIComponent(sort)}&page=${page}`;
|
||||
const html = await httpGet(url);
|
||||
const $ = cheerio.load(html);
|
||||
const items = [];
|
||||
// Try to select video cards; Rumble uses different layouts, so search broadly
|
||||
$('a[href^="/v"], a[href^="/video/"]').each((_, el) => {
|
||||
const a = $(el);
|
||||
const href = a.attr('href') || '';
|
||||
// Expect href like /vabcdef or /video/abcdef
|
||||
const m = href.match(/\/v([A-Za-z0-9]+)/) || href.match(/\/video\/([A-Za-z0-9]+)/);
|
||||
if (!m) return;
|
||||
const vid = `v${m[1]}`;
|
||||
const title = a.attr('title') || a.text().trim();
|
||||
// Look around for thumbnail and meta
|
||||
const parent = a.closest('li, article, div');
|
||||
const img = parent.find('img').first();
|
||||
let thumb = img.attr('data-src') || img.attr('src') || '';
|
||||
if (thumb && thumb.startsWith('//')) thumb = 'https:' + thumb;
|
||||
const durationText = parent.find('.video-item--duration, .video-duration, .duration').first().text().trim();
|
||||
const viewsText = parent.find('.video-item--views, .rumbles-views, .views').first().text().trim();
|
||||
const duration = (() => {
|
||||
const m = durationText.match(/(\d+):(\d+)(?::(\d+))?/);
|
||||
if (!m) return 0;
|
||||
const h = parseInt(m[3] || '0', 10), mn = parseInt(m[1] || '0', 10), s = parseInt(m[2] || '0', 10);
|
||||
return h * 3600 + mn * 60 + s;
|
||||
})();
|
||||
const views = parseInt((viewsText || '').replace(/[^0-9]/g, '')) || 0;
|
||||
items.push({ videoId: vid, title, thumbnail: thumb, uploaderName: '', views, duration, uploadedDate: '', url: `https://rumble.com/${vid}`, type: 'video' });
|
||||
});
|
||||
// De-duplicate by videoId and slice to limit
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
for (const it of items) { if (!seen.has(it.videoId)) { seen.add(it.videoId); unique.push(it); } }
|
||||
const list = unique.slice(0, limit);
|
||||
const nextCursor = list.length === limit ? String(Number(page) + 1) : null;
|
||||
return { items: list, total: unique.length, page: Number(page), limit: Number(limit), nextCursor };
|
||||
} catch (e) {
|
||||
console.error('scrapeRumbleList error:', e.message);
|
||||
return { items: [], total: 0, page: Number(page), limit: Number(limit), nextCursor: null };
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/browse', async (req, res) => {
|
||||
const page = parseInt(String(req.query.page || '1'), 10) || 1;
|
||||
const limit = Math.min(50, parseInt(String(req.query.limit || '24'), 10) || 24);
|
||||
const sort = String(req.query.sort || 'viral');
|
||||
const key = cacheKey('/browse', { page, limit, sort });
|
||||
const cached = getCache(key);
|
||||
if (cached) return res.json(cached);
|
||||
const data = await scrapeRumbleList({ page, limit, sort });
|
||||
setCache(key, data);
|
||||
return res.json(data);
|
||||
});
|
||||
|
||||
router.get('/search', async (req, res) => {
|
||||
const q = String(req.query.q || '').trim();
|
||||
if (!q) return res.status(400).json({ error: 'Query parameter required' });
|
||||
const limit = Math.min(50, parseInt(String(req.query.limit || '24'), 10) || 24);
|
||||
const page = (() => {
|
||||
// Support offset-based cursor from frontend by translating offset->page
|
||||
if (req.query.offset != null) {
|
||||
const offset = parseInt(String(req.query.offset), 10) || 0;
|
||||
return Math.floor(offset / limit) + 1;
|
||||
}
|
||||
return parseInt(String(req.query.page || '1'), 10) || 1;
|
||||
})();
|
||||
const key = cacheKey('/search', { q, page, limit });
|
||||
const cached = getCache(key);
|
||||
if (cached) return res.json(cached);
|
||||
const data = await scrapeRumbleList({ q, page, limit });
|
||||
setCache(key, data);
|
||||
return res.json(data);
|
||||
});
|
||||
|
||||
router.get('/video/:videoId', async (req, res) => {
|
||||
try {
|
||||
const { videoId } = req.params;
|
||||
const key = cacheKey('/video', { videoId });
|
||||
const cached = getCache(key);
|
||||
if (cached) return res.json(cached);
|
||||
const videoData = await scrapeRumbleVideo(videoId);
|
||||
if (videoData.error) return res.status(404).json({ error: 'Video not found or scraping failed' });
|
||||
setCache(key, videoData);
|
||||
return res.json(videoData);
|
||||
} catch (error) {
|
||||
console.error('Rumble video error:', error);
|
||||
return res.status(500).json({ error: 'Failed to scrape video' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user