feat(media-access): add media access tracking endpoint and database support
- Implement POST /api/media-access/[id]/route to track media access events: view, play, bookmark, rate - Validate media ID and access type input with appropriate error responses - Persist media access records in new media_access table with associated media ID and timestamp - Add indexes on media_access for efficient querying by media ID, access type, and timestamp - Export helper functions to track and query media access counts and recent accesses - Add "Surprise Me" navigation link with icon in sidebar component for new discovery feature
This commit is contained in:
parent
dd3eb91fbe
commit
5ed3640733
BIN
data/media.db
BIN
data/media.db
Binary file not shown.
|
|
@ -1,18 +1,58 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDatabase } from '@/db';
|
||||
import { trackMediaAccess, getDatabase } from '@/db';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Universal Media Access API
|
||||
* Provides multiple streaming URLs and protocols for maximum compatibility
|
||||
*
|
||||
* Returns various streaming options:
|
||||
* - Direct HTTP streaming (optimized)
|
||||
* - External player streaming (enhanced range support)
|
||||
* - Protocol-specific URLs for different players
|
||||
* - Metadata and compatibility information
|
||||
* POST: Track media access for recommendations
|
||||
* GET: Get streaming options and media information
|
||||
*/
|
||||
|
||||
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. Must be one of: view, play, bookmark, rate' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
trackMediaAccess(mediaId, accessType);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
mediaId,
|
||||
accessType,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Access tracking error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to track access' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const db = getDatabase();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
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 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 }
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeRecentDays < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Exclude recent days must be non-negative' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const service = new RecommendationService();
|
||||
const recommendations = await service.getRecommendations({
|
||||
algorithm,
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
'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);
|
||||
};
|
||||
|
||||
const handleClosePlayer = () => {
|
||||
setIsPlayerOpen(false);
|
||||
setSelectedVideo(null);
|
||||
};
|
||||
|
||||
const handleBookmark = async (videoId: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => {
|
||||
try {
|
||||
await fetch(`/api/bookmarks/${videoId}`, { method: 'POST' });
|
||||
} catch (error) {
|
||||
console.error('Error bookmarking video:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnbookmark = async (videoId: number, bookmarkType?: 'media' | 'folder', folderPath?: string) => {
|
||||
try {
|
||||
await fetch(`/api/bookmarks/${videoId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
console.error('Error unbookmarking video:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRate = async (videoId: number, rating: number) => {
|
||||
try {
|
||||
if (rating === 0) {
|
||||
// For unstarring (rating = 0), delete the existing rating
|
||||
await fetch(`/api/stars`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mediaId: videoId })
|
||||
});
|
||||
} else {
|
||||
// For setting/updating a rating
|
||||
await fetch(`/api/stars`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mediaId: videoId, rating })
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rating video:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{/* Stats */}
|
||||
{!isLoading && recommendations.length > 0 && (
|
||||
<div className="mt-4 text-sm text-zinc-500">
|
||||
Found {recommendations.length} recommendations using {ALGORITHMS.find(a => a.value === algorithm)?.label}
|
||||
</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 or add more videos to your library</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 hover:transform hover:scale-[1.02]"
|
||||
>
|
||||
<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-110 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
<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/90 backdrop-blur-sm">
|
||||
{video.recommendation_reason}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="w-12 h-12 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
|
||||
<Sparkles className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="text-sm font-medium text-white truncate mb-1">
|
||||
{video.title || video.path.split('/').pop()}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{video.avg_rating > 0 && (
|
||||
<span className="text-xs text-yellow-500 flex items-center gap-1">
|
||||
⭐ {video.avg_rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{video.bookmark_count > 0 && (
|
||||
<span className="text-xs text-blue-400">🔖</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{formatFileSize(video.size)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Video Player */}
|
||||
{selectedVideo && (
|
||||
<UnifiedVideoPlayer
|
||||
video={selectedVideo}
|
||||
isOpen={isPlayerOpen}
|
||||
onClose={handleClosePlayer}
|
||||
playerType="modal"
|
||||
useArtPlayer={true}
|
||||
showBookmarks={true}
|
||||
showRatings={true}
|
||||
formatFileSize={formatFileSize}
|
||||
onBookmark={handleBookmark}
|
||||
onUnbookmark={handleUnbookmark}
|
||||
onRate={handleRate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
FolderOpen,
|
||||
Bookmark,
|
||||
Database,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { Cluster } from '@/db';
|
||||
import Link from "next/link";
|
||||
|
|
@ -83,6 +84,7 @@ const SidebarContent = () => {
|
|||
{ href: "/videos", label: "Videos", icon: Film },
|
||||
{ href: "/photos", label: "Photos", icon: ImageIcon },
|
||||
{ href: "/bookmarks", label: "Bookmarks", icon: Bookmark },
|
||||
{ href: "/surprise-me", label: "Surprise Me", icon: Sparkles },
|
||||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -129,6 +129,17 @@ function initializeDatabase() {
|
|||
);
|
||||
`);
|
||||
|
||||
// Create media access tracking table for recommendations
|
||||
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 for performance
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_media_id ON bookmarks(media_id);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_stars_media_id ON stars(media_id);`);
|
||||
|
|
@ -150,6 +161,11 @@ function initializeDatabase() {
|
|||
db.exec(`CREATE INDEX IF NOT EXISTS idx_library_cluster_mapping_cluster ON library_cluster_mapping(cluster_id);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_clusters_name ON clusters(name);`);
|
||||
|
||||
// Media access indexes for recommendations
|
||||
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);`);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
|
|
@ -203,3 +219,44 @@ export function getFolderBookmarks(limit: number = 50, offset: number = 0) {
|
|||
|
||||
// For backward compatibility, export the database instance getter
|
||||
export default getDatabase;
|
||||
|
||||
// Helper functions for media access tracking
|
||||
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 getRecentlyAccessedMedia(limit: number = 50): any[] {
|
||||
const db = getDatabase();
|
||||
return db.prepare(`
|
||||
SELECT DISTINCT m.*, ma.created_at as last_access
|
||||
FROM media m
|
||||
JOIN media_access ma ON m.id = ma.media_id
|
||||
WHERE m.type = 'video'
|
||||
ORDER BY ma.created_at DESC
|
||||
LIMIT ?
|
||||
`).all(limit);
|
||||
}
|
||||
|
||||
export function getMediaAccessCount(mediaId: number, accessType?: string): number {
|
||||
const db = getDatabase();
|
||||
|
||||
if (accessType) {
|
||||
const result = db.prepare(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM media_access
|
||||
WHERE media_id = ? AND access_type = ?
|
||||
`).get(mediaId, accessType) as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM media_access
|
||||
WHERE media_id = ?
|
||||
`).get(mediaId) as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
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 = 'unwatched_first',
|
||||
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.getUnwatchedFirstRecommendations(limit);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: Start with simple algorithms first
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private getPureRandomRecommendations(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 getWeightedRandomRecommendations(limit: number, excludeRecentDays: 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', '-' || ? || ' days'))
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?
|
||||
`;
|
||||
|
||||
return this.db.prepare(query).all(excludeRecentDays, limit);
|
||||
}
|
||||
|
||||
// Placeholder implementations for advanced algorithms (Phase 2)
|
||||
|
||||
private getForgottenGemsRecommendations(limit: number, excludeRecentDays: number): RecommendedVideo[] {
|
||||
// TODO: Implement in Phase 2
|
||||
return this.getUnwatchedFirstRecommendations(limit);
|
||||
}
|
||||
|
||||
private getSimilarToFavoritesRecommendations(limit: number, excludeRecentDays: number): RecommendedVideo[] {
|
||||
// TODO: Implement in Phase 2
|
||||
return this.getPureRandomRecommendations(limit);
|
||||
}
|
||||
|
||||
private getTimeBasedRecommendations(limit: number, excludeRecentDays: number): RecommendedVideo[] {
|
||||
// TODO: Implement in Phase 2
|
||||
return this.getWeightedRandomRecommendations(limit, excludeRecentDays);
|
||||
}
|
||||
|
||||
private getSmartMixRecommendations(limit: number, excludeRecentDays: number): RecommendedVideo[] {
|
||||
// TODO: Implement in Phase 2 - will combine multiple algorithms
|
||||
return this.getUnwatchedFirstRecommendations(limit);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue