24 KiB
24 KiB
Surprise Me - Implementation Code Examples
This document provides code examples and snippets for implementing the Surprise Me feature.
Table of Contents
Database Migration
Add media_access Table
// 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
// 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
// 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<RecommendedVideo[]> {
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
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
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
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
private async getSmartMixRecommendations(
limit: number,
excludeRecentDays: number
): Promise<RecommendedVideo[]> {
// 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<number, RecommendedVideo>();
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
// 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
// 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
// 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<Algorithm>('smart_mix');
const [recommendations, setRecommendations] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedVideo, setSelectedVideo] = useState<any>(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 (
<div className="min-h-screen bg-zinc-950">
{/* Header */}
<div className="bg-gradient-to-r from-purple-900/20 to-pink-900/20 border-b border-zinc-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-gradient-to-br from-purple-600 to-pink-600 rounded-2xl flex items-center justify-center shadow-lg">
<Sparkles className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">Surprise Me</h1>
<p className="text-zinc-400">Discover videos from your collection</p>
</div>
</div>
{/* Controls */}
<div className="flex flex-wrap items-center gap-4">
<select
value={algorithm}
onChange={(e) => setAlgorithm(e.target.value as Algorithm)}
className="px-4 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-white"
>
{ALGORITHMS.map(algo => (
<option key={algo.value} value={algo.value}>
{algo.label}
</option>
))}
</select>
<Button
onClick={handleRefresh}
disabled={isLoading}
className="flex items-center gap-2"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<div className="text-sm text-zinc-400">
{ALGORITHMS.find(a => a.value === algorithm)?.description}
</div>
</div>
</div>
</div>
{/* Recommendations Grid */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{isLoading ? (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600 mx-auto mb-4"></div>
<p className="text-zinc-400">Loading recommendations...</p>
</div>
</div>
) : recommendations.length === 0 ? (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<Sparkles className="h-16 w-16 text-zinc-700 mx-auto mb-4" />
<p className="text-zinc-400">No recommendations available</p>
<p className="text-sm text-zinc-500 mt-2">Try a different algorithm</p>
</div>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{recommendations.map((video) => (
<VideoRecommendationCard
key={video.id}
video={video}
onClick={() => handleVideoClick(video)}
/>
))}
</div>
)}
</div>
{/* Video Player */}
{selectedVideo && (
<UnifiedVideoPlayer
video={selectedVideo}
isOpen={isPlayerOpen}
onClose={handleClosePlayer}
playerType="modal"
useArtPlayer={true}
/>
)}
</div>
);
}
Video Recommendation Card Component
// 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 (
<div
onClick={onClick}
className="group cursor-pointer bg-zinc-900 rounded-lg overflow-hidden border border-zinc-800 hover:border-purple-600 transition-all"
>
{/* Thumbnail */}
<div className="aspect-video bg-zinc-800 relative overflow-hidden">
{video.thumbnail ? (
<img
src={video.thumbnail}
alt={video.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Sparkles className="h-8 w-8 text-zinc-600" />
</div>
)}
{/* Recommendation Badge */}
{video.recommendation_reason && (
<div className={`absolute top-2 left-2 px-2 py-1 rounded text-xs font-medium text-white ${getBadgeColor(video.recommendation_reason)}`}>
{video.recommendation_reason}
</div>
)}
</div>
{/* Info */}
<div className="p-3">
<h3 className="text-sm font-medium text-white truncate">
{video.title || video.path.split('/').pop()}
</h3>
<div className="flex items-center gap-2 mt-2">
{video.avg_rating > 0 && (
<div className="flex items-center gap-1">
<span className="text-yellow-500">⭐</span>
<span className="text-xs text-zinc-400">{video.avg_rating.toFixed(1)}</span>
</div>
)}
{video.bookmark_count > 0 && (
<span className="text-xs text-blue-400">🔖</span>
)}
</div>
</div>
</div>
);
}
Sidebar Integration
// src/components/sidebar.tsx - Add this item
import { Sparkles } from 'lucide-react';
// ... inside the sidebar component
<Link
href="/surprise-me"
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg transition-colors",
pathname === "/surprise-me"
? "bg-purple-600 text-white"
: "text-zinc-400 hover:text-white hover:bg-zinc-800"
)}
>
<Sparkles className="h-5 w-5" />
{!isCollapsed && <span>Surprise Me</span>}
</Link>
Utility Functions
Format File Size
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
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
export function getRecommendationBadge(reason: string): {
icon: string;
color: string;
label: string;
} {
const badges: Record<string, any> = {
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
// __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
// __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
# .env.local
# Recommendation settings
RECOMMENDATION_DEFAULT_ALGORITHM=smart_mix
RECOMMENDATION_DEFAULT_LIMIT=20
RECOMMENDATION_CACHE_TTL=300
RECOMMENDATION_EXCLUDE_RECENT_DAYS=30
Feature Flags
// 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: