feat: Add view count and progress tracking for videos

This commit is contained in:
Peifan Li
2025-11-26 12:03:28 -05:00
parent f021fd4655
commit ecc17875ef
13 changed files with 365 additions and 20 deletions

View File

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

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

View File

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

View File

@@ -8,12 +8,12 @@ import downloadManager from "../services/downloadManager";
import * as downloadService from "../services/downloadService";
import * as storageService from "../services/storageService";
import {
extractBilibiliVideoId,
extractUrlFromText,
isBilibiliUrl,
isValidUrl,
resolveShortUrl,
trimBilibiliUrl
extractBilibiliVideoId,
extractUrlFromText,
isBilibiliUrl,
isValidUrl,
resolveShortUrl,
trimBilibiliUrl
} from "../utils/helpers";
// Configure Multer for file uploads
@@ -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" });
}
};

View File

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

View File

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

View File

@@ -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
View File

View 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>

View File

@@ -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,14 +110,25 @@ 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) {
videoContainer.requestFullscreen().catch(err => {
console.error(`Error attempting to enable fullscreen: ${err.message}`);
});
// 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 {
document.exitFullscreen();
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
};
@@ -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.

View File

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

View File

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

View File

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