16 KiB
Surprise Me - Quick Start Implementation Guide
This guide will help you implement the Surprise Me feature step by step.
📋 Pre-Implementation Checklist
Before starting, ensure you have:
- ✅ Next.js project set up
- ✅ SQLite database configured
- ✅ Existing video browsing functionality
- ✅ Understanding of the codebase structure
🚀 Implementation Steps
Phase 1: Database Setup (15 minutes)
Step 1.1: Add Media Access Table
Edit /src/db/index.ts and add the new table in the initializeDatabase() function:
// Add after the library_cluster_mapping table creation
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
);
`);
Step 1.2: Add Indexes
// Add with other 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);`);
Step 1.3: Add Helper Functions
At the end of /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);
}
Test: Run your app and check that the database initializes without errors.
Phase 2: Recommendation Service (30 minutes)
Step 2.1: Create Recommendation Service File
Create /src/lib/recommendation-service.ts:
import { getDatabase } from '@/db';
export type RecommendationAlgorithm =
| 'smart_mix'
| 'weighted_random'
| 'forgotten_gems'
| 'unwatched_first'
| 'pure_random';
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 class RecommendationService {
private db: any;
constructor() {
this.db = getDatabase();
}
async getRecommendations(
algorithm: RecommendationAlgorithm = 'smart_mix',
limit: number = 20
): Promise<RecommendedVideo[]> {
switch (algorithm) {
case 'unwatched_first':
return this.getUnwatchedFirst(limit);
case 'pure_random':
return this.getPureRandom(limit);
case 'weighted_random':
return this.getWeightedRandom(limit);
default:
return this.getUnwatchedFirst(limit); // Start simple
}
}
private getUnwatchedFirst(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);
}
private getPureRandom(limit: number): RecommendedVideo[] {
const query = `
SELECT m.*,
'Random selection' as recommendation_reason,
1.0 as recommendation_score
FROM media m
WHERE m.type = 'video'
ORDER BY RANDOM()
LIMIT ?
`;
return this.db.prepare(query).all(limit);
}
private getWeightedRandom(limit: number): RecommendedVideo[] {
const query = `
SELECT m.*,
'Smart random selection' as recommendation_reason,
1.0 as recommendation_score
FROM media m
LEFT JOIN media_access ma ON m.id = ma.media_id AND ma.access_type IN ('view', 'play')
WHERE m.type = 'video'
AND (ma.id IS NULL OR ma.created_at < datetime('now', '-30 days'))
ORDER BY RANDOM()
LIMIT ?
`;
return this.db.prepare(query).all(limit);
}
}
Test: Import and instantiate the service in a test file to verify it compiles.
Phase 3: API Endpoints (20 minutes)
Step 3.1: Create Recommendations API
Create /src/app/api/videos/recommendations/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { RecommendationService, RecommendationAlgorithm } from '@/lib/recommendation-service';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const algorithm = (searchParams.get('algorithm') || 'unwatched_first') as RecommendationAlgorithm;
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
const service = new RecommendationService();
const recommendations = await service.getRecommendations(algorithm, limit);
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 }
);
}
}
Test: Visit http://localhost:3000/api/videos/recommendations in your browser.
Step 3.2: Create Media Access Tracking API
Create /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 }
);
}
}
Test: Use Postman or curl to POST to the endpoint.
Phase 4: Sidebar Integration (10 minutes)
Step 4.1: Add Surprise Me to Sidebar
Edit /src/components/sidebar.tsx:
- Import the icon:
import { Sparkles } from 'lucide-react';
- Add the menu item (after Bookmarks, before Clusters):
<Link
href="/surprise-me"
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg transition-colors",
pathname === "/surprise-me"
? "bg-gradient-to-r from-purple-600 to-pink-600 text-white"
: "text-zinc-400 hover:text-white hover:bg-zinc-800"
)}
>
<Sparkles className="h-5 w-5 flex-shrink-0" />
{!isCollapsed && <span>Surprise Me</span>}
</Link>
Test: Check that the sidebar shows the new item and it's clickable.
Phase 5: Surprise Me Page (45 minutes)
Step 5.1: Create the Page
Create /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 UnifiedVideoPlayer from '@/components/unified-video-player';
type Algorithm = 'unwatched_first' | 'pure_random' | 'weighted_random';
const ALGORITHMS = [
{ value: 'unwatched_first', label: 'Unwatched First', description: 'Videos you\'ve never seen' },
{ value: 'weighted_random', label: 'Weighted Random', description: 'Smart randomization' },
{ value: 'pure_random', label: 'Pure Random', description: 'Complete surprise' }
] as const;
export default function SurpriseMePage() {
const [algorithm, setAlgorithm] = useState<Algorithm>('unwatched_first');
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) => {
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);
};
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={fetchRecommendations}
disabled={isLoading}
className="flex items-center gap-2 bg-purple-600 hover:bg-purple-700"
>
<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) => (
<div
key={video.id}
onClick={() => handleVideoClick(video)}
className="group cursor-pointer bg-zinc-900 rounded-lg overflow-hidden border border-zinc-800 hover:border-purple-600 transition-all"
>
<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>
)}
{video.recommendation_reason && (
<div className="absolute top-2 left-2 px-2 py-1 rounded text-xs font-medium text-white bg-purple-600">
{video.recommendation_reason}
</div>
)}
</div>
<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 && (
<span className="text-xs text-yellow-500">⭐ {video.avg_rating.toFixed(1)}</span>
)}
{video.bookmark_count > 0 && (
<span className="text-xs text-blue-400">🔖</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Video Player */}
{selectedVideo && (
<UnifiedVideoPlayer
video={selectedVideo}
isOpen={isPlayerOpen}
onClose={() => {
setIsPlayerOpen(false);
setSelectedVideo(null);
}}
playerType="modal"
useArtPlayer={true}
/>
)}
</div>
);
}
Test: Navigate to /surprise-me and verify the page loads.
✅ Testing Checklist
Manual Testing
- Database table created successfully
- Sidebar shows "Surprise Me" item
- Clicking sidebar item navigates to
/surprise-me - Page loads without errors
- Algorithm dropdown works
- Refresh button works
- Video cards display correctly
- Clicking video opens player
- Player closes correctly
- Different algorithms show different results
- Access tracking works (check database)
API Testing
# Test recommendations endpoint
curl http://localhost:3000/api/videos/recommendations
# Test with algorithm
curl http://localhost:3000/api/videos/recommendations?algorithm=pure_random&limit=10
# Test access tracking
curl -X POST http://localhost:3000/api/media-access/1 \
-H "Content-Type: application/json" \
-d '{"accessType": "view"}'
🐛 Common Issues & Solutions
Issue: No recommendations showing
Solution: Check if you have videos in the database. Run:
SELECT COUNT(*) FROM media WHERE type = 'video';
Issue: Database error on startup
Solution: Delete data/media.db and restart to recreate schema.
Issue: API returns 500 error
Solution: Check server logs for detailed error messages.
Issue: Sidebar item not showing
Solution: Verify you imported Sparkles icon and added the Link component correctly.
🎯 Next Steps
After basic implementation:
- Add more algorithms (Forgotten Gems, Similar to Favorites)
- Improve UI with animations and transitions
- Add statistics (total unwatched, most recommended, etc.)
- Implement caching for better performance
- Add user preferences for default algorithm
📚 Additional Resources
Estimated Total Time: 2-3 hours for basic implementation
Difficulty: Intermediate
Prerequisites:
- Next.js knowledge
- SQLite/SQL basics
- React hooks understanding
- TypeScript familiarity