# Surprise Me - Implementation Code Examples This document provides code examples and snippets for implementing the Surprise Me feature. --- ## Table of Contents 1. [Database Migration](#database-migration) 2. [Recommendation Service](#recommendation-service) 3. [API Routes](#api-routes) 4. [UI Components](#ui-components) 5. [Utility Functions](#utility-functions) --- ## Database Migration ### Add media_access Table ```typescript // src/db/index.ts - Add to initialization db.exec(` CREATE TABLE IF NOT EXISTS media_access ( id INTEGER PRIMARY KEY AUTOINCREMENT, media_id INTEGER NOT NULL, access_type TEXT NOT NULL CHECK (access_type IN ('view', 'play', 'bookmark', 'rate')), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE ); `); // Create indexes db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_media_id ON media_access(media_id);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_created_at ON media_access(created_at);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_type_created_at ON media_access(access_type, created_at);`); ``` ### Helper Functions ```typescript // src/db/index.ts export function trackMediaAccess( mediaId: number, accessType: 'view' | 'play' | 'bookmark' | 'rate' ): void { const db = getDatabase(); db.prepare(` INSERT INTO media_access (media_id, access_type) VALUES (?, ?) `).run(mediaId, accessType); } export function getLastAccess(mediaId: number): Date | null { const db = getDatabase(); const result = db.prepare(` SELECT MAX(created_at) as last_access FROM media_access WHERE media_id = ? AND access_type IN ('view', 'play') `).get(mediaId) as { last_access: string | null }; return result?.last_access ? new Date(result.last_access) : null; } ``` --- ## Recommendation Service ### Base Service Structure ```typescript // src/lib/recommendation-service.ts import { getDatabase } from '@/db'; export type RecommendationAlgorithm = | 'smart_mix' | 'weighted_random' | 'forgotten_gems' | 'similar_to_favorites' | 'time_based' | 'pure_random' | 'unwatched_first'; export interface RecommendedVideo { id: number; title: string; path: string; size: number; thumbnail: string; type: string; bookmark_count: number; star_count: number; avg_rating: number; created_at: string; recommendation_reason: string; recommendation_score: number; } export interface RecommendationOptions { algorithm?: RecommendationAlgorithm; limit?: number; excludeRecentDays?: number; } export class RecommendationService { private db: any; constructor() { this.db = getDatabase(); } async getRecommendations(options: RecommendationOptions): Promise { const { algorithm = 'smart_mix', limit = 20, excludeRecentDays = 30 } = options; switch (algorithm) { case 'smart_mix': return this.getSmartMixRecommendations(limit, excludeRecentDays); case 'weighted_random': return this.getWeightedRandomRecommendations(limit, excludeRecentDays); case 'forgotten_gems': return this.getForgottenGemsRecommendations(limit, excludeRecentDays); case 'similar_to_favorites': return this.getSimilarToFavoritesRecommendations(limit, excludeRecentDays); case 'time_based': return this.getTimeBasedRecommendations(limit, excludeRecentDays); case 'pure_random': return this.getPureRandomRecommendations(limit); case 'unwatched_first': return this.getUnwatchedFirstRecommendations(limit); default: return this.getSmartMixRecommendations(limit, excludeRecentDays); } } // Algorithm implementations... } ``` ### Weighted Random Algorithm ```typescript private getWeightedRandomRecommendations( limit: number, excludeRecentDays: number ): RecommendedVideo[] { const query = ` SELECT m.*, ABS(RANDOM() % 100) / 100.0 as random_factor, COALESCE( JULIANDAY('now') - JULIANDAY(ma.last_access), JULIANDAY('now') - JULIANDAY(m.created_at) ) as days_since_view, COALESCE(m.avg_rating, 0) as rating, 'Smart random selection' as recommendation_reason FROM media m LEFT JOIN ( SELECT media_id, MAX(created_at) as last_access FROM media_access WHERE access_type IN ('view', 'play') GROUP BY media_id ) ma ON m.id = ma.media_id WHERE m.type = 'video' AND ( ma.last_access IS NULL OR ma.last_access < datetime('now', '-' || ? || ' days') ) ORDER BY ( random_factor * MIN(1.0, days_since_view / 90.0) * (1.0 + rating / 10.0) ) DESC LIMIT ? `; const results = this.db.prepare(query).all(excludeRecentDays, limit); return results.map((row: any, index: number) => ({ ...row, recommendation_score: 1.0 - (index / limit) })); } ``` ### Forgotten Gems Algorithm ```typescript private getForgottenGemsRecommendations( limit: number, excludeRecentDays: number ): RecommendedVideo[] { const query = ` SELECT DISTINCT m.*, CASE WHEN b.id IS NOT NULL THEN 'You bookmarked this video' WHEN m.avg_rating >= 4 THEN 'You rated this ' || m.avg_rating || ' stars' ELSE 'From a folder you liked' END as recommendation_reason, ( COALESCE(m.avg_rating, 0) * 0.7 + CASE WHEN b.id IS NOT NULL THEN 3 ELSE 0 END ) / 8.0 as recommendation_score FROM media m LEFT JOIN bookmarks b ON m.id = b.media_id LEFT JOIN ( SELECT media_id, MAX(created_at) as last_access FROM media_access WHERE access_type IN ('view', 'play') GROUP BY media_id ) ma ON m.id = ma.media_id WHERE m.type = 'video' AND ( b.id IS NOT NULL OR m.avg_rating >= 4 ) AND ( ma.last_access IS NULL OR ma.last_access < datetime('now', '-' || ? || ' days') ) ORDER BY recommendation_score DESC, RANDOM() LIMIT ? `; return this.db.prepare(query).all(excludeRecentDays, limit); } ``` ### Unwatched First Algorithm ```typescript private getUnwatchedFirstRecommendations(limit: number): RecommendedVideo[] { const query = ` SELECT m.*, 'Never watched before' as recommendation_reason, 1.0 as recommendation_score FROM media m LEFT JOIN media_access ma ON m.id = ma.media_id WHERE m.type = 'video' AND ma.id IS NULL ORDER BY m.created_at DESC LIMIT ? `; return this.db.prepare(query).all(limit); } ``` ### Smart Mix Algorithm ```typescript private async getSmartMixRecommendations( limit: number, excludeRecentDays: number ): Promise { // Calculate distribution const distribution = { weightedRandom: Math.floor(limit * 0.30), forgottenGems: Math.floor(limit * 0.25), similarToFavorites: Math.floor(limit * 0.20), unwatchedFirst: Math.floor(limit * 0.15), timeBased: Math.floor(limit * 0.10) }; // Collect from each algorithm const recommendations: RecommendedVideo[] = []; recommendations.push( ...this.getWeightedRandomRecommendations( distribution.weightedRandom, excludeRecentDays ) ); recommendations.push( ...this.getForgottenGemsRecommendations( distribution.forgottenGems, excludeRecentDays ) ); recommendations.push( ...this.getSimilarToFavoritesRecommendations( distribution.similarToFavorites, excludeRecentDays ) ); recommendations.push( ...this.getUnwatchedFirstRecommendations( distribution.unwatchedFirst ) ); recommendations.push( ...this.getTimeBasedRecommendations( distribution.timeBased, excludeRecentDays ) ); // Shuffle and deduplicate return this.shuffleAndDeduplicate(recommendations).slice(0, limit); } private shuffleAndDeduplicate(videos: RecommendedVideo[]): RecommendedVideo[] { // Remove duplicates by ID const uniqueMap = new Map(); videos.forEach(video => { if (!uniqueMap.has(video.id)) { uniqueMap.set(video.id, video); } }); // Convert to array and shuffle const unique = Array.from(uniqueMap.values()); for (let i = unique.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [unique[i], unique[j]] = [unique[j], unique[i]]; } return unique; } ``` --- ## API Routes ### Recommendations Endpoint ```typescript // src/app/api/videos/recommendations/route.ts import { NextRequest, NextResponse } from 'next/server'; import { RecommendationService } from '@/lib/recommendation-service'; export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); const algorithm = searchParams.get('algorithm') || 'smart_mix'; const limit = parseInt(searchParams.get('limit') || '20'); const excludeRecentDays = parseInt(searchParams.get('exclude_recent_days') || '30'); // Validate parameters if (limit < 1 || limit > 100) { return NextResponse.json( { error: 'Limit must be between 1 and 100' }, { status: 400 } ); } const service = new RecommendationService(); const recommendations = await service.getRecommendations({ algorithm: algorithm as any, limit, excludeRecentDays }); return NextResponse.json({ recommendations, algorithm, total_videos: recommendations.length, total_recommended: recommendations.length }); } catch (error: any) { console.error('Recommendation error:', error); return NextResponse.json( { error: error.message || 'Failed to get recommendations' }, { status: 500 } ); } } ``` ### Media Access Tracking Endpoint ```typescript // src/app/api/media-access/[id]/route.ts import { NextRequest, NextResponse } from 'next/server'; import { trackMediaAccess } from '@/db'; export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { const { id } = await params; const mediaId = parseInt(id); if (isNaN(mediaId)) { return NextResponse.json( { error: 'Invalid media ID' }, { status: 400 } ); } const body = await request.json(); const { accessType } = body; if (!['view', 'play', 'bookmark', 'rate'].includes(accessType)) { return NextResponse.json( { error: 'Invalid access type' }, { status: 400 } ); } trackMediaAccess(mediaId, accessType); return NextResponse.json({ success: true, mediaId, accessType }); } catch (error: any) { console.error('Access tracking error:', error); return NextResponse.json( { error: error.message || 'Failed to track access' }, { status: 500 } ); } } ``` --- ## UI Components ### Surprise Me Page ```typescript // src/app/surprise-me/page.tsx 'use client'; import { useState, useEffect } from 'react'; import { RefreshCw, Sparkles } from 'lucide-react'; import { Button } from '@/components/ui/button'; import InfiniteVirtualGrid from '@/components/infinite-virtual-grid'; import UnifiedVideoPlayer from '@/components/unified-video-player'; type Algorithm = | 'smart_mix' | 'weighted_random' | 'forgotten_gems' | 'similar_to_favorites' | 'time_based' | 'pure_random' | 'unwatched_first'; const ALGORITHMS: { value: Algorithm; label: string; description: string }[] = [ { value: 'smart_mix', label: 'Smart Mix', description: 'Balanced mix of all recommendation types' }, { value: 'weighted_random', label: 'Weighted Random', description: 'Smart randomization with variety' }, { value: 'forgotten_gems', label: 'Forgotten Gems', description: 'Bookmarked or highly-rated, but not watched recently' }, { value: 'similar_to_favorites', label: 'Similar to Favorites', description: 'From folders with your top-rated videos' }, { value: 'time_based', label: 'Time-Based', description: 'Based on when videos were added' }, { value: 'pure_random', label: 'Pure Random', description: 'Complete serendipity' }, { value: 'unwatched_first', label: 'Unwatched First', description: 'Videos you\'ve never seen' } ]; export default function SurpriseMePage() { const [algorithm, setAlgorithm] = useState('smart_mix'); const [recommendations, setRecommendations] = useState([]); const [isLoading, setIsLoading] = useState(true); const [selectedVideo, setSelectedVideo] = useState(null); const [isPlayerOpen, setIsPlayerOpen] = useState(false); const fetchRecommendations = async () => { setIsLoading(true); try { const response = await fetch( `/api/videos/recommendations?algorithm=${algorithm}&limit=20` ); const data = await response.json(); setRecommendations(data.recommendations || []); } catch (error) { console.error('Failed to fetch recommendations:', error); } finally { setIsLoading(false); } }; useEffect(() => { fetchRecommendations(); }, [algorithm]); const handleVideoClick = async (video: any) => { // Track view try { await fetch(`/api/media-access/${video.id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accessType: 'view' }) }); } catch (error) { console.error('Failed to track access:', error); } setSelectedVideo(video); setIsPlayerOpen(true); }; const handleClosePlayer = () => { setIsPlayerOpen(false); setSelectedVideo(null); }; const handleRefresh = () => { fetchRecommendations(); }; return (
{/* Header */}

