nextav/docs/active/recommendations/SURPRISE_ME_IMPLEMENTATION_...

24 KiB

Surprise Me - Implementation Code Examples

This document provides code examples and snippets for implementing the Surprise Me feature.


Table of Contents

  1. Database Migration
  2. Recommendation Service
  3. API Routes
  4. UI Components
  5. Utility Functions

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: