1178 lines
40 KiB
TypeScript
1178 lines
40 KiB
TypeScript
import {
|
|
type Video, type InsertVideo, type UpdateVideo,
|
|
type User, type InsertUser,
|
|
type VideoUpload, type InsertVideoUpload,
|
|
type Category, type InsertCategory,
|
|
type Tag, type InsertTag,
|
|
videos, users, videoUploads, categories, tags, videoTags
|
|
} from "@shared/schema";
|
|
import { randomUUID } from "crypto";
|
|
import { BunnyService } from "./bunny";
|
|
import { videoSyncService } from "./videoSync";
|
|
import { db } from "./db";
|
|
import { eq, desc, asc, like, or, sql, and } from "drizzle-orm";
|
|
import bcrypt from "bcryptjs";
|
|
|
|
export interface IStorage {
|
|
// Video operations
|
|
getVideos(limit?: number, offset?: number, search?: string): Promise<Video[]>;
|
|
getVideo(id: string): Promise<Video | undefined>;
|
|
createVideo(video: InsertVideo): Promise<Video>;
|
|
updateVideo(id: string, video: UpdateVideo): Promise<Video | undefined>;
|
|
updateVideoViews(id: string): Promise<void>;
|
|
getVideoCount(search?: string): Promise<number>;
|
|
deleteVideo(id: string): Promise<boolean>;
|
|
|
|
// User operations
|
|
getUser(id: string): Promise<User | undefined>;
|
|
getUserByEmail(email: string): Promise<User | undefined>;
|
|
getUserByUsername(username: string): Promise<User | undefined>;
|
|
getAllUsers(limit?: number, offset?: number, search?: string): Promise<User[]>;
|
|
getUserCount(search?: string): Promise<number>;
|
|
createUser(user: InsertUser): Promise<User>;
|
|
updateUser(id: string, user: Partial<InsertUser>): Promise<User | undefined>;
|
|
upsertUser(user: any): Promise<User>;
|
|
validateUserPassword(email: string, password: string): Promise<User | null>;
|
|
|
|
// Upload operations
|
|
createVideoUpload(upload: InsertVideoUpload): Promise<VideoUpload>;
|
|
getVideoUpload(id: string): Promise<VideoUpload | undefined>;
|
|
updateVideoUpload(id: string, upload: Partial<InsertVideoUpload>): Promise<VideoUpload | undefined>;
|
|
getUserVideoUploads(userId: string): Promise<VideoUpload[]>;
|
|
|
|
// Category operations
|
|
getCategories(): Promise<Category[]>;
|
|
createCategory(category: InsertCategory): Promise<Category>;
|
|
updateCategory(id: string, category: Partial<InsertCategory>): Promise<Category | undefined>;
|
|
deleteCategory(id: string): Promise<boolean>;
|
|
|
|
// Tag operations
|
|
getTags(): Promise<Tag[]>;
|
|
getPopularTags(limit?: number): Promise<Tag[]>;
|
|
createTag(tag: InsertTag): Promise<Tag>;
|
|
incrementTagUse(name: string): Promise<void>;
|
|
}
|
|
|
|
// Database storage implementation using PostgreSQL
|
|
export class DatabaseStorage implements IStorage {
|
|
// Video operations
|
|
async getVideos(limit = 20, offset = 0, search?: string): Promise<Video[]> {
|
|
let query = db.select().from(videos);
|
|
|
|
if (search) {
|
|
const searchTerm = `%${search}%`;
|
|
query = query.where(
|
|
or(
|
|
like(videos.title, searchTerm),
|
|
like(videos.description, searchTerm)
|
|
)
|
|
) as any;
|
|
}
|
|
|
|
const result = await query
|
|
.orderBy(desc(videos.createdAt))
|
|
.limit(limit)
|
|
.offset(offset);
|
|
|
|
return result;
|
|
}
|
|
|
|
async getVideo(id: string): Promise<Video | undefined> {
|
|
const result = await db.select().from(videos).where(eq(videos.id, id));
|
|
return result[0];
|
|
}
|
|
|
|
async createVideo(video: InsertVideo): Promise<Video> {
|
|
const result = await db.insert(videos).values({
|
|
...video,
|
|
updatedAt: new Date()
|
|
}).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async updateVideo(id: string, updates: UpdateVideo): Promise<Video | undefined> {
|
|
const result = await db.update(videos)
|
|
.set({ ...updates, updatedAt: new Date() })
|
|
.where(eq(videos.id, id))
|
|
.returning();
|
|
return result[0];
|
|
}
|
|
|
|
async updateVideoViews(id: string): Promise<void> {
|
|
await db.update(videos)
|
|
.set({ views: sql`${videos.views} + 1` })
|
|
.where(eq(videos.id, id));
|
|
}
|
|
|
|
async getVideoCount(search?: string): Promise<number> {
|
|
let query = db.select({ count: sql<number>`count(*)` }).from(videos);
|
|
|
|
if (search) {
|
|
const searchTerm = `%${search}%`;
|
|
query = query.where(
|
|
or(
|
|
like(videos.title, searchTerm),
|
|
like(videos.description, searchTerm)
|
|
)
|
|
) as any;
|
|
}
|
|
|
|
const result = await query;
|
|
return result[0].count;
|
|
}
|
|
|
|
async deleteVideo(id: string): Promise<boolean> {
|
|
const result = await db.delete(videos).where(eq(videos.id, id));
|
|
return (result.rowCount ?? 0) > 0;
|
|
}
|
|
|
|
// User operations
|
|
async getUser(id: string): Promise<User | undefined> {
|
|
const result = await db.select().from(users).where(eq(users.id, id));
|
|
return result[0];
|
|
}
|
|
|
|
async getUserByEmail(email: string): Promise<User | undefined> {
|
|
const result = await db.select().from(users).where(eq(users.email, email));
|
|
return result[0];
|
|
}
|
|
|
|
async getUserByUsername(username: string): Promise<User | undefined> {
|
|
const result = await db.select().from(users).where(eq(users.username, username));
|
|
return result[0];
|
|
}
|
|
|
|
async createUser(user: InsertUser): Promise<User> {
|
|
// Hash password before storing
|
|
const hashedPassword = await bcrypt.hash(user.passwordHash, 12);
|
|
|
|
const { passwordHash: _, ...userWithoutPassword } = user;
|
|
const result = await db.insert(users).values({
|
|
...userWithoutPassword,
|
|
passwordHash: hashedPassword,
|
|
updatedAt: new Date()
|
|
} as any).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async updateUser(id: string, updates: Partial<InsertUser>): Promise<User | undefined> {
|
|
const updateData: any = { ...updates, updatedAt: new Date() };
|
|
|
|
// Hash password if it's being updated
|
|
if (updates.passwordHash) {
|
|
updateData.passwordHash = await bcrypt.hash(updates.passwordHash, 12);
|
|
}
|
|
|
|
const result = await db.update(users)
|
|
.set(updateData)
|
|
.where(eq(users.id, id))
|
|
.returning();
|
|
return result[0];
|
|
}
|
|
|
|
async upsertUser(userData: any): Promise<User> {
|
|
const [user] = await db
|
|
.insert(users)
|
|
.values({
|
|
id: userData.id,
|
|
email: userData.email,
|
|
firstName: userData.firstName,
|
|
lastName: userData.lastName,
|
|
avatar: userData.profileImageUrl,
|
|
username: userData.email || `user_${userData.id}`,
|
|
passwordHash: '', // No password for OAuth users
|
|
isAdmin: false,
|
|
isSuperAdmin: false,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: users.id,
|
|
set: {
|
|
email: userData.email,
|
|
firstName: userData.firstName,
|
|
lastName: userData.lastName,
|
|
avatar: userData.profileImageUrl,
|
|
updatedAt: new Date(),
|
|
},
|
|
})
|
|
.returning();
|
|
return user;
|
|
}
|
|
|
|
async getAllUsers(limit = 50, offset = 0, search?: string): Promise<User[]> {
|
|
let query = db.select().from(users);
|
|
|
|
if (search) {
|
|
const searchTerm = `%${search}%`;
|
|
query = query.where(
|
|
or(
|
|
like(users.username, searchTerm),
|
|
like(users.email, searchTerm),
|
|
like(users.firstName, searchTerm),
|
|
like(users.lastName, searchTerm)
|
|
)
|
|
) as any;
|
|
}
|
|
|
|
const result = await query
|
|
.orderBy(desc(users.createdAt))
|
|
.limit(limit)
|
|
.offset(offset);
|
|
|
|
return result;
|
|
}
|
|
|
|
async getUserCount(search?: string): Promise<number> {
|
|
let query = db.select({ count: sql<number>`count(*)` }).from(users);
|
|
|
|
if (search) {
|
|
const searchTerm = `%${search}%`;
|
|
query = query.where(
|
|
or(
|
|
like(users.username, searchTerm),
|
|
like(users.email, searchTerm),
|
|
like(users.firstName, searchTerm),
|
|
like(users.lastName, searchTerm)
|
|
)
|
|
) as any;
|
|
}
|
|
|
|
const result = await query;
|
|
return result[0].count;
|
|
}
|
|
|
|
async validateUserPassword(email: string, password: string): Promise<User | null> {
|
|
const user = await this.getUserByEmail(email);
|
|
if (!user) return null;
|
|
|
|
const isValid = await bcrypt.compare(password, user.passwordHash);
|
|
return isValid ? user : null;
|
|
}
|
|
|
|
// Upload operations
|
|
async createVideoUpload(upload: InsertVideoUpload): Promise<VideoUpload> {
|
|
const result = await db.insert(videoUploads).values({
|
|
...upload,
|
|
updatedAt: new Date()
|
|
}).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async getVideoUpload(id: string): Promise<VideoUpload | undefined> {
|
|
const result = await db.select().from(videoUploads).where(eq(videoUploads.id, id));
|
|
return result[0];
|
|
}
|
|
|
|
async updateVideoUpload(id: string, updates: Partial<InsertVideoUpload>): Promise<VideoUpload | undefined> {
|
|
const result = await db.update(videoUploads)
|
|
.set({ ...updates, updatedAt: new Date() })
|
|
.where(eq(videoUploads.id, id))
|
|
.returning();
|
|
return result[0];
|
|
}
|
|
|
|
async getUserVideoUploads(userId: string): Promise<VideoUpload[]> {
|
|
return await db.select().from(videoUploads)
|
|
.where(eq(videoUploads.userId, userId))
|
|
.orderBy(desc(videoUploads.createdAt));
|
|
}
|
|
|
|
// Category operations
|
|
async getCategories(): Promise<Category[]> {
|
|
return await db.select().from(categories).orderBy(asc(categories.name));
|
|
}
|
|
|
|
async createCategory(category: InsertCategory): Promise<Category> {
|
|
const result = await db.insert(categories).values(category).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async updateCategory(id: string, updates: Partial<InsertCategory>): Promise<Category | undefined> {
|
|
const result = await db.update(categories)
|
|
.set(updates)
|
|
.where(eq(categories.id, id))
|
|
.returning();
|
|
return result[0];
|
|
}
|
|
|
|
async deleteCategory(id: string): Promise<boolean> {
|
|
const result = await db.delete(categories).where(eq(categories.id, id));
|
|
return (result.rowCount ?? 0) > 0;
|
|
}
|
|
|
|
// Tag operations
|
|
async getTags(): Promise<Tag[]> {
|
|
return await db.select().from(tags).orderBy(desc(tags.useCount));
|
|
}
|
|
|
|
async getPopularTags(limit = 10): Promise<Tag[]> {
|
|
return await db.select().from(tags)
|
|
.orderBy(desc(tags.useCount))
|
|
.limit(limit);
|
|
}
|
|
|
|
async createTag(tag: InsertTag): Promise<Tag> {
|
|
const result = await db.insert(tags).values(tag).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async incrementTagUse(name: string): Promise<void> {
|
|
await db.update(tags)
|
|
.set({ useCount: sql`${tags.useCount} + 1` })
|
|
.where(eq(tags.name, name));
|
|
}
|
|
}
|
|
|
|
export class MemStorage implements IStorage {
|
|
private videos: Map<string, Video>;
|
|
private users: Map<string, User>;
|
|
private uploads: Map<string, VideoUpload>;
|
|
private categoriesMap: Map<string, Category>;
|
|
private tagsMap: Map<string, Tag>;
|
|
|
|
constructor() {
|
|
this.videos = new Map();
|
|
this.users = new Map();
|
|
this.uploads = new Map();
|
|
this.categoriesMap = new Map();
|
|
this.tagsMap = new Map();
|
|
this.initializeSampleData();
|
|
}
|
|
|
|
private async initializeSampleData() {
|
|
// Initialize sample categories
|
|
const sampleCategories: Category[] = [
|
|
{ id: randomUUID(), name: "Tutorial", description: "Educational content", color: "#3B82F6", createdAt: new Date() },
|
|
{ id: randomUUID(), name: "Business", description: "Business and professional content", color: "#10B981", createdAt: new Date() },
|
|
{ id: randomUUID(), name: "Design", description: "UI/UX and design content", color: "#F59E0B", createdAt: new Date() },
|
|
{ id: randomUUID(), name: "Analytics", description: "Data and analytics content", color: "#EF4444", createdAt: new Date() },
|
|
{ id: randomUUID(), name: "Mobile", description: "Mobile development", color: "#8B5CF6", createdAt: new Date() },
|
|
{ id: randomUUID(), name: "DevOps", description: "DevOps and infrastructure", color: "#06B6D4", createdAt: new Date() }
|
|
];
|
|
|
|
sampleCategories.forEach(category => {
|
|
this.categoriesMap.set(category.id, category);
|
|
});
|
|
|
|
// Initialize sample tags
|
|
const sampleTags: Tag[] = [
|
|
{ id: randomUUID(), name: "react", useCount: 25, createdAt: new Date() },
|
|
{ id: randomUUID(), name: "typescript", useCount: 20, createdAt: new Date() },
|
|
{ id: randomUUID(), name: "javascript", useCount: 30, createdAt: new Date() },
|
|
{ id: randomUUID(), name: "frontend", useCount: 18, createdAt: new Date() },
|
|
{ id: randomUUID(), name: "backend", useCount: 15, createdAt: new Date() },
|
|
{ id: randomUUID(), name: "tutorial", useCount: 40, createdAt: new Date() },
|
|
];
|
|
|
|
sampleTags.forEach(tag => {
|
|
this.tagsMap.set(tag.id, tag);
|
|
});
|
|
|
|
this.initializeSampleVideos();
|
|
}
|
|
|
|
private initializeSampleVideos() {
|
|
const sampleVideos: InsertVideo[] = [
|
|
{
|
|
title: "Advanced Web Development Techniques and Best Practices",
|
|
description: "Learn modern web development techniques including React, TypeScript, and performance optimization strategies.",
|
|
thumbnailUrl: "https://images.unsplash.com/photo-1560472355-536de3962603?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&h=450",
|
|
videoUrl: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
|
|
duration: 754, // 12:34
|
|
views: 2100,
|
|
category: "Tutorial"
|
|
},
|
|
{
|
|
title: "Team Collaboration Strategies for Remote Work",
|
|
description: "Effective strategies for managing remote teams and improving collaboration in distributed environments.",
|
|
thumbnailUrl: "https://images.unsplash.com/photo-1552664730-d307ca884978?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&h=450",
|
|
videoUrl: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4",
|
|
duration: 495, // 8:15
|
|
views: 856,
|
|
category: "Business"
|
|
},
|
|
{
|
|
title: "Modern UI/UX Design Principles and Workflows",
|
|
description: "Comprehensive guide to modern design principles, user experience optimization, and design system creation.",
|
|
thumbnailUrl: "https://images.unsplash.com/photo-1581291518857-4e27b48ff24e?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&h=450",
|
|
videoUrl: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
|
|
duration: 942, // 15:42
|
|
views: 3700,
|
|
category: "Design"
|
|
},
|
|
{
|
|
title: "Data Analytics and Business Intelligence Tutorial",
|
|
description: "Learn how to analyze data effectively using modern tools and create meaningful business insights.",
|
|
thumbnailUrl: "https://images.unsplash.com/photo-1551288049-bebda4e38f71?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&h=450",
|
|
videoUrl: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4",
|
|
duration: 1328, // 22:08
|
|
views: 1400,
|
|
category: "Analytics"
|
|
},
|
|
{
|
|
title: "Mobile App Development: From Concept to Launch",
|
|
description: "Complete guide to mobile app development covering design, development, testing, and deployment strategies.",
|
|
thumbnailUrl: "https://images.unsplash.com/photo-1512941937669-90a1b58e7e9c?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&h=450",
|
|
videoUrl: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4",
|
|
duration: 1113, // 18:33
|
|
views: 4200,
|
|
category: "Mobile"
|
|
},
|
|
{
|
|
title: "Cloud Infrastructure and DevOps Fundamentals",
|
|
description: "Understanding cloud computing, infrastructure as code, and DevOps practices for modern applications.",
|
|
thumbnailUrl: "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&h=450",
|
|
videoUrl: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4",
|
|
duration: 1517, // 25:17
|
|
views: 987,
|
|
category: "DevOps"
|
|
}
|
|
];
|
|
|
|
sampleVideos.forEach(video => {
|
|
const id = randomUUID();
|
|
const fullVideo: Video = {
|
|
...video,
|
|
id,
|
|
description: video.description || "",
|
|
category: video.category || "",
|
|
artist: null, // Add artist field
|
|
filename: null, // Original filename
|
|
episodeNumber: null, // Episode number for shows
|
|
episodeTitle: null, // Episode title for shows
|
|
customThumbnailUrl: null,
|
|
faceCenterPosition: null,
|
|
facesDetected: 0,
|
|
faceConfidence: 0,
|
|
videoUrlMp4: null,
|
|
videoUrlIframe: null,
|
|
tags: [],
|
|
isPublic: true,
|
|
views: video.views || 0,
|
|
contentType: video.contentType || 'video',
|
|
genre: video.genre || 'other',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
uploadStatus: video.uploadStatus || "completed",
|
|
originalFileName: video.originalFileName || null,
|
|
fileSize: video.fileSize || null,
|
|
bitrate: video.bitrate || null,
|
|
resolution: video.resolution || null,
|
|
format: video.format || null,
|
|
encoding: video.encoding || null
|
|
};
|
|
this.videos.set(id, fullVideo);
|
|
});
|
|
}
|
|
|
|
async getVideos(limit = 20, offset = 0, search?: string): Promise<Video[]> {
|
|
let videos = Array.from(this.videos.values());
|
|
|
|
// Optimized search - only search meaningful queries (2+ chars)
|
|
if (search && search.length >= 2) {
|
|
const searchLower = search.toLowerCase();
|
|
videos = videos.filter(video => {
|
|
// Check title first (most common match)
|
|
if (video.title.toLowerCase().includes(searchLower)) return true;
|
|
// Check description if exists
|
|
return video.description?.toLowerCase().includes(searchLower) || false;
|
|
});
|
|
}
|
|
|
|
// Sort by created date (newest first) - more efficient sort
|
|
videos.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
|
|
return videos.slice(offset, offset + limit);
|
|
}
|
|
|
|
async getVideo(id: string): Promise<Video | undefined> {
|
|
return this.videos.get(id);
|
|
}
|
|
|
|
async createVideo(video: InsertVideo): Promise<Video> {
|
|
const id = randomUUID();
|
|
const fullVideo: Video = {
|
|
...video,
|
|
id,
|
|
description: video.description || "",
|
|
category: video.category || "",
|
|
artist: video.artist || null, // Add artist field
|
|
filename: video.filename || null,
|
|
episodeNumber: video.episodeNumber || null,
|
|
episodeTitle: video.episodeTitle || null,
|
|
customThumbnailUrl: null,
|
|
faceCenterPosition: null,
|
|
facesDetected: 0,
|
|
faceConfidence: 0,
|
|
videoUrlMp4: null,
|
|
videoUrlIframe: null,
|
|
tags: video.tags || [],
|
|
isPublic: video.isPublic ?? true,
|
|
views: video.views || 0,
|
|
contentType: video.contentType || 'video',
|
|
genre: video.genre || 'other',
|
|
uploadStatus: video.uploadStatus || "completed",
|
|
originalFileName: video.originalFileName || null,
|
|
fileSize: video.fileSize || null,
|
|
bitrate: video.bitrate || null,
|
|
resolution: video.resolution || null,
|
|
format: video.format || null,
|
|
encoding: video.encoding || null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date()
|
|
};
|
|
this.videos.set(id, fullVideo);
|
|
return fullVideo;
|
|
}
|
|
|
|
async updateVideo(id: string, updates: UpdateVideo): Promise<Video | undefined> {
|
|
const video = this.videos.get(id);
|
|
if (!video) return undefined;
|
|
|
|
const updatedVideo: Video = {
|
|
...video,
|
|
...updates,
|
|
updatedAt: new Date()
|
|
};
|
|
this.videos.set(id, updatedVideo);
|
|
return updatedVideo;
|
|
}
|
|
|
|
async updateVideoViews(id: string): Promise<void> {
|
|
const video = this.videos.get(id);
|
|
if (video) {
|
|
video.views += 1;
|
|
this.videos.set(id, video);
|
|
}
|
|
}
|
|
|
|
async getVideoCount(search?: string): Promise<number> {
|
|
// More efficient count without loading all data
|
|
let videos = Array.from(this.videos.values());
|
|
|
|
if (search && search.length >= 2) {
|
|
const searchLower = search.toLowerCase();
|
|
videos = videos.filter(video =>
|
|
video.title.toLowerCase().includes(searchLower) ||
|
|
video.description?.toLowerCase().includes(searchLower)
|
|
);
|
|
}
|
|
|
|
return videos.length;
|
|
}
|
|
|
|
async deleteVideo(id: string): Promise<boolean> {
|
|
return this.videos.delete(id);
|
|
}
|
|
|
|
// User operations
|
|
async getUser(id: string): Promise<User | undefined> {
|
|
return this.users.get(id);
|
|
}
|
|
|
|
async getUserByEmail(email: string): Promise<User | undefined> {
|
|
return Array.from(this.users.values()).find(user => user.email === email);
|
|
}
|
|
|
|
async getUserByUsername(username: string): Promise<User | undefined> {
|
|
return Array.from(this.users.values()).find(user => user.username === username);
|
|
}
|
|
|
|
async createUser(user: InsertUser): Promise<User> {
|
|
const id = randomUUID();
|
|
const hashedPassword = await bcrypt.hash(user.passwordHash, 12);
|
|
|
|
const fullUser: User = {
|
|
...user,
|
|
id,
|
|
passwordHash: hashedPassword,
|
|
firstName: user.firstName || null,
|
|
lastName: user.lastName || null,
|
|
avatar: user.avatar || null,
|
|
isActive: user.isActive ?? true,
|
|
isAdmin: user.isAdmin ?? false,
|
|
isSuperAdmin: user.isSuperAdmin ?? false,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date()
|
|
};
|
|
this.users.set(id, fullUser);
|
|
return fullUser;
|
|
}
|
|
|
|
async updateUser(id: string, updates: Partial<InsertUser>): Promise<User | undefined> {
|
|
const user = this.users.get(id);
|
|
if (!user) return undefined;
|
|
|
|
const updateData: any = { ...updates, updatedAt: new Date() };
|
|
if (updates.passwordHash) {
|
|
updateData.passwordHash = await bcrypt.hash(updates.passwordHash, 12);
|
|
}
|
|
|
|
const updatedUser: User = {
|
|
...user,
|
|
...updateData
|
|
};
|
|
this.users.set(id, updatedUser);
|
|
return updatedUser;
|
|
}
|
|
|
|
async upsertUser(userData: any): Promise<User> {
|
|
const existingUser = this.users.get(userData.id);
|
|
const user: User = {
|
|
id: userData.id,
|
|
email: userData.email,
|
|
firstName: userData.firstName,
|
|
lastName: userData.lastName,
|
|
avatar: userData.profileImageUrl,
|
|
username: userData.email || `user_${userData.id}`,
|
|
passwordHash: '',
|
|
isActive: existingUser?.isActive ?? true,
|
|
isAdmin: existingUser?.isAdmin || false,
|
|
isSuperAdmin: existingUser?.isSuperAdmin || false,
|
|
createdAt: existingUser?.createdAt || new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
this.users.set(userData.id, user);
|
|
return user;
|
|
}
|
|
|
|
async getAllUsers(limit = 50, offset = 0, search?: string): Promise<User[]> {
|
|
let users = Array.from(this.users.values());
|
|
|
|
if (search && search.length >= 2) {
|
|
const searchLower = search.toLowerCase();
|
|
users = users.filter(user =>
|
|
user.username.toLowerCase().includes(searchLower) ||
|
|
user.email.toLowerCase().includes(searchLower) ||
|
|
user.firstName?.toLowerCase().includes(searchLower) ||
|
|
user.lastName?.toLowerCase().includes(searchLower)
|
|
);
|
|
}
|
|
|
|
// Sort by created date (newest first)
|
|
users.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
|
|
return users.slice(offset, offset + limit);
|
|
}
|
|
|
|
async getUserCount(search?: string): Promise<number> {
|
|
let users = Array.from(this.users.values());
|
|
|
|
if (search && search.length >= 2) {
|
|
const searchLower = search.toLowerCase();
|
|
users = users.filter(user =>
|
|
user.username.toLowerCase().includes(searchLower) ||
|
|
user.email.toLowerCase().includes(searchLower) ||
|
|
user.firstName?.toLowerCase().includes(searchLower) ||
|
|
user.lastName?.toLowerCase().includes(searchLower)
|
|
);
|
|
}
|
|
|
|
return users.length;
|
|
}
|
|
|
|
async validateUserPassword(email: string, password: string): Promise<User | null> {
|
|
const user = await this.getUserByEmail(email);
|
|
if (!user) return null;
|
|
|
|
const isValid = await bcrypt.compare(password, user.passwordHash);
|
|
return isValid ? user : null;
|
|
}
|
|
|
|
// Upload operations
|
|
async createVideoUpload(upload: InsertVideoUpload): Promise<VideoUpload> {
|
|
const id = randomUUID();
|
|
const fullUpload: VideoUpload = {
|
|
...upload,
|
|
id,
|
|
uploadStatus: upload.uploadStatus || "uploading",
|
|
uploadProgress: upload.uploadProgress || 0,
|
|
videoId: upload.videoId || null,
|
|
errorMessage: upload.errorMessage || null,
|
|
uploadUrl: upload.uploadUrl || null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date()
|
|
};
|
|
this.uploads.set(id, fullUpload);
|
|
return fullUpload;
|
|
}
|
|
|
|
async getVideoUpload(id: string): Promise<VideoUpload | undefined> {
|
|
return this.uploads.get(id);
|
|
}
|
|
|
|
async updateVideoUpload(id: string, updates: Partial<InsertVideoUpload>): Promise<VideoUpload | undefined> {
|
|
const upload = this.uploads.get(id);
|
|
if (!upload) return undefined;
|
|
|
|
const updatedUpload: VideoUpload = {
|
|
...upload,
|
|
...updates,
|
|
updatedAt: new Date()
|
|
};
|
|
this.uploads.set(id, updatedUpload);
|
|
return updatedUpload;
|
|
}
|
|
|
|
async getUserVideoUploads(userId: string): Promise<VideoUpload[]> {
|
|
return Array.from(this.uploads.values())
|
|
.filter(upload => upload.userId === userId)
|
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
}
|
|
|
|
// Category operations
|
|
async getCategories(): Promise<Category[]> {
|
|
return Array.from(this.categoriesMap.values())
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
async createCategory(category: InsertCategory): Promise<Category> {
|
|
const id = randomUUID();
|
|
const fullCategory: Category = {
|
|
...category,
|
|
id,
|
|
description: category.description || null,
|
|
color: category.color || "#000000",
|
|
createdAt: new Date()
|
|
};
|
|
this.categoriesMap.set(id, fullCategory);
|
|
return fullCategory;
|
|
}
|
|
|
|
async updateCategory(id: string, updates: Partial<InsertCategory>): Promise<Category | undefined> {
|
|
const category = this.categoriesMap.get(id);
|
|
if (!category) return undefined;
|
|
|
|
const updatedCategory: Category = {
|
|
...category,
|
|
...updates
|
|
};
|
|
this.categoriesMap.set(id, updatedCategory);
|
|
return updatedCategory;
|
|
}
|
|
|
|
async deleteCategory(id: string): Promise<boolean> {
|
|
return this.categoriesMap.delete(id);
|
|
}
|
|
|
|
// Tag operations
|
|
async getTags(): Promise<Tag[]> {
|
|
return Array.from(this.tagsMap.values())
|
|
.sort((a, b) => b.useCount - a.useCount);
|
|
}
|
|
|
|
async getPopularTags(limit = 10): Promise<Tag[]> {
|
|
return this.getTags().then(tags => tags.slice(0, limit));
|
|
}
|
|
|
|
async createTag(tag: InsertTag): Promise<Tag> {
|
|
const id = randomUUID();
|
|
const fullTag: Tag = {
|
|
...tag,
|
|
id,
|
|
useCount: tag.useCount ?? 0,
|
|
createdAt: new Date()
|
|
};
|
|
this.tagsMap.set(id, fullTag);
|
|
return fullTag;
|
|
}
|
|
|
|
async incrementTagUse(name: string): Promise<void> {
|
|
const tag = Array.from(this.tagsMap.values()).find(t => t.name === name);
|
|
if (tag) {
|
|
tag.useCount += 1;
|
|
this.tagsMap.set(tag.id, tag);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use Bunny.net storage if API keys are available, otherwise fallback to memory storage
|
|
class BunnyStorage implements IStorage {
|
|
private bunnyService: BunnyService;
|
|
private faceDataCache: Map<string, { faceCenterPosition?: string; facesDetected?: number; faceConfidence?: number }> = new Map();
|
|
|
|
constructor() {
|
|
this.bunnyService = new BunnyService();
|
|
}
|
|
|
|
async getVideos(limit = 20, offset = 0, search?: string): Promise<Video[]> {
|
|
console.log(`Fetching videos from cache: limit=${limit}, offset=${offset}, search=${search}`);
|
|
const result = videoSyncService.getVideos(limit, offset, search);
|
|
console.log(`Returning ${result.videos.length} videos from cache (age: ${result.cacheAge}ms)`);
|
|
|
|
// Apply face detection data from cache
|
|
return result.videos.map(video => {
|
|
const faceData = this.faceDataCache.get(video.id);
|
|
return faceData ? { ...video, ...faceData } : video;
|
|
});
|
|
}
|
|
|
|
async getVideo(id: string): Promise<Video | undefined> {
|
|
// Try cache first for faster loading
|
|
const cachedVideo = videoSyncService.getVideos(100, 0).videos.find(v => v.id === id);
|
|
if (cachedVideo) {
|
|
// Apply face detection data from cache
|
|
const faceData = this.faceDataCache.get(cachedVideo.id);
|
|
return faceData ? { ...cachedVideo, ...faceData } : cachedVideo;
|
|
}
|
|
|
|
// Fallback to direct API call
|
|
try {
|
|
const video = await this.bunnyService.getVideo(id);
|
|
if (!video) return undefined;
|
|
|
|
// Apply face detection data from cache
|
|
const faceData = this.faceDataCache.get(video.id);
|
|
return faceData ? { ...video, ...faceData } : video;
|
|
} catch (error) {
|
|
console.error(`Error fetching video ${id} from Bunny:`, error);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async createVideo(video: InsertVideo): Promise<Video> {
|
|
throw new Error("Creating videos is not supported with Bunny.net integration");
|
|
}
|
|
|
|
async updateVideo(id: string, updates: UpdateVideo): Promise<Video | undefined> {
|
|
// For admin edits, allow all metadata updates
|
|
// Face detection and views require special handling but metadata can be updated
|
|
|
|
try {
|
|
// Try to update in database first
|
|
let dbUpdateSuccessful = false;
|
|
try {
|
|
console.log(`Updating video ${id} with data:`, JSON.stringify(updates, null, 2));
|
|
await db.update(videos).set({...updates, updatedAt: new Date()}).where(eq(videos.id, id));
|
|
console.log(`Successfully updated video ${id} in database`);
|
|
dbUpdateSuccessful = true;
|
|
} catch (dbError) {
|
|
console.warn(`Database update failed for video ${id}, using cache:`, dbError);
|
|
}
|
|
|
|
// If database update was successful, force video sync refresh
|
|
if (dbUpdateSuccessful) {
|
|
try {
|
|
// Force videoSyncService to refresh this specific video
|
|
console.log(`Updated video ${id} in database, forcing cache refresh`);
|
|
videoSyncService.forceVideoRefresh(id);
|
|
} catch (cacheError) {
|
|
console.warn('Failed to update local cache after video update:', cacheError);
|
|
}
|
|
}
|
|
|
|
// Update face detection data in cache if present
|
|
const faceFields = ['faceCenterPosition', 'facesDetected', 'faceConfidence'];
|
|
const faceData = Object.fromEntries(
|
|
Object.entries(updates).filter(([key]) => faceFields.includes(key))
|
|
);
|
|
if (Object.keys(faceData).length > 0) {
|
|
const existing = this.faceDataCache.get(id) || {};
|
|
this.faceDataCache.set(id, { ...existing, ...faceData });
|
|
}
|
|
|
|
|
|
// Return updated video
|
|
return await this.getVideo(id);
|
|
} catch (error) {
|
|
console.error(`Error updating video ${id}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async deleteVideo(id: string): Promise<boolean> {
|
|
throw new Error("Deleting videos is not supported with Bunny.net integration");
|
|
}
|
|
|
|
// User operations (not supported with Bunny.net)
|
|
async getUser(id: string): Promise<User | undefined> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async getUserByEmail(email: string): Promise<User | undefined> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async getUserByUsername(username: string): Promise<User | undefined> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async createUser(user: InsertUser): Promise<User> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async updateUser(id: string, updates: Partial<InsertUser>): Promise<User | undefined> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async upsertUser(userData: any): Promise<User> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async getAllUsers(limit?: number, offset?: number, search?: string): Promise<User[]> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async getUserCount(search?: string): Promise<number> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async validateUserPassword(email: string, password: string): Promise<User | null> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
// Upload operations (not supported with Bunny.net)
|
|
async createVideoUpload(upload: InsertVideoUpload): Promise<VideoUpload> {
|
|
throw new Error("Upload operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async getVideoUpload(id: string): Promise<VideoUpload | undefined> {
|
|
throw new Error("Upload operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async updateVideoUpload(id: string, updates: Partial<InsertVideoUpload>): Promise<VideoUpload | undefined> {
|
|
throw new Error("Upload operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async getUserVideoUploads(userId: string): Promise<VideoUpload[]> {
|
|
throw new Error("Upload operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
// Category operations (not supported with Bunny.net)
|
|
async getCategories(): Promise<Category[]> {
|
|
throw new Error("Category operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async createCategory(category: InsertCategory): Promise<Category> {
|
|
throw new Error("Category operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async updateCategory(id: string, updates: Partial<InsertCategory>): Promise<Category | undefined> {
|
|
throw new Error("Category operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async deleteCategory(id: string): Promise<boolean> {
|
|
throw new Error("Category operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
// Tag operations (not supported with Bunny.net)
|
|
async getTags(): Promise<Tag[]> {
|
|
throw new Error("Tag operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async getPopularTags(limit = 10): Promise<Tag[]> {
|
|
throw new Error("Tag operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async createTag(tag: InsertTag): Promise<Tag> {
|
|
throw new Error("Tag operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async incrementTagUse(name: string): Promise<void> {
|
|
throw new Error("Tag operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async updateVideoViews(id: string): Promise<void> {
|
|
// Views are tracked automatically by Bunny.net CDN when videos are played
|
|
// No additional tracking needed - views are fetched fresh every 5 minutes from Bunny.net API
|
|
console.log(`Video ${id} played - views tracked by Bunny.net CDN`);
|
|
}
|
|
|
|
async getVideoCount(search?: string): Promise<number> {
|
|
try {
|
|
// Get the total from Bunny API directly for better performance
|
|
const { total } = await this.bunnyService.getVideos(1, 1);
|
|
|
|
// If no search, return total count
|
|
if (!search) {
|
|
return total;
|
|
}
|
|
|
|
// For search, we need to get videos and count filtered results
|
|
// This is expensive but needed for accurate search counts
|
|
const { videos } = await this.bunnyService.getVideos(1, 1000);
|
|
const searchLower = search.toLowerCase();
|
|
const filteredCount = videos.filter(video =>
|
|
video.title.toLowerCase().includes(searchLower) ||
|
|
(video.description && video.description.toLowerCase().includes(searchLower))
|
|
).length;
|
|
|
|
return filteredCount;
|
|
} catch (error) {
|
|
console.error('Error getting video count from Bunny:', error);
|
|
return 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hybrid storage implementation that uses Bunny.net for videos and Database for users
|
|
export class HybridStorage implements IStorage {
|
|
private bunnyStorage: BunnyStorage;
|
|
private databaseStorage: DatabaseStorage;
|
|
|
|
constructor() {
|
|
this.bunnyStorage = new BunnyStorage();
|
|
this.databaseStorage = new DatabaseStorage();
|
|
}
|
|
|
|
// Video operations - use Bunny.net
|
|
async getVideos(limit?: number, offset?: number, search?: string): Promise<Video[]> {
|
|
return this.databaseStorage.getVideos(limit, offset, search);
|
|
}
|
|
|
|
async getVideo(id: string): Promise<Video | undefined> {
|
|
return this.databaseStorage.getVideo(id);
|
|
}
|
|
|
|
async createVideo(video: InsertVideo): Promise<Video> {
|
|
return this.bunnyStorage.createVideo(video);
|
|
}
|
|
|
|
async updateVideo(id: string, video: UpdateVideo): Promise<Video | undefined> {
|
|
// Save changes ONLY to PostgreSQL database - do not update Bunny cache
|
|
return this.databaseStorage.updateVideo(id, video);
|
|
}
|
|
|
|
async updateVideoViews(id: string): Promise<void> {
|
|
return this.databaseStorage.updateVideoViews(id);
|
|
}
|
|
|
|
async getVideoCount(search?: string): Promise<number> {
|
|
return this.databaseStorage.getVideoCount(search);
|
|
}
|
|
|
|
async deleteVideo(id: string): Promise<boolean> {
|
|
return this.bunnyStorage.deleteVideo(id);
|
|
}
|
|
|
|
// User operations - use Database
|
|
async getUser(id: string): Promise<User | undefined> {
|
|
return this.databaseStorage.getUser(id);
|
|
}
|
|
|
|
async getUserByEmail(email: string): Promise<User | undefined> {
|
|
return this.databaseStorage.getUserByEmail(email);
|
|
}
|
|
|
|
async getUserByUsername(username: string): Promise<User | undefined> {
|
|
return this.databaseStorage.getUserByUsername(username);
|
|
}
|
|
|
|
async createUser(user: InsertUser): Promise<User> {
|
|
return this.databaseStorage.createUser(user);
|
|
}
|
|
|
|
async updateUser(id: string, user: Partial<InsertUser>): Promise<User | undefined> {
|
|
return this.databaseStorage.updateUser(id, user);
|
|
}
|
|
|
|
async upsertUser(user: any): Promise<User> {
|
|
return this.databaseStorage.upsertUser(user);
|
|
}
|
|
|
|
async getAllUsers(limit?: number, offset?: number, search?: string): Promise<User[]> {
|
|
return this.databaseStorage.getAllUsers(limit, offset, search);
|
|
}
|
|
|
|
async getUserCount(search?: string): Promise<number> {
|
|
return this.databaseStorage.getUserCount(search);
|
|
}
|
|
|
|
async validateUserPassword(email: string, password: string): Promise<User | null> {
|
|
return this.databaseStorage.validateUserPassword(email, password);
|
|
}
|
|
|
|
// Upload operations - use Database
|
|
async createVideoUpload(upload: InsertVideoUpload): Promise<VideoUpload> {
|
|
return this.databaseStorage.createVideoUpload(upload);
|
|
}
|
|
|
|
async getVideoUpload(id: string): Promise<VideoUpload | undefined> {
|
|
return this.databaseStorage.getVideoUpload(id);
|
|
}
|
|
|
|
async updateVideoUpload(id: string, upload: Partial<InsertVideoUpload>): Promise<VideoUpload | undefined> {
|
|
return this.databaseStorage.updateVideoUpload(id, upload);
|
|
}
|
|
|
|
async getUserVideoUploads(userId: string): Promise<VideoUpload[]> {
|
|
return this.databaseStorage.getUserVideoUploads(userId);
|
|
}
|
|
|
|
// Category operations - use Database
|
|
async getCategories(): Promise<Category[]> {
|
|
return this.databaseStorage.getCategories();
|
|
}
|
|
|
|
async createCategory(category: InsertCategory): Promise<Category> {
|
|
return this.databaseStorage.createCategory(category);
|
|
}
|
|
|
|
async updateCategory(id: string, category: Partial<InsertCategory>): Promise<Category | undefined> {
|
|
return this.databaseStorage.updateCategory(id, category);
|
|
}
|
|
|
|
async deleteCategory(id: string): Promise<boolean> {
|
|
return this.databaseStorage.deleteCategory(id);
|
|
}
|
|
|
|
// Tag operations - use Database
|
|
async getTags(): Promise<Tag[]> {
|
|
return this.databaseStorage.getTags();
|
|
}
|
|
|
|
async getPopularTags(limit?: number): Promise<Tag[]> {
|
|
return this.databaseStorage.getPopularTags(limit);
|
|
}
|
|
|
|
async createTag(tag: InsertTag): Promise<Tag> {
|
|
return this.databaseStorage.createTag(tag);
|
|
}
|
|
|
|
async incrementTagUse(name: string): Promise<void> {
|
|
return this.databaseStorage.incrementTagUse(name);
|
|
}
|
|
}
|
|
|
|
// Storage selection logic - use hybrid approach when both Bunny.net and Database are available
|
|
let storage: IStorage;
|
|
|
|
const hasDatabase = process.env.DATABASE_URL;
|
|
const hasBunnyConfig = process.env.BUNNY_API_KEY && process.env.BUNNY_LIBRARY_ID && process.env.BUNNY_HOSTNAME;
|
|
|
|
// Use hybrid storage when both Bunny.net and Database are available
|
|
if (hasBunnyConfig && hasDatabase) {
|
|
try {
|
|
storage = new HybridStorage();
|
|
console.log('✅ Using Hybrid storage (Bunny.net for videos + Database for users)');
|
|
} catch (error) {
|
|
console.error('❌ Failed to initialize Hybrid storage:', error);
|
|
console.log('📁 Falling back to memory storage');
|
|
storage = new MemStorage();
|
|
}
|
|
}
|
|
// Prioritize Bunny.net storage for video content only
|
|
else if (hasBunnyConfig) {
|
|
try {
|
|
storage = new BunnyStorage();
|
|
console.log('✅ Using Bunny.net storage with library ID:', process.env.BUNNY_LIBRARY_ID);
|
|
} catch (error) {
|
|
console.error('❌ Failed to initialize Bunny.net storage:', error);
|
|
console.log('📁 Falling back to memory storage');
|
|
storage = new MemStorage();
|
|
}
|
|
} else if (hasDatabase) {
|
|
try {
|
|
storage = new DatabaseStorage();
|
|
console.log('✅ Using PostgreSQL database storage');
|
|
} catch (error) {
|
|
console.error('❌ Failed to initialize database storage:', error);
|
|
console.log('📁 Falling back to memory storage');
|
|
storage = new MemStorage();
|
|
}
|
|
} else {
|
|
console.log('📁 Using memory storage (no database or Bunny.net config found)');
|
|
storage = new MemStorage();
|
|
}
|
|
|
|
export { storage };
|