feat: Add view count and progress tracking for videos
This commit is contained in:
@@ -37,9 +37,35 @@ async function migrate() {
|
||||
seriesTitle: video.seriesTitle,
|
||||
rating: video.rating,
|
||||
description: video.description,
|
||||
viewCount: video.viewCount,
|
||||
viewCount: video.viewCount || 0,
|
||||
progress: video.progress || 0,
|
||||
duration: video.duration,
|
||||
}).onConflictDoNothing();
|
||||
}).onConflictDoUpdate({
|
||||
target: videos.id,
|
||||
set: {
|
||||
title: video.title,
|
||||
author: video.author,
|
||||
date: video.date,
|
||||
source: video.source,
|
||||
sourceUrl: video.sourceUrl,
|
||||
videoFilename: video.videoFilename,
|
||||
thumbnailFilename: video.thumbnailFilename,
|
||||
videoPath: video.videoPath,
|
||||
thumbnailPath: video.thumbnailPath,
|
||||
thumbnailUrl: video.thumbnailUrl,
|
||||
addedAt: video.addedAt,
|
||||
createdAt: video.createdAt,
|
||||
updatedAt: video.updatedAt,
|
||||
partNumber: video.partNumber,
|
||||
totalParts: video.totalParts,
|
||||
seriesTitle: video.seriesTitle,
|
||||
rating: video.rating,
|
||||
description: video.description,
|
||||
viewCount: video.viewCount || 0,
|
||||
progress: video.progress || 0,
|
||||
duration: video.duration,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error migrating video ${video.id}:`, error);
|
||||
}
|
||||
|
||||
79
backend/scripts/update-durations.ts
Normal file
79
backend/scripts/update-durations.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { exec } from 'child_process';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { VIDEOS_DIR } from '../src/config/paths';
|
||||
import { db } from '../src/db';
|
||||
import { videos } from '../src/db/schema';
|
||||
|
||||
async function updateDurations() {
|
||||
console.log('Starting duration update...');
|
||||
|
||||
// Get all videos with missing duration
|
||||
// Note: We can't easily filter by isNull(videos.duration) if the column was just added and defaults to null,
|
||||
// but let's try to get all videos and check in JS if needed, or just update all.
|
||||
// Updating all is safer to ensure correctness.
|
||||
|
||||
const allVideos = await db.select().from(videos).all();
|
||||
console.log(`Found ${allVideos.length} videos.`);
|
||||
|
||||
let updatedCount = 0;
|
||||
|
||||
for (const video of allVideos) {
|
||||
if (video.duration) {
|
||||
// Skip if already has duration (optional: remove this check to force update)
|
||||
continue;
|
||||
}
|
||||
|
||||
let videoPath = video.videoPath;
|
||||
if (!videoPath) continue;
|
||||
|
||||
// Resolve absolute path
|
||||
// videoPath in DB is web path like "/videos/subdir/file.mp4"
|
||||
// We need filesystem path.
|
||||
// Assuming /videos maps to VIDEOS_DIR
|
||||
|
||||
let fsPath = '';
|
||||
if (videoPath.startsWith('/videos/')) {
|
||||
const relativePath = videoPath.replace('/videos/', '');
|
||||
fsPath = path.join(VIDEOS_DIR, relativePath);
|
||||
} else {
|
||||
// Fallback or other path structure
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fsPath)) {
|
||||
console.warn(`File not found: ${fsPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const duration = await new Promise<string>((resolve, reject) => {
|
||||
exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${fsPath}"`, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (duration) {
|
||||
const durationSec = parseFloat(duration);
|
||||
if (!isNaN(durationSec)) {
|
||||
await db.update(videos)
|
||||
.set({ duration: Math.round(durationSec).toString() })
|
||||
.where(eq(videos.id, video.id));
|
||||
console.log(`Updated duration for ${video.title}: ${Math.round(durationSec)}s`);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting duration for ${video.title}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Finished. Updated ${updatedCount} videos.`);
|
||||
}
|
||||
|
||||
updateDurations().catch(console.error);
|
||||
@@ -117,6 +117,28 @@ export const scanFiles = async (req: Request, res: Response): Promise<any> => {
|
||||
});
|
||||
});
|
||||
|
||||
// Get duration
|
||||
let duration = undefined;
|
||||
try {
|
||||
const durationOutput = await new Promise<string>((resolve, reject) => {
|
||||
exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
if (durationOutput) {
|
||||
const durationSec = parseFloat(durationOutput);
|
||||
if (!isNaN(durationSec)) {
|
||||
duration = Math.round(durationSec).toString();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error getting duration:", err);
|
||||
}
|
||||
|
||||
const newVideo = {
|
||||
id: videoId,
|
||||
title: path.parse(filename).name,
|
||||
@@ -131,6 +153,7 @@ export const scanFiles = async (req: Request, res: Response): Promise<any> => {
|
||||
createdAt: createdDate.toISOString(),
|
||||
addedAt: new Date().toISOString(),
|
||||
date: createdDate.toISOString().split('T')[0].replace(/-/g, ''),
|
||||
duration: duration,
|
||||
};
|
||||
|
||||
storageService.saveVideo(newVideo);
|
||||
|
||||
@@ -615,8 +615,55 @@ export const refreshThumbnail = async (req: Request, res: Response): Promise<any
|
||||
} catch (error: any) {
|
||||
console.error("Error refreshing thumbnail:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to refresh thumbnail",
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Increment view count
|
||||
export const incrementViewCount = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const video = storageService.getVideoById(id);
|
||||
|
||||
if (!video) {
|
||||
return res.status(404).json({ error: "Video not found" });
|
||||
}
|
||||
|
||||
const currentViews = video.viewCount || 0;
|
||||
const updatedVideo = storageService.updateVideo(id, { viewCount: currentViews + 1 });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
viewCount: updatedVideo?.viewCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error incrementing view count:", error);
|
||||
res.status(500).json({ error: "Failed to increment view count" });
|
||||
}
|
||||
};
|
||||
|
||||
// Update progress
|
||||
export const updateProgress = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { progress } = req.body;
|
||||
|
||||
if (typeof progress !== 'number') {
|
||||
return res.status(400).json({ error: "Progress must be a number" });
|
||||
}
|
||||
|
||||
const updatedVideo = storageService.updateVideo(id, { progress });
|
||||
|
||||
if (!updatedVideo) {
|
||||
return res.status(404).json({ error: "Video not found" });
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
progress: updatedVideo.progress
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating progress:", error);
|
||||
res.status(500).json({ error: "Failed to update progress" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ export const videos = sqliteTable('videos', {
|
||||
viewCount: integer('view_count'),
|
||||
duration: text('duration'),
|
||||
tags: text('tags'), // JSON stringified array of strings
|
||||
progress: integer('progress'), // Playback progress in seconds
|
||||
});
|
||||
|
||||
export const collections = sqliteTable('collections', {
|
||||
|
||||
@@ -16,6 +16,8 @@ router.delete("/videos/:id", videoController.deleteVideo);
|
||||
router.get("/videos/:id/comments", videoController.getVideoComments);
|
||||
router.post("/videos/:id/rate", videoController.rateVideo);
|
||||
router.post("/videos/:id/refresh-thumbnail", videoController.refreshThumbnail);
|
||||
router.post("/videos/:id/view", videoController.incrementViewCount);
|
||||
router.put("/videos/:id/progress", videoController.updateProgress);
|
||||
|
||||
router.post("/scan-files", scanController.scanFiles);
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface Video {
|
||||
thumbnailFilename?: string;
|
||||
createdAt: string;
|
||||
tags?: string[];
|
||||
viewCount?: number;
|
||||
progress?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -89,6 +91,32 @@ export function initializeStorage(): void {
|
||||
} catch (error) {
|
||||
console.error("Error checking/migrating tags column:", error);
|
||||
}
|
||||
|
||||
// Check and migrate viewCount and progress columns if needed
|
||||
try {
|
||||
const tableInfo = sqlite.prepare("PRAGMA table_info(videos)").all();
|
||||
const columns = (tableInfo as any[]).map((col: any) => col.name);
|
||||
|
||||
if (!columns.includes('view_count')) {
|
||||
console.log("Migrating database: Adding view_count column to videos table...");
|
||||
sqlite.prepare("ALTER TABLE videos ADD COLUMN view_count INTEGER DEFAULT 0").run();
|
||||
console.log("Migration successful: view_count added.");
|
||||
}
|
||||
|
||||
if (!columns.includes('progress')) {
|
||||
console.log("Migrating database: Adding progress column to videos table...");
|
||||
sqlite.prepare("ALTER TABLE videos ADD COLUMN progress INTEGER DEFAULT 0").run();
|
||||
console.log("Migration successful: progress added.");
|
||||
}
|
||||
|
||||
if (!columns.includes('duration')) {
|
||||
console.log("Migrating database: Adding duration column to videos table...");
|
||||
sqlite.prepare("ALTER TABLE videos ADD COLUMN duration TEXT").run();
|
||||
console.log("Migration successful: duration added.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking/migrating viewCount/progress/duration columns:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
0
data/mytube.db
Normal file
0
data/mytube.db
Normal file
@@ -60,6 +60,24 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// Format duration (seconds or MM:SS)
|
||||
const formatDuration = (duration: string | number | undefined) => {
|
||||
if (!duration) return null;
|
||||
|
||||
// If it's already a string with colon, assume it's formatted
|
||||
if (typeof duration === 'string' && duration.includes(':')) {
|
||||
return duration;
|
||||
}
|
||||
|
||||
// Otherwise treat as seconds
|
||||
const seconds = typeof duration === 'string' ? parseInt(duration, 10) : duration;
|
||||
if (isNaN(seconds)) return null;
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Use local thumbnail if available, otherwise fall back to the original URL
|
||||
const thumbnailSrc = video.thumbnailPath
|
||||
? `${BACKEND_URL}${video.thumbnailPath}`
|
||||
@@ -183,7 +201,23 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
label={`${t('part')} ${video.partNumber}/${video.totalParts}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ position: 'absolute', bottom: 8, right: 8 }}
|
||||
sx={{ position: 'absolute', bottom: 36, right: 8 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{video.duration && (
|
||||
<Chip
|
||||
label={formatDuration(video.duration)}
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
height: 20,
|
||||
fontSize: '0.75rem',
|
||||
bgcolor: 'rgba(0,0,0,0.8)',
|
||||
color: 'white'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -226,6 +260,9 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(video.date)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
|
||||
{video.viewCount || 0} {t('views')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
|
||||
@@ -24,12 +24,18 @@ interface VideoControlsProps {
|
||||
src: string;
|
||||
autoPlay?: boolean;
|
||||
autoLoop?: boolean;
|
||||
onTimeUpdate?: (currentTime: number) => void;
|
||||
onLoadedMetadata?: (duration: number) => void;
|
||||
startTime?: number;
|
||||
}
|
||||
|
||||
const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
src,
|
||||
autoPlay = false,
|
||||
autoLoop = false
|
||||
autoLoop = false,
|
||||
onTimeUpdate,
|
||||
onLoadedMetadata,
|
||||
startTime = 0
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
@@ -59,9 +65,28 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
const handleWebkitBeginFullscreen = () => {
|
||||
setIsFullscreen(true);
|
||||
};
|
||||
|
||||
const handleWebkitEndFullscreen = () => {
|
||||
setIsFullscreen(false);
|
||||
};
|
||||
|
||||
const videoElement = videoRef.current;
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
if (videoElement) {
|
||||
videoElement.addEventListener('webkitbeginfullscreen', handleWebkitBeginFullscreen);
|
||||
videoElement.addEventListener('webkitendfullscreen', handleWebkitEndFullscreen);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
if (videoElement) {
|
||||
videoElement.removeEventListener('webkitbeginfullscreen', handleWebkitBeginFullscreen);
|
||||
videoElement.removeEventListener('webkitendfullscreen', handleWebkitEndFullscreen);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -85,15 +110,26 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
|
||||
const handleToggleFullscreen = () => {
|
||||
const videoContainer = videoRef.current?.parentElement;
|
||||
if (!videoContainer) return;
|
||||
const videoElement = videoRef.current;
|
||||
|
||||
if (!videoContainer || !videoElement) return;
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
// Try standard fullscreen first (for Desktop, Android, iPad)
|
||||
if (videoContainer.requestFullscreen) {
|
||||
videoContainer.requestFullscreen().catch(err => {
|
||||
console.error(`Error attempting to enable fullscreen: ${err.message}`);
|
||||
});
|
||||
}
|
||||
// Fallback for iPhone Safari
|
||||
else if ((videoElement as any).webkitEnterFullscreen) {
|
||||
(videoElement as any).webkitEnterFullscreen();
|
||||
}
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (seconds: number) => {
|
||||
@@ -107,7 +143,7 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
<video
|
||||
ref={videoRef}
|
||||
style={{ width: '100%', aspectRatio: '16/9', display: 'block' }}
|
||||
controls={false} // We use custom controls, but maybe we should keep native controls as fallback or overlay? The original had controls={true} AND custom controls.
|
||||
controls={true} // Enable native controls as requested
|
||||
// The original code had `controls` attribute on the video tag, which enables native controls.
|
||||
// But it also rendered custom controls below it.
|
||||
// Let's keep it consistent with original: native controls enabled.
|
||||
@@ -115,6 +151,15 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
src={src}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onTimeUpdate={(e) => onTimeUpdate && onTimeUpdate(e.currentTarget.currentTime)}
|
||||
onLoadedMetadata={(e) => {
|
||||
if (startTime > 0) {
|
||||
e.currentTarget.currentTime = startTime;
|
||||
}
|
||||
if (onLoadedMetadata) {
|
||||
onLoadedMetadata(e.currentTarget.duration);
|
||||
}
|
||||
}}
|
||||
playsInline
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
|
||||
@@ -163,6 +163,9 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
<Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
|
||||
{video.rating ? `(${video.rating})` : t('rateThisVideo')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ ml: 2 }}>
|
||||
{video.viewCount || 0} {t('views')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import CollectionModal from '../components/VideoPlayer/CollectionModal';
|
||||
@@ -313,6 +313,50 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const [hasViewed, setHasViewed] = useState<boolean>(false);
|
||||
const lastProgressSave = useRef<number>(0);
|
||||
const currentTimeRef = useRef<number>(0);
|
||||
|
||||
// Reset hasViewed when video changes
|
||||
useEffect(() => {
|
||||
setHasViewed(false);
|
||||
currentTimeRef.current = 0;
|
||||
}, [id]);
|
||||
|
||||
// Save progress on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (id && currentTimeRef.current > 0) {
|
||||
axios.put(`${API_URL}/videos/${id}/progress`, { progress: Math.floor(currentTimeRef.current) })
|
||||
.catch(err => console.error('Error saving progress on unmount:', err));
|
||||
}
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const handleTimeUpdate = (currentTime: number) => {
|
||||
currentTimeRef.current = currentTime;
|
||||
|
||||
// Increment view count after 10 seconds
|
||||
if (currentTime > 10 && !hasViewed && id) {
|
||||
setHasViewed(true);
|
||||
axios.post(`${API_URL}/videos/${id}/view`)
|
||||
.then(res => {
|
||||
if (res.data.success && video) {
|
||||
setVideo({ ...video, viewCount: res.data.viewCount });
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Error incrementing view count:', err));
|
||||
}
|
||||
|
||||
// Save progress every 5 seconds
|
||||
const now = Date.now();
|
||||
if (now - lastProgressSave.current > 5000 && id) {
|
||||
lastProgressSave.current = now;
|
||||
axios.put(`${API_URL}/videos/${id}/progress`, { progress: Math.floor(currentTime) })
|
||||
.catch(err => console.error('Error saving progress:', err));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
||||
@@ -342,6 +386,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
src={`${BACKEND_URL}${video.videoPath || video.sourceUrl}`}
|
||||
autoPlay={autoPlay}
|
||||
autoLoop={autoLoop}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
startTime={video.progress || 0}
|
||||
/>
|
||||
|
||||
<VideoInfo
|
||||
@@ -415,6 +461,11 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
{formatDate(relatedVideo.date)}
|
||||
</Typography>
|
||||
{relatedVideo.viewCount !== undefined && (
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
{relatedVideo.viewCount} {t('views')}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -3,7 +3,7 @@ export interface Video {
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
source: 'youtube' | 'bilibili' | 'local';
|
||||
source: 'youtube' | 'bilibili' | 'local' | 'missav';
|
||||
sourceUrl: string;
|
||||
videoFilename?: string;
|
||||
thumbnailFilename?: string;
|
||||
@@ -16,6 +16,9 @@ export interface Video {
|
||||
seriesTitle?: string;
|
||||
rating?: number;
|
||||
tags?: string[];
|
||||
viewCount?: number;
|
||||
progress?: number;
|
||||
duration?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user