nextav/docs/active/recommendations/SURPRISE_ME_QUICKSTART.md

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:

  1. Import the icon:
import { Sparkles } from 'lucide-react';
  1. 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:

  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


Estimated Total Time: 2-3 hours for basic implementation

Difficulty: Intermediate

Prerequisites:

  • Next.js knowledge
  • SQLite/SQL basics
  • React hooks understanding
  • TypeScript familiarity