feat: Add last played timestamp to video data

This commit is contained in:
Peifan Li
2025-11-27 15:13:30 -05:00
parent 87bf011da7
commit ea4c173e4d
6 changed files with 162 additions and 5 deletions

View File

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

View File

@@ -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', {

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ export interface Video {
progress?: number;
duration?: string;
fileSize?: string; // Size in bytes as string
lastPlayedAt?: number;
[key: string]: any;
}

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