videofolxtv/server/videoSync.ts
sebastjanartic 3c7aef0fa5 Ensure video titles and metadata reflect database edits
Update video fetching logic to merge database edits with CDN content, fixing issues with displayed titles and ensuring data consistency across the platform.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 2cd2c0bc-434c-4bc9-ad3f-b99d3897a0d1
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/2cd2c0bc-434c-4bc9-ad3f-b99d3897a0d1/DVlzRoR
2025-09-04 06:33:23 +00:00

319 lines
11 KiB
TypeScript

import { BunnyService } from './bunny';
import { db } from './db';
import { videos } from '@shared/schema';
import { eq, sql } from 'drizzle-orm';
interface VideoSyncCache {
videos: any[];
lastUpdate: number;
isUpdating: boolean;
}
class VideoSyncService {
private cache: VideoSyncCache = {
videos: [],
lastUpdate: 0,
isUpdating: false
};
private bunnyService: BunnyService;
private syncInterval: NodeJS.Timeout | null = null;
constructor() {
this.bunnyService = new BunnyService();
}
private async getAllVideos(): Promise<any[]> {
let allVideos: any[] = [];
let page = 1;
const itemsPerPage = 100; // Maximum per request
let hasMore = true;
console.log('📥 Starting to fetch all videos with pagination...');
while (hasMore) {
try {
console.log(`📄 Fetching page ${page}...`);
const result = await this.bunnyService.getVideos(page, itemsPerPage);
allVideos = allVideos.concat(result.videos);
// Check if there are more pages
const totalFetched = allVideos.length;
hasMore = totalFetched < result.total && result.videos.length > 0;
console.log(`📊 Page ${page}: ${result.videos.length} videos (Total so far: ${allVideos.length}/${result.total})`);
page++;
// Add small delay to avoid rate limiting
if (hasMore) {
await new Promise(resolve => setTimeout(resolve, 100));
}
// Safety limit to prevent infinite loops
if (page > 100) {
console.log('⚠️ Reached page limit (100), stopping fetch');
break;
}
} catch (error) {
console.error(`❌ Error fetching page ${page}:`, error);
break;
}
}
console.log(`✅ Completed pagination fetch: ${allVideos.length} total videos`);
return allVideos;
}
private async syncVideosToDatabase() {
if (this.cache.videos.length === 0) {
console.log('⚠️ No videos in cache to sync to database');
return;
}
console.log('🔄 Syncing cached videos to PostgreSQL database...');
const startTime = Date.now();
let insertedCount = 0;
let updatedCount = 0;
let errorCount = 0;
try {
// First, test database connectivity
await db.execute(sql`SELECT 1`);
console.log('📡 Database connection verified');
// Process videos in smaller batches to avoid overwhelming the database
const batchSize = 10;
const totalBatches = Math.ceil(this.cache.videos.length / batchSize);
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
const start = batchIndex * batchSize;
const end = Math.min(start + batchSize, this.cache.videos.length);
const batch = this.cache.videos.slice(start, end);
console.log(`📦 Processing batch ${batchIndex + 1}/${totalBatches} (videos ${start + 1}-${end})`);
for (const video of batch) {
try {
// Check if video exists in database using raw SQL
const existingVideo = await db.execute(sql`SELECT id FROM videos WHERE id = ${video.id} LIMIT 1`);
const videoData = {
id: video.id,
title: video.title,
description: video.description || '',
thumbnailUrl: video.thumbnailUrl,
customThumbnailUrl: video.customThumbnailUrl || null,
videoUrl: video.videoUrl,
duration: video.duration,
views: video.views,
category: video.category || '',
tags: Array.isArray(video.tags) ? video.tags : [],
isPublic: video.isPublic !== false,
createdAt: video.createdAt ? new Date(video.createdAt) : new Date(),
updatedAt: new Date(),
};
if (existingVideo.rows.length === 0) {
// Insert new video using raw SQL to avoid schema issues
await db.execute(sql`
INSERT INTO videos (id, title, description, thumbnail_url, video_url, duration, views, category, custom_thumbnail_url, tags, is_public, created_at, updated_at)
VALUES (${videoData.id}, ${videoData.title}, ${videoData.description}, ${videoData.thumbnailUrl}, ${videoData.videoUrl}, ${videoData.duration}, ${videoData.views}, ${videoData.category}, ${videoData.customThumbnailUrl}, ${'{' + videoData.tags.join(',') + '}'}, ${videoData.isPublic}, ${videoData.createdAt.toISOString()}, ${videoData.updatedAt.toISOString()})
`);
insertedCount++;
} else {
// Update existing video using raw SQL
await db.execute(sql`
UPDATE videos
SET title = ${videoData.title}, description = ${videoData.description}, thumbnail_url = ${videoData.thumbnailUrl},
video_url = ${videoData.videoUrl}, duration = ${videoData.duration}, views = ${videoData.views},
category = ${videoData.category}, custom_thumbnail_url = ${videoData.customThumbnailUrl},
tags = ${'{' + videoData.tags.join(',') + '}'}, is_public = ${videoData.isPublic}, updated_at = ${videoData.updatedAt.toISOString()}
WHERE id = ${video.id}
`);
updatedCount++;
}
} catch (error) {
console.error(`❌ Failed to sync video ${video.id}:`, error);
errorCount++;
// Continue with other videos
}
}
// Small delay between batches to avoid overwhelming the database
if (batchIndex < totalBatches - 1) {
await new Promise(resolve => setTimeout(resolve, 50));
}
}
const duration = Date.now() - startTime;
console.log(`✅ Database sync completed in ${duration}ms:`);
console.log(` 📥 Inserted: ${insertedCount} new videos`);
console.log(` 🔄 Updated: ${updatedCount} existing videos`);
console.log(` ❌ Errors: ${errorCount} videos`);
console.log(` 📊 Database total: ${insertedCount + updatedCount} videos`);
console.log(` 🎥 Cache total: ${this.cache.videos.length} videos`);
} catch (error) {
console.error('❌ Database sync failed:', error);
throw error; // Re-throw to be caught by timeout wrapper
}
}
async initialize() {
console.log('🔄 Initializing video sync service...');
try {
await this.syncVideos();
// Add timeout protection for database sync
const syncTimeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Database sync timeout after 30 seconds')), 30000)
);
await Promise.race([
this.syncVideosToDatabase(),
syncTimeout
]);
this.startPeriodicSync();
console.log('✅ Video sync service initialized successfully');
} catch (error) {
console.error('❌ Failed to initialize video sync service:', error);
console.log('⚠️ Continuing without database sync - app will still work with cached data');
// Continue without crashing the server
}
}
private async syncVideos() {
if (this.cache.isUpdating) {
console.log('⏳ Video sync already in progress, skipping...');
return;
}
this.cache.isUpdating = true;
const startTime = Date.now();
try {
console.log('🔍 Fetching ALL videos from Bunny.net...');
const allVideos = await this.getAllVideos();
this.cache.videos = allVideos;
this.cache.lastUpdate = Date.now();
const duration = Date.now() - startTime;
console.log(`✅ Video sync completed: ${allVideos.length} videos cached in ${duration}ms`);
} catch (error) {
console.error('❌ Video sync failed:', error);
} finally {
this.cache.isUpdating = false;
}
}
private startPeriodicSync() {
// Sync every 5 minutes for better performance
this.syncInterval = setInterval(() => {
console.log('⏰ Starting scheduled video sync...');
this.syncVideos();
}, 5 * 60 * 1000);
console.log('📅 Scheduled video sync every 5 minutes for optimal performance');
}
async getVideos(limit: number = 20, offset: number = 0, search?: string) {
let filteredVideos = await this.mergeWithDatabaseEdits(this.cache.videos);
// Fast client-side search
if (search && search.length >= 2) {
const searchLower = search.toLowerCase();
filteredVideos = filteredVideos.filter(video =>
video.title.toLowerCase().includes(searchLower) ||
video.description?.toLowerCase().includes(searchLower)
);
}
const paginatedVideos = filteredVideos.slice(offset, offset + limit);
return {
videos: paginatedVideos,
total: filteredVideos.length,
hasMore: offset + limit < filteredVideos.length,
cacheAge: Date.now() - this.cache.lastUpdate
};
}
private async mergeWithDatabaseEdits(bunnyVideos: any[]) {
try {
// Fetch all edited videos from database
const dbVideos = await db.select().from(videos);
const dbVideoMap = new Map(dbVideos.map(v => [v.id, v]));
// Merge Bunny.net data with database edits
return bunnyVideos.map(bunnyVideo => {
const dbVideo = dbVideoMap.get(bunnyVideo.id);
if (dbVideo) {
// Database has edited version - use edited metadata but keep Bunny.net URLs
return {
...bunnyVideo,
title: dbVideo.title,
description: dbVideo.description,
category: dbVideo.category,
tags: dbVideo.tags || [],
customThumbnailUrl: dbVideo.customThumbnailUrl,
isPublic: dbVideo.isPublic,
updatedAt: dbVideo.updatedAt
};
}
return bunnyVideo; // No database edits - use original Bunny.net data
});
} catch (error) {
console.warn('Failed to merge database edits, using Bunny.net data only:', error);
return bunnyVideos;
}
}
async getVideo(id: string) {
// First try cache
const cachedVideo = this.cache.videos.find(v => v.id === id);
if (cachedVideo) {
// Merge with database edits
const mergedVideos = await this.mergeWithDatabaseEdits([cachedVideo]);
return mergedVideos[0];
}
// Fallback to direct API call
const bunnyVideo = await this.bunnyService.getVideo(id);
if (bunnyVideo) {
const mergedVideos = await this.mergeWithDatabaseEdits([bunnyVideo]);
return mergedVideos[0];
}
return bunnyVideo;
}
forceVideoRefresh(videoId: string) {
console.log(`🔄 Forcing refresh for video ${videoId}`);
// Remove video from cache to force fresh fetch
this.cache.videos = this.cache.videos.filter(v => v.id !== videoId);
// Mark cache as needing update
this.cache.lastUpdate = 0;
}
getCacheStats() {
return {
videosCount: this.cache.videos.length,
lastUpdate: this.cache.lastUpdate,
isUpdating: this.cache.isUpdating,
cacheAge: Date.now() - this.cache.lastUpdate
};
}
stop() {
if (this.syncInterval) {
clearInterval(this.syncInterval);
this.syncInterval = null;
console.log('🛑 Video sync service stopped');
}
}
}
export const videoSyncService = new VideoSyncService();