feat: Add last played timestamp to video data
This commit is contained in:
@@ -657,7 +657,10 @@ export const incrementViewCount = (req: Request, res: Response): any => {
|
||||
}
|
||||
|
||||
const currentViews = video.viewCount || 0;
|
||||
const updatedVideo = storageService.updateVideo(id, { viewCount: currentViews + 1 });
|
||||
const updatedVideo = storageService.updateVideo(id, {
|
||||
viewCount: currentViews + 1,
|
||||
lastPlayedAt: Date.now()
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
@@ -679,7 +682,10 @@ export const updateProgress = (req: Request, res: Response): any => {
|
||||
return res.status(400).json({ error: "Progress must be a number" });
|
||||
}
|
||||
|
||||
const updatedVideo = storageService.updateVideo(id, { progress });
|
||||
const updatedVideo = storageService.updateVideo(id, {
|
||||
progress,
|
||||
lastPlayedAt: Date.now()
|
||||
});
|
||||
|
||||
if (!updatedVideo) {
|
||||
return res.status(404).json({ error: "Video not found" });
|
||||
|
||||
@@ -27,6 +27,7 @@ export const videos = sqliteTable('videos', {
|
||||
tags: text('tags'), // JSON stringified array of strings
|
||||
progress: integer('progress'), // Playback progress in seconds
|
||||
fileSize: text('file_size'),
|
||||
lastPlayedAt: integer('last_played_at'), // Timestamp when video was last played
|
||||
});
|
||||
|
||||
export const collections = sqliteTable('collections', {
|
||||
|
||||
@@ -143,6 +143,12 @@ export function initializeStorage(): void {
|
||||
console.log("Migration successful: file_size added.");
|
||||
}
|
||||
|
||||
if (!columns.includes('last_played_at')) {
|
||||
console.log("Migrating database: Adding last_played_at column to videos table...");
|
||||
sqlite.prepare("ALTER TABLE videos ADD COLUMN last_played_at INTEGER").run();
|
||||
console.log("Migration successful: last_played_at added.");
|
||||
}
|
||||
|
||||
// Populate fileSize for existing videos
|
||||
const allVideos = db.select().from(videos).all();
|
||||
let updatedCount = 0;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import CollectionModal from '../components/VideoPlayer/CollectionModal';
|
||||
@@ -25,6 +25,7 @@ import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../contexts/SnackbarContext';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
import { Collection, Video } from '../types';
|
||||
import { getRecommendations } from '../utils/recommendations';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
@@ -343,8 +344,15 @@ const VideoPlayer: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Get related videos (exclude current video)
|
||||
const relatedVideos = videos.filter(v => v.id !== id).slice(0, 10);
|
||||
// Get related videos using recommendation algorithm
|
||||
const relatedVideos = useMemo(() => {
|
||||
if (!video) return [];
|
||||
return getRecommendations({
|
||||
currentVideo: video,
|
||||
allVideos: videos,
|
||||
collections: collections
|
||||
}).slice(0, 10);
|
||||
}, [video, videos, collections]);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Video {
|
||||
progress?: number;
|
||||
duration?: string;
|
||||
fileSize?: string; // Size in bytes as string
|
||||
lastPlayedAt?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
135
frontend/src/utils/recommendations.ts
Normal file
135
frontend/src/utils/recommendations.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
export interface RecommendationWeights {
|
||||
recency: number;
|
||||
frequency: number;
|
||||
collection: number;
|
||||
tags: number;
|
||||
author: number;
|
||||
filename: number;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_WEIGHTS: RecommendationWeights = {
|
||||
recency: 0.2,
|
||||
frequency: 0.1,
|
||||
collection: 0.4,
|
||||
tags: 0.2,
|
||||
author: 0.1,
|
||||
filename: 0.0, // Used as tie-breaker mostly
|
||||
sequence: 0.5, // Boost for the immediate next file
|
||||
};
|
||||
|
||||
export interface RecommendationContext {
|
||||
currentVideo: Video;
|
||||
allVideos: Video[];
|
||||
collections: Collection[];
|
||||
weights?: Partial<RecommendationWeights>;
|
||||
}
|
||||
|
||||
export const getRecommendations = (context: RecommendationContext): Video[] => {
|
||||
const { currentVideo, allVideos, collections, weights } = context;
|
||||
const finalWeights = { ...DEFAULT_WEIGHTS, ...weights };
|
||||
|
||||
// Filter out current video
|
||||
const candidates = allVideos.filter(v => v.id !== currentVideo.id);
|
||||
|
||||
// Pre-calculate collection membership for current video
|
||||
const currentVideoCollections = collections.filter(c => c.videos.includes(currentVideo.id)).map(c => c.id);
|
||||
|
||||
// Calculate max values for normalization
|
||||
const maxViewCount = Math.max(...allVideos.map(v => v.viewCount || 0), 1);
|
||||
const now = Date.now();
|
||||
// Normalize recency: 1.0 for now, 0.0 for very old (e.g. 1 year ago)
|
||||
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Determine natural sequence
|
||||
// Sort all videos by filename/title to find the "next" one naturally
|
||||
const sortedAllVideos = [...allVideos].sort((a, b) => {
|
||||
const nameA = a.videoFilename || a.title;
|
||||
const nameB = b.videoFilename || b.title;
|
||||
return nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: 'base' });
|
||||
});
|
||||
const currentIndex = sortedAllVideos.findIndex(v => v.id === currentVideo.id);
|
||||
const nextInSequenceId = currentIndex !== -1 && currentIndex < sortedAllVideos.length - 1
|
||||
? sortedAllVideos[currentIndex + 1].id
|
||||
: null;
|
||||
|
||||
const scoredCandidates = candidates.map(video => {
|
||||
let score = 0;
|
||||
|
||||
// 1. Recency (lastPlayedAt)
|
||||
// Higher score for more recently played.
|
||||
// If never played, score is 0.
|
||||
if (video.lastPlayedAt) {
|
||||
const age = Math.max(0, now - video.lastPlayedAt);
|
||||
const recencyScore = Math.max(0, 1 - (age / ONE_YEAR_MS));
|
||||
score += recencyScore * finalWeights.recency;
|
||||
}
|
||||
|
||||
// 2. Frequency (viewCount)
|
||||
const frequencyScore = (video.viewCount || 0) / maxViewCount;
|
||||
score += frequencyScore * finalWeights.frequency;
|
||||
|
||||
// 3. Collection/Series
|
||||
// Check if video is in the same collection as current video
|
||||
const videoCollections = collections.filter(c => c.videos.includes(video.id)).map(c => c.id);
|
||||
const inSameCollection = currentVideoCollections.some(id => videoCollections.includes(id));
|
||||
|
||||
// Also check seriesTitle if available
|
||||
const sameSeriesTitle = currentVideo.seriesTitle && video.seriesTitle && currentVideo.seriesTitle === video.seriesTitle;
|
||||
|
||||
if (inSameCollection || sameSeriesTitle) {
|
||||
score += 1.0 * finalWeights.collection;
|
||||
}
|
||||
|
||||
// 4. Tags
|
||||
// Jaccard index or simple overlap
|
||||
const currentTags = currentVideo.tags || [];
|
||||
const videoTags = video.tags || [];
|
||||
if (currentTags.length > 0 && videoTags.length > 0) {
|
||||
const intersection = currentTags.filter(t => videoTags.includes(t));
|
||||
const union = new Set([...currentTags, ...videoTags]);
|
||||
const tagScore = intersection.length / union.size;
|
||||
score += tagScore * finalWeights.tags;
|
||||
}
|
||||
|
||||
// 5. Author
|
||||
if (currentVideo.author && video.author && currentVideo.author === video.author) {
|
||||
score += 1.0 * finalWeights.author;
|
||||
}
|
||||
|
||||
// 6. Sequence (Natural Order)
|
||||
if (video.id === nextInSequenceId) {
|
||||
score += 1.0 * finalWeights.sequence;
|
||||
}
|
||||
|
||||
return {
|
||||
video,
|
||||
score,
|
||||
inSameCollection
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by score descending
|
||||
scoredCandidates.sort((a, b) => {
|
||||
if (Math.abs(a.score - b.score) > 0.001) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
|
||||
// Tie-breakers
|
||||
|
||||
// 1. Same collection
|
||||
if (a.inSameCollection !== b.inSameCollection) {
|
||||
return a.inSameCollection ? -1 : 1;
|
||||
}
|
||||
|
||||
// 2. Filename natural order
|
||||
const nameA = a.video.videoFilename || a.video.title;
|
||||
const nameB = b.video.videoFilename || b.video.title;
|
||||
|
||||
return nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: 'base' });
|
||||
});
|
||||
|
||||
return scoredCandidates.map(item => item.video);
|
||||
};
|
||||
Reference in New Issue
Block a user