Surprise Me

Discover videos from your collection

{/* Controls */}
{ALGORITHMS.find(a => a.value === algorithm)?.description}
{/* Recommendations Grid */}
{isLoading ? (

Loading recommendations...

) : recommendations.length === 0 ? (

No recommendations available

Try a different algorithm

) : (
{recommendations.map((video) => ( handleVideoClick(video)} /> ))}
)}
{/* Video Player */} {selectedVideo && ( )}
); } ``` ### Video Recommendation Card Component ```typescript // Component within the same file or separate interface VideoRecommendationCardProps { video: any; onClick: () => void; } function VideoRecommendationCard({ video, onClick }: VideoRecommendationCardProps) { const getBadgeColor = (reason: string) => { if (reason.includes('bookmark')) return 'bg-blue-600'; if (reason.includes('rated')) return 'bg-yellow-600'; if (reason.includes('Never')) return 'bg-teal-600'; if (reason.includes('folder')) return 'bg-orange-600'; return 'bg-purple-600'; }; return (
{/* Thumbnail */}
{video.thumbnail ? ( {video.title} ) : (
)} {/* Recommendation Badge */} {video.recommendation_reason && (
{video.recommendation_reason}
)}
{/* Info */}

{video.title || video.path.split('/').pop()}

{video.avg_rating > 0 && (
{video.avg_rating.toFixed(1)}
)} {video.bookmark_count > 0 && ( 🔖 )}
); } ``` ### Sidebar Integration ```typescript // src/components/sidebar.tsx - Add this item import { Sparkles } from 'lucide-react'; // ... inside the sidebar component {!isCollapsed && Surprise Me} ``` --- ## Utility Functions ### Format File Size ```typescript export function formatFileSize(bytes: number): string { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } ``` ### Calculate Days Since ```typescript export function daysSince(date: string | Date): number { const then = new Date(date); const now = new Date(); const diffTime = Math.abs(now.getTime() - then.getTime()); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); return diffDays; } ``` ### Get Recommendation Badge ```typescript export function getRecommendationBadge(reason: string): { icon: string; color: string; label: string; } { const badges: Record = { bookmark: { icon: '🔖', color: 'blue', label: 'Bookmarked' }, rated: { icon: '⭐', color: 'yellow', label: 'Rated' }, never: { icon: '🎯', color: 'teal', label: 'Unwatched' }, folder: { icon: '📁', color: 'orange', label: 'From Favorites' }, random: { icon: '🎲', color: 'purple', label: 'Random' }, new: { icon: '✨', color: 'green', label: 'New' } }; for (const [key, value] of Object.entries(badges)) { if (reason.toLowerCase().includes(key)) { return value; } } return { icon: '🎲', color: 'gray', label: 'Recommended' }; } ``` --- ## Testing Examples ### Unit Test for Recommendation Service ```typescript // __tests__/recommendation-service.test.ts import { RecommendationService } from '@/lib/recommendation-service'; describe('RecommendationService', () => { let service: RecommendationService; beforeEach(() => { service = new RecommendationService(); }); test('should return recommendations', async () => { const recommendations = await service.getRecommendations({ algorithm: 'smart_mix', limit: 10 }); expect(recommendations).toBeInstanceOf(Array); expect(recommendations.length).toBeLessThanOrEqual(10); }); test('should return unwatched videos first', async () => { const recommendations = await service.getRecommendations({ algorithm: 'unwatched_first', limit: 5 }); recommendations.forEach(video => { expect(video.recommendation_reason).toBe('Never watched before'); }); }); test('should exclude recently viewed videos', async () => { const recommendations = await service.getRecommendations({ algorithm: 'weighted_random', limit: 20, excludeRecentDays: 7 }); // Verify none are from the last 7 days // (would need access tracking test data) }); }); ``` ### API Route Test ```typescript // __tests__/api/recommendations.test.ts import { GET } from '@/app/api/videos/recommendations/route'; import { NextRequest } from 'next/server'; describe('Recommendations API', () => { test('should return recommendations with default algorithm', async () => { const request = new NextRequest('http://localhost/api/videos/recommendations'); const response = await GET(request); const data = await response.json(); expect(response.status).toBe(200); expect(data.algorithm).toBe('smart_mix'); expect(data.recommendations).toBeInstanceOf(Array); }); test('should respect limit parameter', async () => { const request = new NextRequest('http://localhost/api/videos/recommendations?limit=5'); const response = await GET(request); const data = await response.json(); expect(data.recommendations.length).toBeLessThanOrEqual(5); }); }); ``` --- ## Configuration Examples ### Environment Variables ```bash # .env.local # Recommendation settings RECOMMENDATION_DEFAULT_ALGORITHM=smart_mix RECOMMENDATION_DEFAULT_LIMIT=20 RECOMMENDATION_CACHE_TTL=300 RECOMMENDATION_EXCLUDE_RECENT_DAYS=30 ``` ### Feature Flags ```typescript // src/lib/feature-flags.ts export const FEATURE_FLAGS = { surpriseMe: { enabled: true, algorithms: { smartMix: true, weightedRandom: true, forgottenGems: true, similarToFavorites: true, timeBased: true, pureRandom: true, unwatchedFirst: true }, caching: true, accessTracking: true } }; ``` --- **Related Documentation**: - [Complete Design](SURPRISE_ME_RECOMMENDATION_DESIGN.md) - [Architecture Diagrams](SURPRISE_ME_ARCHITECTURE_DIAGRAM.md) - [Quick Summary](SURPRISE_ME_SUMMARY.md)