docs(readme): add Surprise Me video recommendation feature documentation
- Introduce "Surprise Me" as a new video recommendation system for personal video discovery - Add detailed quick links including guides, summaries, design, architecture, and implementation examples - Reorganize documentation overview to include Surprise Me above Library Cluster feature - Update documentation statistics reflecting increased total documents and size - Update current project focus including Surprise Me as top priority - Add new documentation entry for Surprise Me on recent changes list - Bump documentation version to 1.1 and update last modified date accordingly docs(archive): update Docker image tag for build and push commands - Change Docker image tag from version 1.3 to 1.4 in deployment instructions
This commit is contained in:
parent
a31973ea46
commit
dd3eb91fbe
|
|
@ -6,7 +6,20 @@ Welcome to the NextAV documentation repository. This directory contains comprehe
|
|||
|
||||
## 📚 Documentation Overview
|
||||
|
||||
### 🆕 Latest: Library Cluster Feature
|
||||
### 🆕 Latest: Surprise Me - Video Recommendation Feature
|
||||
|
||||
The **Surprise Me** feature is a lightweight video recommendation system that helps you rediscover videos in your personal collection through smart algorithms.
|
||||
|
||||
**Start Here**: 📑 [Surprise Me Documentation Index](SURPRISE_ME_INDEX.md)
|
||||
|
||||
**Quick Links**:
|
||||
- ⚡ [Quick Start Guide](SURPRISE_ME_QUICKSTART.md) - **Fastest way to implement**
|
||||
- 📋 [Quick Summary](SURPRISE_ME_SUMMARY.md) - Overview and key concepts
|
||||
- 📖 [Complete Design](SURPRISE_ME_RECOMMENDATION_DESIGN.md) - Full specification
|
||||
- 🏗️ [Architecture Diagrams](SURPRISE_ME_ARCHITECTURE_DIAGRAM.md) - Visual system design
|
||||
- 💻 [Implementation Examples](SURPRISE_ME_IMPLEMENTATION_EXAMPLES.md) - Code snippets
|
||||
|
||||
### Library Cluster Feature
|
||||
|
||||
The **Library Cluster** feature allows you to group multiple libraries that contain similar content for better organization and unified access.
|
||||
|
||||
|
|
@ -25,7 +38,16 @@ The **Library Cluster** feature allows you to group multiple libraries that cont
|
|||
|
||||
### Features & Enhancements
|
||||
|
||||
#### Library Cluster Feature (NEW)
|
||||
#### Surprise Me - Video Recommendation (NEW)
|
||||
Lightweight recommendation system for personal video discovery.
|
||||
- 📑 [Documentation Index](SURPRISE_ME_INDEX.md) - **Complete navigation hub**
|
||||
- ⚡ [Quick Start Guide](SURPRISE_ME_QUICKSTART.md) - **Start here for implementation**
|
||||
- 📋 [Quick Summary](SURPRISE_ME_SUMMARY.md) - Overview and concepts
|
||||
- 📖 [Complete Design](SURPRISE_ME_RECOMMENDATION_DESIGN.md) - Full specification
|
||||
- 🏗️ [Architecture Diagrams](SURPRISE_ME_ARCHITECTURE_DIAGRAM.md) - Visual system design
|
||||
- 💻 [Implementation Examples](SURPRISE_ME_IMPLEMENTATION_EXAMPLES.md) - Code snippets
|
||||
|
||||
#### Library Cluster Feature
|
||||
Group and organize libraries by content category.
|
||||
- [Index](LIBRARY_CLUSTER_INDEX.md)
|
||||
- [Summary](LIBRARY_CLUSTER_SUMMARY.md)
|
||||
|
|
@ -173,6 +195,7 @@ Older documentation and historical records are stored in the [`archive/`](archiv
|
|||
|
||||
| Topic | Key Documents |
|
||||
|-------|--------------|
|
||||
| **Video Discovery** | Surprise Me Summary, Surprise Me Design |
|
||||
| **Library Management** | Library Cluster Feature docs |
|
||||
| **Video Playback** | ArtPlayer Enhancement, Format Compatibility |
|
||||
| **Database** | Migration Guide, Migration README |
|
||||
|
|
@ -192,10 +215,10 @@ Older documentation and historical records are stored in the [`archive/`](archiv
|
|||
|
||||
## 📊 Documentation Statistics
|
||||
|
||||
- **Total Documents**: 17+ active documents
|
||||
- **Total Size**: ~250 KB
|
||||
- **Total Documents**: 23+ active documents
|
||||
- **Total Size**: ~550 KB
|
||||
- **Categories**: 5 main categories
|
||||
- **Latest Update**: 2025-10-11
|
||||
- **Latest Update**: 2025-10-12
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -223,9 +246,10 @@ Please create an issue or update the documentation directly.
|
|||
## 🎯 Current Focus
|
||||
|
||||
The project is currently focused on:
|
||||
1. **Library Cluster Feature** - New feature for library organization
|
||||
2. **Video Playback Optimization** - Improving compatibility and performance
|
||||
3. **Database Migration** - Ongoing schema improvements
|
||||
1. **Surprise Me Feature** - Video recommendation system for personal discovery
|
||||
2. **Library Cluster Feature** - Library organization and grouping
|
||||
3. **Video Playback Optimization** - Improving compatibility and performance
|
||||
4. **Database Migration** - Ongoing schema improvements
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -233,6 +257,7 @@ The project is currently focused on:
|
|||
|
||||
| Date | Document | Change |
|
||||
|------|----------|--------|
|
||||
| 2025-10-12 | Surprise Me docs | Initial documentation for recommendation feature |
|
||||
| 2025-10-11 | Library Cluster docs | Initial documentation package created |
|
||||
| [Previous] | Various | See individual documents for history |
|
||||
|
||||
|
|
@ -246,6 +271,6 @@ The project is currently focused on:
|
|||
|
||||
---
|
||||
|
||||
**Documentation Version**: 1.0
|
||||
**Last Updated**: 2025-10-11
|
||||
**Documentation Version**: 1.1
|
||||
**Last Updated**: 2025-10-12
|
||||
**Maintainer**: Development Team
|
||||
|
|
|
|||
|
|
@ -0,0 +1,385 @@
|
|||
# Surprise Me - Architecture Diagrams
|
||||
|
||||
## System Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
User[User] --> Sidebar[Sidebar Navigation]
|
||||
Sidebar --> SurpriseMePage[Surprise Me Page]
|
||||
|
||||
SurpriseMePage --> AlgorithmSelector[Algorithm Selector]
|
||||
SurpriseMePage --> RecommendationGrid[Recommendation Grid]
|
||||
SurpriseMePage --> RefreshButton[Refresh Button]
|
||||
|
||||
AlgorithmSelector --> API[/api/videos/recommendations]
|
||||
RefreshButton --> API
|
||||
|
||||
API --> RecService[Recommendation Service]
|
||||
|
||||
RecService --> SmartMix[Smart Mix Algorithm]
|
||||
RecService --> WeightedRandom[Weighted Random]
|
||||
RecService --> ForgottenGems[Forgotten Gems]
|
||||
RecService --> SimilarFavorites[Similar to Favorites]
|
||||
RecService --> TimeBased[Time-Based]
|
||||
RecService --> PureRandom[Pure Random]
|
||||
RecService --> UnwatchedFirst[Unwatched First]
|
||||
|
||||
SmartMix --> Database[(SQLite Database)]
|
||||
WeightedRandom --> Database
|
||||
ForgottenGems --> Database
|
||||
SimilarFavorites --> Database
|
||||
TimeBased --> Database
|
||||
PureRandom --> Database
|
||||
UnwatchedFirst --> Database
|
||||
|
||||
Database --> MediaTable[media table]
|
||||
Database --> AccessTable[media_access table]
|
||||
Database --> BookmarksTable[bookmarks table]
|
||||
Database --> StarsTable[stars table]
|
||||
|
||||
RecommendationGrid --> VideoCard[Video Cards]
|
||||
VideoCard --> VideoPlayer[Unified Video Player]
|
||||
VideoCard --> AccessTracking[Track Access API]
|
||||
|
||||
AccessTracking --> Database
|
||||
```
|
||||
|
||||
## Data Flow Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant SurpriseMePage
|
||||
participant API
|
||||
participant RecService
|
||||
participant Database
|
||||
participant VideoPlayer
|
||||
|
||||
User->>SurpriseMePage: Click "Surprise Me" in sidebar
|
||||
SurpriseMePage->>API: GET /api/videos/recommendations?algorithm=smart_mix
|
||||
API->>RecService: getRecommendations('smart_mix', 20)
|
||||
|
||||
RecService->>Database: Query media table
|
||||
RecService->>Database: Query media_access table
|
||||
RecService->>Database: Query bookmarks & stars
|
||||
|
||||
Database-->>RecService: Return matching videos
|
||||
RecService->>RecService: Apply algorithm logic
|
||||
RecService->>RecService: Score and rank videos
|
||||
RecService-->>API: Return top 20 videos
|
||||
API-->>SurpriseMePage: JSON response with videos
|
||||
|
||||
SurpriseMePage->>User: Display 20 recommended videos
|
||||
|
||||
User->>VideoCard: Click video to watch
|
||||
VideoCard->>VideoPlayer: Open player with video
|
||||
VideoCard->>API: POST /api/media-access/:id {type: "view"}
|
||||
API->>Database: INSERT into media_access
|
||||
Database-->>API: Success
|
||||
|
||||
User->>VideoPlayer: Play video
|
||||
VideoPlayer->>API: POST /api/media-access/:id {type: "play"}
|
||||
API->>Database: INSERT into media_access
|
||||
```
|
||||
|
||||
## Database Schema Diagram
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
MEDIA ||--o{ MEDIA_ACCESS : tracks
|
||||
MEDIA ||--o{ BOOKMARKS : has
|
||||
MEDIA ||--o{ STARS : has
|
||||
MEDIA }o--|| LIBRARIES : belongs_to
|
||||
|
||||
MEDIA {
|
||||
int id PK
|
||||
int library_id FK
|
||||
string path
|
||||
string type
|
||||
string title
|
||||
int size
|
||||
string thumbnail
|
||||
int bookmark_count
|
||||
int star_count
|
||||
float avg_rating
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
MEDIA_ACCESS {
|
||||
int id PK
|
||||
int media_id FK
|
||||
string access_type
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
BOOKMARKS {
|
||||
int id PK
|
||||
int media_id FK
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
STARS {
|
||||
int id PK
|
||||
int media_id FK
|
||||
int rating
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
LIBRARIES {
|
||||
int id PK
|
||||
string path
|
||||
}
|
||||
```
|
||||
|
||||
## Algorithm Decision Flow
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Start[User Opens Surprise Me] --> CheckAlgorithm{Algorithm Selected?}
|
||||
|
||||
CheckAlgorithm -->|Default| SmartMix[Smart Mix Algorithm]
|
||||
CheckAlgorithm -->|User Selected| SelectedAlg[Selected Algorithm]
|
||||
|
||||
SmartMix --> DistributeLoad[Distribute Load]
|
||||
DistributeLoad --> WR[30% Weighted Random]
|
||||
DistributeLoad --> FG[25% Forgotten Gems]
|
||||
DistributeLoad --> SF[20% Similar Favorites]
|
||||
DistributeLoad --> UF[15% Unwatched First]
|
||||
DistributeLoad --> TB[10% Time-Based]
|
||||
|
||||
WR --> Combine[Combine & Shuffle]
|
||||
FG --> Combine
|
||||
SF --> Combine
|
||||
UF --> Combine
|
||||
TB --> Combine
|
||||
|
||||
SelectedAlg --> SingleAlg[Run Single Algorithm]
|
||||
SingleAlg --> Combine
|
||||
|
||||
Combine --> Dedupe[Remove Duplicates]
|
||||
Dedupe --> LimitResults[Limit to 20 videos]
|
||||
LimitResults --> Display[Display to User]
|
||||
```
|
||||
|
||||
## Weighted Random Scoring Flow
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Video[Video] --> BaseScore[Base Score: Random 0.5-1.0]
|
||||
|
||||
BaseScore --> CheckAccess{Ever Accessed?}
|
||||
CheckAccess -->|Yes| CalcRecency[Calculate Days Since View]
|
||||
CheckAccess -->|No| MaxRecency[Recency Multiplier = 1.0]
|
||||
|
||||
CalcRecency --> RecencyMult[Recency Multiplier:<br/>min 1.0, days/90]
|
||||
|
||||
RecencyMult --> ApplyRecency[Score × Recency]
|
||||
MaxRecency --> ApplyRecency
|
||||
|
||||
ApplyRecency --> CheckRating{Has Rating?}
|
||||
CheckRating -->|Yes| RatingMult[Rating Multiplier:<br/>1.0 + rating/10]
|
||||
CheckRating -->|No| NoRating[Rating Multiplier = 1.0]
|
||||
|
||||
RatingMult --> ApplyRating[Score × Rating]
|
||||
NoRating --> ApplyRating
|
||||
|
||||
ApplyRating --> CheckDiversity{From Underrepresented<br/>Library?}
|
||||
CheckDiversity -->|Yes| DiversityBonus[Diversity Bonus:<br/>Score × 1.2]
|
||||
CheckDiversity -->|No| NoDiversity[No Bonus]
|
||||
|
||||
DiversityBonus --> FinalScore[Final Score]
|
||||
NoDiversity --> FinalScore
|
||||
|
||||
FinalScore --> Rank[Rank Against Others]
|
||||
```
|
||||
|
||||
## UI Component Hierarchy
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
App[App Layout] --> Sidebar[Sidebar]
|
||||
App --> MainArea[Main Content Area]
|
||||
|
||||
Sidebar --> SurpriseMenuItem[🎲 Surprise Me Item]
|
||||
|
||||
SurpriseMenuItem --> SurpriseMePage[Surprise Me Page]
|
||||
MainArea --> SurpriseMePage
|
||||
|
||||
SurpriseMePage --> Header[Page Header]
|
||||
SurpriseMePage --> Controls[Control Panel]
|
||||
SurpriseMePage --> Grid[Recommendation Grid]
|
||||
|
||||
Header --> Title[Title & Description]
|
||||
Header --> Stats[Statistics]
|
||||
|
||||
Controls --> AlgoDropdown[Algorithm Dropdown]
|
||||
Controls --> RefreshBtn[Refresh Button]
|
||||
Controls --> LimitSelector[Limit Selector]
|
||||
|
||||
Grid --> VirtualGrid[Virtualized Grid]
|
||||
VirtualGrid --> VideoCard1[Video Card 1]
|
||||
VirtualGrid --> VideoCard2[Video Card 2]
|
||||
VirtualGrid --> VideoCardN[Video Card N]
|
||||
|
||||
VideoCard1 --> Thumbnail[Thumbnail]
|
||||
VideoCard1 --> Info[Video Info]
|
||||
VideoCard1 --> Badge[Recommendation Badge]
|
||||
VideoCard1 --> Actions[Actions]
|
||||
|
||||
Actions --> PlayBtn[Play Button]
|
||||
Actions --> BookmarkBtn[Bookmark]
|
||||
Actions --> RatingStars[Star Rating]
|
||||
```
|
||||
|
||||
## State Management Flow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Initial
|
||||
Initial --> Loading: User clicks Surprise Me
|
||||
Loading --> Loaded: Recommendations received
|
||||
Loading --> Error: API Error
|
||||
|
||||
Error --> Loading: User retries
|
||||
|
||||
Loaded --> Refreshing: User clicks Refresh
|
||||
Loaded --> ChangingAlgorithm: User changes algorithm
|
||||
|
||||
Refreshing --> Loaded: New recommendations received
|
||||
Refreshing --> Error: API Error
|
||||
|
||||
ChangingAlgorithm --> Loading: Fetching with new algorithm
|
||||
|
||||
Loaded --> PlayingVideo: User clicks video
|
||||
PlayingVideo --> Loaded: User closes player
|
||||
PlayingVideo --> TrackingAccess: Video opened
|
||||
|
||||
TrackingAccess --> Loaded: Access tracked
|
||||
```
|
||||
|
||||
## Recommendation Algorithm Comparison
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Algorithm Characteristics"
|
||||
WR[Weighted Random<br/>Balanced & Fair]
|
||||
FG[Forgotten Gems<br/>Personal History]
|
||||
SF[Similar Favorites<br/>Folder-Based]
|
||||
TB[Time-Based<br/>Temporal Patterns]
|
||||
PR[Pure Random<br/>Complete Surprise]
|
||||
UF[Unwatched First<br/>New Content]
|
||||
SM[Smart Mix<br/>Best of All]
|
||||
end
|
||||
|
||||
subgraph "User Goals"
|
||||
Variety[Want Variety]
|
||||
Rediscover[Rediscover Old]
|
||||
Similar[Similar to Favorites]
|
||||
New[Explore New]
|
||||
Surprise[Complete Surprise]
|
||||
end
|
||||
|
||||
Variety --> WR
|
||||
Variety --> SM
|
||||
Rediscover --> FG
|
||||
Similar --> SF
|
||||
New --> UF
|
||||
New --> TB
|
||||
Surprise --> PR
|
||||
```
|
||||
|
||||
## Access Tracking Integration
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "User Actions"
|
||||
OpenPlayer[Open Video Player]
|
||||
StartPlayback[Start Playback]
|
||||
BookmarkVideo[Bookmark Video]
|
||||
RateVideo[Rate Video]
|
||||
end
|
||||
|
||||
subgraph "Tracking System"
|
||||
TrackView[Track: view]
|
||||
TrackPlay[Track: play]
|
||||
TrackBookmark[Track: bookmark]
|
||||
TrackRate[Track: rate]
|
||||
end
|
||||
|
||||
subgraph "Database"
|
||||
MediaAccessTable[media_access table]
|
||||
end
|
||||
|
||||
subgraph "Recommendation Engine"
|
||||
UseInAlgorithms[Use in Future Recommendations]
|
||||
end
|
||||
|
||||
OpenPlayer --> TrackView
|
||||
StartPlayback --> TrackPlay
|
||||
BookmarkVideo --> TrackBookmark
|
||||
RateVideo --> TrackRate
|
||||
|
||||
TrackView --> MediaAccessTable
|
||||
TrackPlay --> MediaAccessTable
|
||||
TrackBookmark --> MediaAccessTable
|
||||
TrackRate --> MediaAccessTable
|
||||
|
||||
MediaAccessTable --> UseInAlgorithms
|
||||
|
||||
UseInAlgorithms --> RecencyPenalty[Apply Recency Penalty]
|
||||
UseInAlgorithms --> IdentifyFavorites[Identify Favorite Patterns]
|
||||
UseInAlgorithms --> FindUnwatched[Find Unwatched Videos]
|
||||
```
|
||||
|
||||
## Performance Optimization Strategy
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
Request[Recommendation Request] --> CheckCache{Cache Hit?}
|
||||
|
||||
CheckCache -->|Yes| ReturnCached[Return Cached Results]
|
||||
CheckCache -->|No| QueryDB[Query Database]
|
||||
|
||||
QueryDB --> UseIndexes[Use Optimized Indexes]
|
||||
UseIndexes --> idx1[idx_media_access_created_at]
|
||||
UseIndexes --> idx2[idx_media_type_created_at]
|
||||
UseIndexes --> idx3[idx_media_bookmark_count]
|
||||
|
||||
idx1 --> ExecuteQuery[Execute Query]
|
||||
idx2 --> ExecuteQuery
|
||||
idx3 --> ExecuteQuery
|
||||
|
||||
ExecuteQuery --> LimitResults[LIMIT 20]
|
||||
LimitResults --> ScoreRank[Score & Rank]
|
||||
ScoreRank --> CacheResults[Cache for 5 minutes]
|
||||
|
||||
CacheResults --> ReturnResults[Return Results]
|
||||
ReturnCached --> ReturnResults
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Legend
|
||||
|
||||
### Diagram Types
|
||||
- **Architecture Overview**: High-level system components
|
||||
- **Data Flow**: Request/response sequences
|
||||
- **Database Schema**: Entity relationships
|
||||
- **Algorithm Flow**: Decision trees and logic
|
||||
- **Component Hierarchy**: UI structure
|
||||
- **State Management**: Application states
|
||||
- **Performance**: Optimization strategies
|
||||
|
||||
### Color Coding
|
||||
- **Blue**: User-facing components
|
||||
- **Green**: Backend services
|
||||
- **Yellow**: Database operations
|
||||
- **Purple**: Algorithm logic
|
||||
- **Orange**: Tracking/analytics
|
||||
|
||||
---
|
||||
|
||||
**Related Documentation**:
|
||||
- [Complete Design Document](SURPRISE_ME_RECOMMENDATION_DESIGN.md)
|
||||
- [Quick Summary](SURPRISE_ME_SUMMARY.md)
|
||||
- [Main Docs Index](README.md)
|
||||
|
|
@ -0,0 +1,912 @@
|
|||
# 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)
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
# Surprise Me Feature - Documentation Index
|
||||
|
||||
Welcome to the Surprise Me feature documentation! This page helps you navigate all the documentation for this feature.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 What You're Looking For
|
||||
|
||||
### I want to understand the feature quickly
|
||||
👉 **[Quick Summary](SURPRISE_ME_SUMMARY.md)** - 5-minute read with key concepts and benefits
|
||||
|
||||
### I want to implement the feature
|
||||
👉 **[Quick Start Guide](SURPRISE_ME_QUICKSTART.md)** - Step-by-step implementation (2-3 hours)
|
||||
|
||||
### I need the complete design specification
|
||||
👉 **[Complete Design Document](SURPRISE_ME_RECOMMENDATION_DESIGN.md)** - Full technical specification
|
||||
|
||||
### I want to see code examples
|
||||
👉 **[Implementation Examples](SURPRISE_ME_IMPLEMENTATION_EXAMPLES.md)** - Ready-to-use code snippets
|
||||
|
||||
### I need visual diagrams
|
||||
👉 **[Architecture Diagrams](SURPRISE_ME_ARCHITECTURE_DIAGRAM.md)** - System architecture and flows
|
||||
|
||||
---
|
||||
|
||||
## 📚 All Documentation
|
||||
|
||||
| Document | Purpose | Audience | Time to Read |
|
||||
|----------|---------|----------|--------------|
|
||||
| **[Quick Summary](SURPRISE_ME_SUMMARY.md)** | Feature overview | Everyone | 5 min |
|
||||
| **[Quick Start Guide](SURPRISE_ME_QUICKSTART.md)** | Implementation steps | Developers | 10 min |
|
||||
| **[Complete Design](SURPRISE_ME_RECOMMENDATION_DESIGN.md)** | Full specification | Developers, Architects | 30 min |
|
||||
| **[Architecture Diagrams](SURPRISE_ME_ARCHITECTURE_DIAGRAM.md)** | Visual system design | Developers, Architects | 15 min |
|
||||
| **[Implementation Examples](SURPRISE_ME_IMPLEMENTATION_EXAMPLES.md)** | Code snippets | Developers | 20 min |
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Learning Path
|
||||
|
||||
### For Product Owners / Managers
|
||||
1. Read [Quick Summary](SURPRISE_ME_SUMMARY.md)
|
||||
2. Review "Key Concept" and "Benefits" sections
|
||||
3. Check "Success Metrics" section
|
||||
4. Done! ✅
|
||||
|
||||
### For Developers (New to Project)
|
||||
1. Read [Quick Summary](SURPRISE_ME_SUMMARY.md)
|
||||
2. Study [Architecture Diagrams](SURPRISE_ME_ARCHITECTURE_DIAGRAM.md)
|
||||
3. Read [Complete Design](SURPRISE_ME_RECOMMENDATION_DESIGN.md)
|
||||
4. Follow [Quick Start Guide](SURPRISE_ME_QUICKSTART.md)
|
||||
5. Reference [Implementation Examples](SURPRISE_ME_IMPLEMENTATION_EXAMPLES.md) as needed
|
||||
|
||||
### For Developers (Ready to Code)
|
||||
1. Skim [Quick Summary](SURPRISE_ME_SUMMARY.md)
|
||||
2. Jump to [Quick Start Guide](SURPRISE_ME_QUICKSTART.md)
|
||||
3. Use [Implementation Examples](SURPRISE_ME_IMPLEMENTATION_EXAMPLES.md) for reference
|
||||
4. Refer to [Complete Design](SURPRISE_ME_RECOMMENDATION_DESIGN.md) for details
|
||||
|
||||
### For System Architects
|
||||
1. Read [Complete Design](SURPRISE_ME_RECOMMENDATION_DESIGN.md)
|
||||
2. Study [Architecture Diagrams](SURPRISE_ME_ARCHITECTURE_DIAGRAM.md)
|
||||
3. Review database schema and API design
|
||||
4. Evaluate scalability and performance considerations
|
||||
|
||||
### For QA Engineers
|
||||
1. Read [Quick Summary](SURPRISE_ME_SUMMARY.md)
|
||||
2. Review "User Experience Flow" in [Complete Design](SURPRISE_ME_RECOMMENDATION_DESIGN.md)
|
||||
3. Use "Testing Checklist" in [Quick Start Guide](SURPRISE_ME_QUICKSTART.md)
|
||||
4. Reference [Implementation Examples](SURPRISE_ME_IMPLEMENTATION_EXAMPLES.md) for test cases
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Quick Reference
|
||||
|
||||
### Key Concepts
|
||||
- **7 Recommendation Algorithms**: Smart Mix, Weighted Random, Forgotten Gems, Similar to Favorites, Time-Based, Pure Random, Unwatched First
|
||||
- **Lightweight Design**: No ML, just smart SQL queries
|
||||
- **Personal Focus**: Single-user video rediscovery
|
||||
- **Sidebar Navigation**: Standalone page, not a tab
|
||||
|
||||
### Technical Stack
|
||||
- **Frontend**: Next.js, React, TailwindCSS
|
||||
- **Backend**: Next.js API Routes
|
||||
- **Database**: SQLite with new `media_access` table
|
||||
- **Service Layer**: RecommendationService class
|
||||
|
||||
### Main Components
|
||||
- `/app/surprise-me/page.tsx` - Main page
|
||||
- `/app/api/videos/recommendations/route.ts` - API endpoint
|
||||
- `/app/api/media-access/[id]/route.ts` - Access tracking
|
||||
- `/lib/recommendation-service.ts` - Algorithm logic
|
||||
|
||||
### Database Schema
|
||||
```sql
|
||||
CREATE TABLE media_access (
|
||||
id INTEGER PRIMARY KEY,
|
||||
media_id INTEGER,
|
||||
access_type TEXT CHECK (access_type IN ('view', 'play', 'bookmark', 'rate')),
|
||||
created_at DATETIME
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Document Details
|
||||
|
||||
### [Quick Summary](SURPRISE_ME_SUMMARY.md)
|
||||
- **Lines**: ~220
|
||||
- **Topics**: Overview, algorithms, benefits, scenarios
|
||||
- **Best for**: Understanding the "why" and "what"
|
||||
|
||||
### [Quick Start Guide](SURPRISE_ME_QUICKSTART.md)
|
||||
- **Lines**: ~570
|
||||
- **Topics**: Step-by-step implementation, testing, troubleshooting
|
||||
- **Best for**: Actually building the feature
|
||||
|
||||
### [Complete Design](SURPRISE_ME_RECOMMENDATION_DESIGN.md)
|
||||
- **Lines**: ~580
|
||||
- **Topics**: Architecture, algorithms, database, API, UI, performance
|
||||
- **Best for**: Deep technical understanding
|
||||
|
||||
### [Architecture Diagrams](SURPRISE_ME_ARCHITECTURE_DIAGRAM.md)
|
||||
- **Lines**: ~390
|
||||
- **Topics**: Mermaid diagrams for system, data flow, algorithms
|
||||
- **Best for**: Visual learners and system design
|
||||
|
||||
### [Implementation Examples](SURPRISE_ME_IMPLEMENTATION_EXAMPLES.md)
|
||||
- **Lines**: ~910
|
||||
- **Topics**: Complete code examples, tests, utilities
|
||||
- **Best for**: Copy-paste ready code
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Implementation Phases
|
||||
|
||||
### Phase 1: MVP (Week 1)
|
||||
- Database schema ✅ [Quick Start Guide](SURPRISE_ME_QUICKSTART.md)
|
||||
- Basic algorithms (3) ✅ [Implementation Examples](SURPRISE_ME_IMPLEMENTATION_EXAMPLES.md)
|
||||
- Simple UI ✅ [Quick Start Guide](SURPRISE_ME_QUICKSTART.md)
|
||||
- Sidebar integration ✅ [Quick Start Guide](SURPRISE_ME_QUICKSTART.md)
|
||||
|
||||
### Phase 2: Enhanced (Week 2)
|
||||
- Advanced algorithms (4 more) → [Complete Design](SURPRISE_ME_RECOMMENDATION_DESIGN.md)
|
||||
- Smart Mix → [Implementation Examples](SURPRISE_ME_IMPLEMENTATION_EXAMPLES.md)
|
||||
- Polished UI → [Architecture Diagrams](SURPRISE_ME_ARCHITECTURE_DIAGRAM.md)
|
||||
|
||||
### Phase 3: Optimized (Week 3)
|
||||
- Caching → [Complete Design](SURPRISE_ME_RECOMMENDATION_DESIGN.md)
|
||||
- Performance tuning → [Complete Design](SURPRISE_ME_RECOMMENDATION_DESIGN.md)
|
||||
- Analytics → [Complete Design](SURPRISE_ME_RECOMMENDATION_DESIGN.md)
|
||||
|
||||
---
|
||||
|
||||
## 🤔 Frequently Asked Questions
|
||||
|
||||
### Where do I start?
|
||||
**Answer**: If you're implementing, go to [Quick Start Guide](SURPRISE_ME_QUICKSTART.md). If you're learning, start with [Quick Summary](SURPRISE_ME_SUMMARY.md).
|
||||
|
||||
### How long does implementation take?
|
||||
**Answer**: Basic MVP takes 2-3 hours. Full feature with all algorithms takes 1-2 weeks.
|
||||
|
||||
### Do I need machine learning?
|
||||
**Answer**: No! The design specifically avoids ML for simplicity and lightweight operation.
|
||||
|
||||
### Will this work with 100,000 videos?
|
||||
**Answer**: Yes, with proper database indexes (included in design). See performance section in [Complete Design](SURPRISE_ME_RECOMMENDATION_DESIGN.md).
|
||||
|
||||
### Can I customize the algorithms?
|
||||
**Answer**: Absolutely! See [Implementation Examples](SURPRISE_ME_IMPLEMENTATION_EXAMPLES.md) for how to add new algorithms.
|
||||
|
||||
### Is this production-ready?
|
||||
**Answer**: The design is production-ready. Implementation quality depends on following the guides.
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Features
|
||||
|
||||
This feature integrates with:
|
||||
- **Video Player**: Uses existing UnifiedVideoPlayer component
|
||||
- **Bookmarks**: Leverages bookmark data for Forgotten Gems algorithm
|
||||
- **Star Ratings**: Uses rating data for recommendations
|
||||
- **Media Database**: Extends existing media table with access tracking
|
||||
|
||||
---
|
||||
|
||||
## 📝 Contributing to Documentation
|
||||
|
||||
Found an issue or want to improve the docs?
|
||||
|
||||
1. Check which document needs updating using this index
|
||||
2. Follow the style of existing documentation
|
||||
3. Update the relevant document
|
||||
4. Update this index if you add new content
|
||||
5. Update the main [docs README](README.md)
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Need Help?
|
||||
|
||||
### Can't find something?
|
||||
- Use browser's find function (Ctrl/Cmd + F) to search this page
|
||||
- Check the [main docs README](README.md)
|
||||
- Search within individual documents
|
||||
|
||||
### Found a bug in documentation?
|
||||
- Create an issue describing the problem
|
||||
- Reference the specific document and section
|
||||
- Suggest a fix if possible
|
||||
|
||||
### Want to suggest improvements?
|
||||
- Share your ideas in project discussions
|
||||
- Submit a pull request with improvements
|
||||
- Update this index to reflect changes
|
||||
|
||||
---
|
||||
|
||||
## 📊 Version Information
|
||||
|
||||
| Document | Version | Last Updated | Status |
|
||||
|----------|---------|--------------|--------|
|
||||
| Quick Summary | 1.0 | 2025-10-12 | ✅ Complete |
|
||||
| Quick Start Guide | 1.0 | 2025-10-12 | ✅ Complete |
|
||||
| Complete Design | 1.0 | 2025-10-12 | ✅ Complete |
|
||||
| Architecture Diagrams | 1.0 | 2025-10-12 | ✅ Complete |
|
||||
| Implementation Examples | 1.0 | 2025-10-12 | ✅ Complete |
|
||||
| This Index | 1.0 | 2025-10-12 | ✅ Complete |
|
||||
|
||||
---
|
||||
|
||||
**Next Steps**:
|
||||
1. Choose your learning path above
|
||||
2. Start reading the recommended documents
|
||||
3. Begin implementation using the Quick Start Guide
|
||||
|
||||
**Happy Coding! 🎉**
|
||||
|
|
@ -0,0 +1,566 @@
|
|||
# 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:
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
// 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`:
|
||||
|
||||
```typescript
|
||||
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`:
|
||||
|
||||
```typescript
|
||||
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`:
|
||||
|
||||
```typescript
|
||||
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`:
|
||||
|
||||
```typescript
|
||||
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:
|
||||
```typescript
|
||||
import { Sparkles } from 'lucide-react';
|
||||
```
|
||||
|
||||
2. Add the menu item (after Bookmarks, before Clusters):
|
||||
```typescript
|
||||
<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`:
|
||||
|
||||
```typescript
|
||||
'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
|
||||
```bash
|
||||
# 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:
|
||||
```sql
|
||||
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
|
||||
|
||||
- [Complete Design Document](SURPRISE_ME_RECOMMENDATION_DESIGN.md)
|
||||
- [Architecture Diagrams](SURPRISE_ME_ARCHITECTURE_DIAGRAM.md)
|
||||
- [Implementation Examples](SURPRISE_ME_IMPLEMENTATION_EXAMPLES.md)
|
||||
- [Quick Summary](SURPRISE_ME_SUMMARY.md)
|
||||
|
||||
---
|
||||
|
||||
**Estimated Total Time**: 2-3 hours for basic implementation
|
||||
|
||||
**Difficulty**: Intermediate
|
||||
|
||||
**Prerequisites**:
|
||||
- Next.js knowledge
|
||||
- SQLite/SQL basics
|
||||
- React hooks understanding
|
||||
- TypeScript familiarity
|
||||
|
|
@ -0,0 +1,580 @@
|
|||
# Surprise Me - Video Recommendation Feature Design
|
||||
|
||||
## Overview
|
||||
|
||||
The "Surprise Me" feature is a lightweight video recommendation system designed for personal video management. Unlike enterprise systems like YouTube, this focuses on **rediscovery and variety** for a single user's personal collection, helping users find interesting videos they might have forgotten about or haven't watched recently.
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
### Core Principles
|
||||
1. **Discovery Over Prediction**: Help users rediscover their own collection rather than predict preferences
|
||||
2. **Lightweight**: No machine learning infrastructure required
|
||||
3. **Variety-Focused**: Ensure diverse recommendations across different libraries/folders
|
||||
4. **Personal**: Leverage user's own bookmarks, ratings, and viewing patterns
|
||||
5. **Serendipity**: Balance between smart recommendations and pleasant surprises
|
||||
|
||||
### Key Difference from YouTube-Style Recommendations
|
||||
- **No collaborative filtering**: Single user system
|
||||
- **No external data**: Works entirely with user's personal library
|
||||
- **Focus on forgotten content**: Help surface older or unwatched videos
|
||||
- **Simplicity**: Easy to understand and implement
|
||||
|
||||
## Architecture
|
||||
|
||||
### UI Integration
|
||||
|
||||
#### Sidebar Navigation
|
||||
```
|
||||
├── Home
|
||||
├── Settings
|
||||
├── Videos
|
||||
├── Photos
|
||||
├── Texts
|
||||
├── Bookmarks
|
||||
├── 🎲 Surprise Me ← NEW ITEM
|
||||
├── Clusters
|
||||
│ ├── Cluster 1
|
||||
│ └── Cluster 2
|
||||
└── Folder Viewer
|
||||
├── Library 1
|
||||
└── Library 2
|
||||
```
|
||||
|
||||
**Important**: "Surprise Me" is a **standalone sidebar item**, NOT a tab within the Videos page.
|
||||
|
||||
#### Page Layout (`/surprise-me`)
|
||||
When users click "Surprise Me" in the sidebar, they navigate to a dedicated page showing:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 🎲 Surprise Me │
|
||||
│ │
|
||||
│ Discover videos from your collection │
|
||||
│ │
|
||||
│ [🔄 Refresh Recommendations] [⚙️ Algorithm: Smart Mix]│
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Recommended for You │
|
||||
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||
│ │Video │ │Video │ │Video │ │Video │ │Video │ │
|
||||
│ │ #1 │ │ #2 │ │ #3 │ │ #4 │ │ #5 │ │
|
||||
│ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │
|
||||
│ │
|
||||
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||
│ │Video │ │Video │ │Video │ │Video │ │Video │ │
|
||||
│ │ #6 │ │ #7 │ │ #8 │ │ #9 │ │ #10 │ │
|
||||
│ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Database Schema Extensions
|
||||
|
||||
#### New Table: `media_access`
|
||||
Tracks user interactions with media for better recommendations.
|
||||
|
||||
```sql
|
||||
CREATE TABLE 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
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_media_access_media_id ON media_access(media_id);
|
||||
CREATE INDEX idx_media_access_created_at ON media_access(created_at);
|
||||
CREATE INDEX idx_media_access_type_created_at ON media_access(access_type, created_at);
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
- `view`: When video modal/player is opened
|
||||
- `play`: When video playback actually starts
|
||||
- `bookmark`: When video is bookmarked
|
||||
- `rate`: When video is rated
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### GET `/api/videos/recommendations`
|
||||
|
||||
**Query Parameters**:
|
||||
- `algorithm` (optional): Recommendation algorithm to use
|
||||
- `smart_mix` (default): Balanced mix of all algorithms
|
||||
- `weighted_random`: Smart randomization with recency weighting
|
||||
- `forgotten_gems`: Bookmarked/rated but not recently viewed
|
||||
- `similar_to_favorites`: From same folders as highly-rated videos
|
||||
- `time_based`: Based on video creation/addition time
|
||||
- `pure_random`: Complete randomization
|
||||
- `unwatched_first`: Prioritize never-accessed videos
|
||||
- `limit` (optional, default: 20): Number of recommendations
|
||||
- `exclude_recent_days` (optional, default: 30): Exclude videos viewed in last N days
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"recommendations": [
|
||||
{
|
||||
"id": 123,
|
||||
"title": "Movie Title",
|
||||
"path": "/mnt/videos/movie.mp4",
|
||||
"size": 1073741824,
|
||||
"thumbnail": "/api/thumbnails/...",
|
||||
"type": "video",
|
||||
"bookmark_count": 0,
|
||||
"star_count": 1,
|
||||
"avg_rating": 4.5,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"recommendation_reason": "From your favorite library",
|
||||
"recommendation_score": 0.85
|
||||
}
|
||||
],
|
||||
"algorithm": "smart_mix",
|
||||
"total_videos": 1500,
|
||||
"total_recommended": 20
|
||||
}
|
||||
```
|
||||
|
||||
#### POST `/api/media-access/:id`
|
||||
|
||||
Track media access for recommendation improvement.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"accessType": "view" | "play" | "bookmark" | "rate"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"mediaId": 123
|
||||
}
|
||||
```
|
||||
|
||||
## Recommendation Algorithms
|
||||
|
||||
### 1. Smart Mix (Default)
|
||||
Combines multiple strategies for balanced recommendations:
|
||||
- 30% Weighted Random Discovery
|
||||
- 25% Forgotten Gems
|
||||
- 20% Similar to Favorites
|
||||
- 15% Unwatched First
|
||||
- 10% Time-Based Variety
|
||||
|
||||
**Implementation**:
|
||||
```typescript
|
||||
async function getSmartMixRecommendations(limit: number) {
|
||||
const recommendations = [];
|
||||
|
||||
// Calculate distribution
|
||||
const distribution = {
|
||||
weightedRandom: Math.floor(limit * 0.3),
|
||||
forgottenGems: Math.floor(limit * 0.25),
|
||||
similarToFavorites: Math.floor(limit * 0.2),
|
||||
unwatchedFirst: Math.floor(limit * 0.15),
|
||||
timeBased: Math.floor(limit * 0.1)
|
||||
};
|
||||
|
||||
// Get from each algorithm
|
||||
recommendations.push(...await getWeightedRandom(distribution.weightedRandom));
|
||||
recommendations.push(...await getForgottenGems(distribution.forgottenGems));
|
||||
recommendations.push(...await getSimilarToFavorites(distribution.similarToFavorites));
|
||||
recommendations.push(...await getUnwatchedFirst(distribution.unwatchedFirst));
|
||||
recommendations.push(...await getTimeBased(distribution.timeBased));
|
||||
|
||||
// Shuffle and deduplicate
|
||||
return shuffleAndDeduplicate(recommendations).slice(0, limit);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Weighted Random Discovery
|
||||
Smart randomization that considers:
|
||||
- **Recency penalty**: Videos viewed recently get lower weight
|
||||
- **Rating boost**: Higher-rated videos get slight preference
|
||||
- **Library diversity**: Ensure videos from different sources
|
||||
|
||||
**Scoring Formula**:
|
||||
```
|
||||
score = base_score × recency_multiplier × rating_multiplier × diversity_bonus
|
||||
|
||||
where:
|
||||
base_score = random(0.5, 1.0)
|
||||
recency_multiplier = min(1.0, days_since_last_view / 90)
|
||||
rating_multiplier = 1.0 + (avg_rating / 10)
|
||||
diversity_bonus = 1.2 if from underrepresented library
|
||||
```
|
||||
|
||||
**SQL Query**:
|
||||
```sql
|
||||
SELECT
|
||||
m.*,
|
||||
RANDOM() as random_factor,
|
||||
JULIANDAY('now') - JULIANDAY(COALESCE(ma.last_access, m.created_at)) as days_since_view,
|
||||
COALESCE(m.avg_rating, 0) as rating
|
||||
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', '-30 days'))
|
||||
ORDER BY (
|
||||
random_factor *
|
||||
MIN(1.0, (JULIANDAY('now') - JULIANDAY(COALESCE(ma.last_access, m.created_at))) / 90.0) *
|
||||
(1.0 + COALESCE(m.avg_rating, 0) / 10.0)
|
||||
) DESC
|
||||
LIMIT ?
|
||||
```
|
||||
|
||||
### 3. Forgotten Gems
|
||||
Surface videos that you've shown interest in but haven't watched recently:
|
||||
- Bookmarked videos not viewed in 30+ days
|
||||
- Highly-rated videos (4+ stars) not viewed in 60+ days
|
||||
- Videos from folders you've bookmarked
|
||||
|
||||
**Target Criteria**:
|
||||
- Has bookmark OR rating >= 4
|
||||
- Not accessed in last 30-60 days
|
||||
- Sorted by rating and bookmark combination
|
||||
|
||||
**SQL Query**:
|
||||
```sql
|
||||
SELECT DISTINCT m.*,
|
||||
CASE
|
||||
WHEN b.id IS NOT NULL THEN 'You bookmarked this'
|
||||
WHEN m.avg_rating >= 4 THEN 'You rated this highly'
|
||||
ELSE 'From a favorite folder'
|
||||
END as recommendation_reason
|
||||
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 OR
|
||||
m.path LIKE (SELECT folder_path || '%' FROM folder_bookmarks LIMIT 1)
|
||||
)
|
||||
AND (ma.last_access IS NULL OR ma.last_access < datetime('now', '-30 days'))
|
||||
ORDER BY
|
||||
(COALESCE(m.avg_rating, 0) * 0.7 +
|
||||
CASE WHEN b.id IS NOT NULL THEN 3 ELSE 0 END) DESC,
|
||||
RANDOM()
|
||||
LIMIT ?
|
||||
```
|
||||
|
||||
### 4. Similar to Favorites
|
||||
Find videos from the same location as your highly-rated content:
|
||||
- Same parent folder as 4+ star videos
|
||||
- Same library as frequently accessed content
|
||||
- Similar file size/characteristics
|
||||
|
||||
**Logic**:
|
||||
1. Find user's top-rated videos (4+ stars)
|
||||
2. Extract their parent folders
|
||||
3. Find other videos in those folders
|
||||
4. Exclude already-watched videos
|
||||
|
||||
**SQL Query**:
|
||||
```sql
|
||||
WITH favorite_folders AS (
|
||||
SELECT DISTINCT
|
||||
SUBSTR(path, 1, LENGTH(path) - LENGTH(SUBSTR(path, INSTR(path, '/', -1) + 1))) as folder_path
|
||||
FROM media
|
||||
WHERE type = 'video' AND avg_rating >= 4
|
||||
LIMIT 10
|
||||
)
|
||||
SELECT m.*,
|
||||
'From a folder with your favorites' as recommendation_reason
|
||||
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 EXISTS (
|
||||
SELECT 1 FROM favorite_folders ff
|
||||
WHERE m.path LIKE ff.folder_path || '%'
|
||||
)
|
||||
AND m.avg_rating < 4 -- Not already a favorite
|
||||
AND (ma.id IS NULL OR ma.created_at < datetime('now', '-60 days'))
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?
|
||||
```
|
||||
|
||||
### 5. Time-Based Recommendations
|
||||
Recommendations based on temporal patterns:
|
||||
- **Seasonal**: Videos added around the same time last year
|
||||
- **New discoveries**: Recently added to library but not yet viewed
|
||||
- **Old but gold**: Videos added long ago that you might have missed
|
||||
|
||||
**Categories**:
|
||||
```typescript
|
||||
type TimeBasedCategory =
|
||||
| 'recently_added' // Added in last 7 days, not viewed
|
||||
| 'one_month_old' // Added ~30 days ago
|
||||
| 'seasonal' // Added ~365 days ago
|
||||
| 'archive_discovery' // Added >1 year ago, never viewed
|
||||
```
|
||||
|
||||
### 6. Pure Random
|
||||
Complete randomization with minimal filters:
|
||||
- Only filter: video type and exclude corrupted files
|
||||
- Pure serendipity mode
|
||||
- No bias towards ratings or access patterns
|
||||
|
||||
### 7. Unwatched First
|
||||
Prioritize videos that have never been accessed:
|
||||
- Never appeared in `media_access` table
|
||||
- Sorted by date added (newest first)
|
||||
- Ensures new content gets discovered
|
||||
|
||||
**SQL Query**:
|
||||
```sql
|
||||
SELECT m.*,
|
||||
'Never watched before' as recommendation_reason
|
||||
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 ?
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Foundation (Priority: P0)
|
||||
- [x] Design documentation
|
||||
- [ ] Database schema migration (add `media_access` table)
|
||||
- [ ] Media access tracking utility functions
|
||||
- [ ] Basic recommendation service structure
|
||||
|
||||
### Phase 2: Core Algorithms (Priority: P0)
|
||||
- [ ] Implement Weighted Random algorithm
|
||||
- [ ] Implement Unwatched First algorithm
|
||||
- [ ] Implement Pure Random algorithm
|
||||
- [ ] Create API endpoint `/api/videos/recommendations`
|
||||
- [ ] Create media access tracking endpoint
|
||||
|
||||
### Phase 3: Advanced Algorithms (Priority: P1)
|
||||
- [ ] Implement Forgotten Gems algorithm
|
||||
- [ ] Implement Similar to Favorites algorithm
|
||||
- [ ] Implement Time-Based algorithm
|
||||
- [ ] Implement Smart Mix algorithm
|
||||
|
||||
### Phase 4: UI Components (Priority: P0)
|
||||
- [ ] Create `SurpriseMePage` component (`/src/app/surprise-me/page.tsx`)
|
||||
- [ ] Add "Surprise Me" item to sidebar navigation
|
||||
- [ ] Implement recommendation grid display
|
||||
- [ ] Add algorithm selector dropdown
|
||||
- [ ] Add refresh button functionality
|
||||
|
||||
### Phase 5: Integration & Polish (Priority: P1)
|
||||
- [ ] Track video views automatically when player opens
|
||||
- [ ] Add recommendation reasons/badges to video cards
|
||||
- [ ] Implement smooth transitions and loading states
|
||||
- [ ] Add empty state when no recommendations available
|
||||
- [ ] Performance testing with large libraries
|
||||
|
||||
### Phase 6: Advanced Features (Priority: P2)
|
||||
- [ ] Save preferred algorithm in user preferences
|
||||
- [ ] "Not interested" button to exclude videos
|
||||
- [ ] Recommendation history tracking
|
||||
- [ ] Export/share recommendations
|
||||
- [ ] Statistics dashboard (most recommended, never recommended, etc.)
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
/root/workspace/nextav/
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── api/
|
||||
│ │ │ ├── media-access/
|
||||
│ │ │ │ └── [id]/
|
||||
│ │ │ │ └── route.ts ← NEW: Track media access
|
||||
│ │ │ └── videos/
|
||||
│ │ │ └── recommendations/
|
||||
│ │ │ └── route.ts ← NEW: Get recommendations
|
||||
│ │ └── surprise-me/
|
||||
│ │ └── page.tsx ← NEW: Surprise Me page
|
||||
│ ├── components/
|
||||
│ │ ├── recommendation-grid.tsx ← NEW: Display recommendations
|
||||
│ │ └── sidebar.tsx ← MODIFY: Add Surprise Me item
|
||||
│ ├── db/
|
||||
│ │ └── index.ts ← MODIFY: Add media_access table
|
||||
│ └── lib/
|
||||
│ └── recommendation-service.ts ← NEW: Recommendation algorithms
|
||||
└── docs/
|
||||
└── SURPRISE_ME_RECOMMENDATION_DESIGN.md ← This file
|
||||
```
|
||||
|
||||
## User Experience Flow
|
||||
|
||||
### Scenario 1: First Time User
|
||||
1. User clicks "🎲 Surprise Me" in sidebar
|
||||
2. Page shows loading state
|
||||
3. Default "Smart Mix" algorithm runs
|
||||
4. 20 diverse videos displayed
|
||||
5. User can click any video to watch
|
||||
6. User can click "Refresh" for new recommendations
|
||||
|
||||
### Scenario 2: Regular User
|
||||
1. User navigates to Surprise Me
|
||||
2. System recognizes viewing patterns
|
||||
3. Shows mix of:
|
||||
- Unwatched videos from favorite libraries
|
||||
- Old favorites not seen recently
|
||||
- Random discoveries for variety
|
||||
4. Each card shows reason: "From your favorite library" or "Never watched"
|
||||
|
||||
### Scenario 3: Algorithm Exploration
|
||||
1. User selects "Forgotten Gems" from dropdown
|
||||
2. System shows only bookmarked/highly-rated unwatched content
|
||||
3. User finds an old favorite they forgot about
|
||||
4. User watches and enjoys rediscovery
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Database Optimization
|
||||
- All queries use indexed columns (`media_id`, `created_at`, `type`)
|
||||
- Limit queries to reasonable batch sizes (20-50 videos)
|
||||
- Use `RANDOM()` efficiently with proper `ORDER BY`
|
||||
- Cache recommendation results for 5-10 minutes
|
||||
|
||||
### Caching Strategy
|
||||
```typescript
|
||||
// Cache recommendations for short period
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
const recommendationCache = new Map<string, {
|
||||
data: Video[],
|
||||
timestamp: number
|
||||
}>();
|
||||
```
|
||||
|
||||
### Scalability
|
||||
- Works efficiently with 1,000 to 100,000+ videos
|
||||
- Lazy loading for recommendation display
|
||||
- Pagination for large result sets
|
||||
- Background precomputation for complex algorithms (future enhancement)
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### User Preferences (Future)
|
||||
```typescript
|
||||
interface RecommendationPreferences {
|
||||
defaultAlgorithm: RecommendationAlgorithm;
|
||||
excludeRecentDays: number; // Default: 30
|
||||
recommendationLimit: number; // Default: 20
|
||||
enableAutoRefresh: boolean; // Auto-refresh on page visit
|
||||
preferredLibraries: number[]; // Boost these libraries
|
||||
excludedFolders: string[]; // Never recommend from these
|
||||
}
|
||||
```
|
||||
|
||||
### System Settings
|
||||
```typescript
|
||||
const RECOMMENDATION_CONFIG = {
|
||||
MAX_RECOMMENDATIONS: 100,
|
||||
DEFAULT_LIMIT: 20,
|
||||
MIN_RATING_FOR_FAVORITES: 4,
|
||||
RECENT_VIEW_THRESHOLD_DAYS: 30,
|
||||
DIVERSITY_BOOST_FACTOR: 1.2,
|
||||
CACHE_TTL_SECONDS: 300
|
||||
};
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Test each algorithm independently
|
||||
- Verify SQL query correctness
|
||||
- Test edge cases (empty library, all videos watched, etc.)
|
||||
|
||||
### Integration Tests
|
||||
- Test API endpoint responses
|
||||
- Verify media access tracking
|
||||
- Test algorithm switching
|
||||
|
||||
### User Acceptance Testing
|
||||
- Test with small library (<100 videos)
|
||||
- Test with medium library (100-1000 videos)
|
||||
- Test with large library (>10,000 videos)
|
||||
- Verify recommendations are diverse and interesting
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Phase 2 Features (Post-MVP)
|
||||
1. **Playlist Generation**: Create playlists from recommendations
|
||||
2. **Mood-Based**: Filter by video characteristics (length, genre from filename)
|
||||
3. **Social**: Share recommendation sets with other users
|
||||
4. **Learning**: Track which recommendations user clicks to improve algorithms
|
||||
5. **Filters**: By library, rating range, size, date added
|
||||
6. **Smart Scheduling**: "Video of the Day" feature
|
||||
7. **Collections**: "Similar to this video" on video player
|
||||
8. **Statistics**: Dashboard showing recommendation effectiveness
|
||||
|
||||
### Advanced Algorithms (Future)
|
||||
1. **Content-Based Filtering**: Use filename patterns, folder structure
|
||||
2. **Collaborative Patterns**: If multiple users, find correlation
|
||||
3. **Temporal Patterns**: Learn user's viewing time preferences
|
||||
4. **Sequence Learning**: Detect video series/collections
|
||||
5. **Quality Scoring**: Prefer higher quality encodes
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Key Performance Indicators
|
||||
- **Engagement**: % of recommendations clicked
|
||||
- **Discovery Rate**: # of previously unwatched videos discovered
|
||||
- **Diversity Score**: # of different libraries in recommendations
|
||||
- **User Satisfaction**: Time spent on Surprise Me page
|
||||
- **Rediscovery**: % of forgotten favorites surfaced
|
||||
|
||||
### Target Goals
|
||||
- 80%+ of users try Surprise Me feature
|
||||
- 30%+ click-through rate on recommendations
|
||||
- 50%+ of recommendations are from unwatched videos
|
||||
- 70%+ user satisfaction with variety
|
||||
|
||||
## Appendix
|
||||
|
||||
### Recommendation Reason Badges
|
||||
Visual indicators showing why a video was recommended:
|
||||
|
||||
| Badge | Meaning | Color |
|
||||
|-------|---------|-------|
|
||||
| 🌟 Favorite | From folder with 4+ star videos | Gold |
|
||||
| 🔖 Bookmarked | You bookmarked this | Blue |
|
||||
| ✨ New | Added recently, never watched | Green |
|
||||
| 🕰️ Forgotten Gem | Rated highly but not watched in 60+ days | Purple |
|
||||
| 🎲 Random | Pure random selection | Gray |
|
||||
| 📁 Same Folder | From folder with your favorites | Orange |
|
||||
| 🎯 Unwatched | Never accessed before | Teal |
|
||||
|
||||
### Example UI Component Props
|
||||
```typescript
|
||||
interface SurpriseMePageProps {
|
||||
initialAlgorithm?: RecommendationAlgorithm;
|
||||
initialLimit?: number;
|
||||
}
|
||||
|
||||
interface RecommendationCardProps {
|
||||
video: Video;
|
||||
reason: string;
|
||||
score: number;
|
||||
onVideoClick: (video: Video) => void;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Last Updated**: 2025-10-12
|
||||
**Status**: Design Phase
|
||||
**Next Steps**: Begin Phase 1 implementation
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
# Surprise Me Feature - Quick Summary
|
||||
|
||||
## What is "Surprise Me"?
|
||||
|
||||
A lightweight video recommendation system for personal video collections that helps users **rediscover** content they might have forgotten about or haven't watched yet.
|
||||
|
||||
## Key Concept
|
||||
|
||||
Unlike YouTube's complex ML-based recommendations, "Surprise Me" focuses on:
|
||||
- ✨ **Rediscovery**: Surface videos you've forgotten
|
||||
- 🎲 **Variety**: Mix content from different libraries
|
||||
- 🎯 **Simplicity**: No complex ML, just smart queries
|
||||
- 📊 **Personal**: Uses YOUR ratings, bookmarks, and viewing patterns
|
||||
|
||||
## UI Location
|
||||
|
||||
**Sidebar Item** (NOT a tab):
|
||||
```
|
||||
Sidebar Navigation:
|
||||
├── Home
|
||||
├── Settings
|
||||
├── Videos
|
||||
├── Photos
|
||||
├── Texts
|
||||
├── Bookmarks
|
||||
├── 🎲 Surprise Me ← NEW standalone page at /surprise-me
|
||||
├── Clusters
|
||||
└── Folder Viewer
|
||||
```
|
||||
|
||||
When clicked → navigates to dedicated `/surprise-me` page
|
||||
|
||||
## Recommendation Algorithms
|
||||
|
||||
### 1. **Smart Mix** (Default)
|
||||
Balanced combination of all algorithms:
|
||||
- 30% Weighted Random
|
||||
- 25% Forgotten Gems
|
||||
- 20% Similar to Favorites
|
||||
- 15% Unwatched First
|
||||
- 10% Time-Based
|
||||
|
||||
### 2. **Weighted Random**
|
||||
Smart randomization considering:
|
||||
- How long since last viewed (prefer older)
|
||||
- Video ratings (slight boost for higher rated)
|
||||
- Library diversity (ensure variety)
|
||||
|
||||
### 3. **Forgotten Gems**
|
||||
Videos you showed interest in but haven't watched recently:
|
||||
- Bookmarked videos (30+ days ago)
|
||||
- Highly-rated videos (4+ stars, 60+ days)
|
||||
- Videos from bookmarked folders
|
||||
|
||||
### 4. **Similar to Favorites**
|
||||
Find videos from same folders as your 4+ star videos
|
||||
|
||||
### 5. **Time-Based**
|
||||
- Recently added but unwatched
|
||||
- Seasonal (same time last year)
|
||||
- Archive discoveries (old, never watched)
|
||||
|
||||
### 6. **Pure Random**
|
||||
Complete serendipity - totally random selection
|
||||
|
||||
### 7. **Unwatched First**
|
||||
Prioritize videos never accessed before
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### New Database Table
|
||||
```sql
|
||||
media_access (
|
||||
media_id,
|
||||
access_type: 'view' | 'play' | 'bookmark' | 'rate',
|
||||
created_at
|
||||
)
|
||||
```
|
||||
|
||||
Tracks when users interact with videos for better recommendations.
|
||||
|
||||
### New API Endpoints
|
||||
|
||||
**GET `/api/videos/recommendations`**
|
||||
- Query params: `algorithm`, `limit`, `exclude_recent_days`
|
||||
- Returns: Array of recommended videos with reasons
|
||||
|
||||
**POST `/api/media-access/:id`**
|
||||
- Tracks user interactions for recommendation improvement
|
||||
|
||||
### New Components
|
||||
|
||||
1. **`/src/app/surprise-me/page.tsx`** - Main page
|
||||
2. **`/src/lib/recommendation-service.ts`** - Algorithm logic
|
||||
3. **Modified `sidebar.tsx`** - Add Surprise Me item
|
||||
|
||||
## User Flow
|
||||
|
||||
1. User clicks "🎲 Surprise Me" in sidebar
|
||||
2. Page loads with 20 recommended videos (default: Smart Mix)
|
||||
3. Each video card shows:
|
||||
- Thumbnail
|
||||
- Title, path, size
|
||||
- Rating/bookmark status
|
||||
- **Recommendation reason** badge (e.g., "From your favorite library")
|
||||
4. User can:
|
||||
- Click video to watch
|
||||
- Click "Refresh" for new recommendations
|
||||
- Change algorithm via dropdown
|
||||
- Bookmark/rate directly from cards
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Foundation ✅
|
||||
- [x] Design documentation
|
||||
- [ ] Database migration
|
||||
- [ ] Basic service structure
|
||||
|
||||
### Phase 2: Core (Week 1)
|
||||
- [ ] 3 basic algorithms (Weighted Random, Unwatched, Pure Random)
|
||||
- [ ] API endpoint
|
||||
- [ ] Basic UI page
|
||||
|
||||
### Phase 3: Advanced (Week 2)
|
||||
- [ ] 4 advanced algorithms
|
||||
- [ ] Smart Mix
|
||||
- [ ] Polished UI
|
||||
|
||||
### Phase 4: Integration (Week 3)
|
||||
- [ ] Auto-track video views
|
||||
- [ ] Recommendation badges
|
||||
- [ ] Performance optimization
|
||||
|
||||
## Benefits
|
||||
|
||||
### For Users
|
||||
- 🎯 **Discover new content** in their own library
|
||||
- ⏰ **Save time** deciding what to watch
|
||||
- 💎 **Rediscover forgotten favorites**
|
||||
- 🎲 **Serendipitous discoveries**
|
||||
- 📚 **Explore entire collection** systematically
|
||||
|
||||
### Technical
|
||||
- 🚀 **Lightweight** - no ML infrastructure needed
|
||||
- ⚡ **Fast** - simple SQL queries with indexes
|
||||
- 📈 **Scalable** - works with 1K to 100K+ videos
|
||||
- 🔧 **Maintainable** - pure SQL, no black boxes
|
||||
- 🎨 **Extensible** - easy to add new algorithms
|
||||
|
||||
## Example Scenarios
|
||||
|
||||
### Scenario: Large Movie Collection
|
||||
**Problem**: User has 5,000 movies but always watches the same 50
|
||||
|
||||
**Solution**:
|
||||
- Unwatched First: Shows 100s of movies never opened
|
||||
- Forgotten Gems: Surfaces 4-star movies from 2 years ago
|
||||
- Similar to Favorites: Finds movies in same genre folders
|
||||
|
||||
### Scenario: New Content Added
|
||||
**Problem**: User adds 200 new videos but forgets about them
|
||||
|
||||
**Solution**:
|
||||
- Time-Based: "Recently added" shows last 7 days
|
||||
- Smart Mix: Includes 30% new content automatically
|
||||
|
||||
### Scenario: Bored, Want Something Different
|
||||
**Problem**: User wants to watch something but doesn't know what
|
||||
|
||||
**Solution**:
|
||||
- Pure Random: Complete surprise from entire collection
|
||||
- Weighted Random: Random but slightly favors good stuff
|
||||
|
||||
## Why This Design?
|
||||
|
||||
### Lightweight Approach
|
||||
- ✅ No complex ML training
|
||||
- ✅ No external dependencies
|
||||
- ✅ Works offline
|
||||
- ✅ Instant results
|
||||
- ✅ Easy to understand and debug
|
||||
|
||||
### Personal-Focused
|
||||
- ✅ Designed for single user
|
||||
- ✅ Leverages existing data (bookmarks, ratings)
|
||||
- ✅ Respects user's organizational structure
|
||||
- ✅ No privacy concerns (all local)
|
||||
|
||||
### Practical
|
||||
- ✅ Solves real problem (content discovery)
|
||||
- ✅ Easy to implement incrementally
|
||||
- ✅ Low maintenance overhead
|
||||
- ✅ Clear success metrics
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- **80%** of users try the feature
|
||||
- **30%** click-through rate on recommendations
|
||||
- **50%** of recommendations are unwatched videos
|
||||
- **High** diversity across libraries/folders
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- 🎵 Playlist generation from recommendations
|
||||
- 📊 Statistics dashboard
|
||||
- 🎯 "Similar videos" on player page
|
||||
- 📅 "Video of the Day" feature
|
||||
- 🔍 Advanced filters (by library, rating, size)
|
||||
- 💾 Save recommendation sets
|
||||
- 🚫 "Not interested" exclusions
|
||||
|
||||
---
|
||||
|
||||
**See Full Design**: [SURPRISE_ME_RECOMMENDATION_DESIGN.md](./SURPRISE_ME_RECOMMENDATION_DESIGN.md)
|
||||
|
||||
**Status**: 📝 Design Complete → Ready for Implementation
|
||||
**Next Step**: Database schema migration
|
||||
|
|
@ -254,7 +254,7 @@ For issues and feature requests, please check:
|
|||
## Build/Push Docker image to private repo
|
||||
Usage:
|
||||
# Build & push to private registry
|
||||
docker build -t 192.168.2.212:3000/tigeren/nextav:1.3 .
|
||||
docker push 192.168.2.212:3000/tigeren/nextav:1.3
|
||||
docker build -t 192.168.2.212:3000/tigeren/nextav:1.4 .
|
||||
docker push 192.168.2.212:3000/tigeren/nextav:1.4
|
||||
|
||||
docker login 192.168.2.212:3000
|
||||
Loading…
Reference in New Issue