567 lines
16 KiB
Markdown
567 lines
16 KiB
Markdown
# 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:
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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`:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
1. Import the icon:
|
|
```typescript
|
|
import { Sparkles } from 'lucide-react';
|
|
```
|
|
|
|
2. Add the menu item (after Bookmarks, before Clusters):
|
|
```typescript
|
|
<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`:
|
|
|
|
```typescript
|
|
'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
|
|
```bash
|
|
# 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:
|
|
```sql
|
|
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:
|
|
1. Add more algorithms (Forgotten Gems, Similar to Favorites)
|
|
2. Improve UI with animations and transitions
|
|
3. Add statistics (total unwatched, most recommended, etc.)
|
|
4. Implement caching for better performance
|
|
5. Add user preferences for default algorithm
|
|
|
|
---
|
|
|
|
## 📚 Additional Resources
|
|
|
|
- [Complete Design Document](SURPRISE_ME_RECOMMENDATION_DESIGN.md)
|
|
- [Architecture Diagrams](SURPRISE_ME_ARCHITECTURE_DIAGRAM.md)
|
|
- [Implementation Examples](SURPRISE_ME_IMPLEMENTATION_EXAMPLES.md)
|
|
- [Quick Summary](SURPRISE_ME_SUMMARY.md)
|
|
|
|
---
|
|
|
|
**Estimated Total Time**: 2-3 hours for basic implementation
|
|
|
|
**Difficulty**: Intermediate
|
|
|
|
**Prerequisites**:
|
|
- Next.js knowledge
|
|
- SQLite/SQL basics
|
|
- React hooks understanding
|
|
- TypeScript familiarity
|