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:
tigeren 2025-10-12 12:29:34 +00:00
parent dd3eb91fbe
commit 5ed3640733
7 changed files with 542 additions and 6 deletions

Binary file not shown.

View File

@ -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();

View File

@ -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 }
);
}
}

View File

@ -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>
);
}

View File

@ -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 },
];

View File

@ -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;
}

View File

@ -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);
}
}