913 lines
24 KiB
Markdown
913 lines
24 KiB
Markdown
# 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<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
|
|
|
|
```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<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
|
|
|
|
```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<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
|
|
|
|
```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 (
|
|
<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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```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<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
|
|
|
|
```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)
|