feat: Add functionality to fetch and display video comments

This commit is contained in:
Peifan Li
2025-11-22 22:55:28 -05:00
parent 0e2a0a791d
commit 8978c52047
5 changed files with 243 additions and 20 deletions

View File

@@ -3,12 +3,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";
// Search for videos
@@ -370,3 +370,15 @@ export const checkBilibiliCollection = async (req: Request, res: Response): Prom
});
}
};
// Get video comments
export const getVideoComments = async (req: Request, res: Response): Promise<any> => {
try {
const { id } = req.params;
const comments = await import("../services/commentService").then(m => m.getComments(id));
res.status(200).json(comments);
} catch (error) {
console.error("Error fetching video comments:", error);
res.status(500).json({ error: "Failed to fetch video comments" });
}
};

View File

@@ -10,6 +10,8 @@ router.post("/download", videoController.downloadVideo);
router.get("/videos", videoController.getVideos);
router.get("/videos/:id", videoController.getVideoById);
router.delete("/videos/:id", videoController.deleteVideo);
router.get("/videos/:id/comments", videoController.getVideoComments);
router.get("/download-status", videoController.getDownloadStatus);
router.get("/check-bilibili-parts", videoController.checkBilibiliParts);
router.get("/check-bilibili-collection", videoController.checkBilibiliCollection);

View File

@@ -0,0 +1,60 @@
import youtubedl from "youtube-dl-exec";
import * as storageService from "./storageService";
export interface Comment {
id: string;
author: string;
content: string;
date: string;
avatar?: string;
}
// Fetch comments for a video
export const getComments = async (videoId: string): Promise<Comment[]> => {
try {
const video = storageService.getVideoById(videoId);
if (!video) {
throw new Error("Video not found");
}
// Use youtube-dl for both Bilibili and YouTube as it's more reliable
return await getCommentsWithYoutubeDl(video.sourceUrl);
} catch (error) {
console.error("Error fetching comments:", error);
return [];
}
};
// Fetch comments using youtube-dl (works for YouTube and Bilibili)
const getCommentsWithYoutubeDl = async (url: string): Promise<Comment[]> => {
try {
console.log(`[CommentService] Fetching comments using youtube-dl for: ${url}`);
const output = await youtubedl(url, {
getComments: true,
dumpSingleJson: true,
noWarnings: true,
playlistEnd: 1, // Ensure we only process one video
extractorArgs: "youtube:max_comments=20,all_comments=false",
} as any);
const info = output as any;
if (info.comments) {
// Sort by date (newest first) and take top 10
// Note: youtube-dl comments structure might vary
return info.comments
.slice(0, 10)
.map((comment: any) => ({
id: comment.id,
author: comment.author.startsWith('@') ? comment.author.substring(1) : comment.author,
content: comment.text,
date: comment.timestamp ? new Date(comment.timestamp * 1000).toISOString().split('T')[0] : 'Unknown',
}));
}
return [];
} catch (error) {
console.error("Error fetching comments with youtube-dl:", error);
return [];
}
};

View File

@@ -1,7 +1,14 @@
import {
Add,
Delete,
Folder
FastForward,
FastRewind,
Folder,
Forward10,
Loop,
Pause,
PlayArrow,
Replay10
} from '@mui/icons-material';
import {
Alert,
@@ -30,10 +37,10 @@ import {
useTheme
} 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 { Collection, Video } from '../types';
import { Collection, Comment, Video } from '../types';
const API_URL = import.meta.env.VITE_API_URL;
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
@@ -68,6 +75,12 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const [newCollectionName, setNewCollectionName] = useState<string>('');
const [selectedCollection, setSelectedCollection] = useState<string>('');
const [videoCollections, setVideoCollections] = useState<Collection[]>([]);
const [comments, setComments] = useState<Comment[]>([]);
const [loadingComments, setLoadingComments] = useState<boolean>(false);
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isLooping, setIsLooping] = useState<boolean>(false);
// Confirmation Modal State
const [confirmationModal, setConfirmationModal] = useState({
@@ -79,6 +92,30 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
isDanger: false
});
const handlePlayPause = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const handleToggleLoop = () => {
if (videoRef.current) {
videoRef.current.loop = !isLooping;
setIsLooping(!isLooping);
}
};
const handleSeek = (seconds: number) => {
if (videoRef.current) {
videoRef.current.currentTime += seconds;
}
};
useEffect(() => {
// Don't try to fetch the video if it's being deleted
if (isDeleting) {
@@ -118,6 +155,26 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
fetchVideo();
}, [id, videos, navigate, isDeleting]);
// Fetch comments
useEffect(() => {
const fetchComments = async () => {
if (!id) return;
setLoadingComments(true);
try {
const response = await axios.get(`${API_URL}/videos/${id}/comments`);
setComments(response.data);
} catch (err) {
console.error('Error fetching comments:', err);
// We don't set a global error here as comments are secondary
} finally {
setLoadingComments(false);
}
};
fetchComments();
}, [id]);
// Find collections that contain this video
useEffect(() => {
if (collections && collections.length > 0 && id) {
@@ -273,13 +330,51 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
<Grid size={{ xs: 12, lg: 9 }}>
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: 2, overflow: 'hidden', boxShadow: 4 }}>
<video
ref={videoRef}
style={{ width: '100%', aspectRatio: '16/9', display: 'block' }}
controls
autoPlay
src={`${BACKEND_URL}${video.videoPath || video.sourceUrl}`}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
>
Your browser does not support the video tag.
</video>
{/* Custom Controls Area */}
<Box sx={{ p: 1, display: 'flex', justifyContent: 'center', gap: 2, bgcolor: '#1a1a1a' }}>
<Button
variant="contained"
color={isPlaying ? "warning" : "primary"}
onClick={handlePlayPause}
startIcon={isPlaying ? <Pause /> : <PlayArrow />}
>
{isPlaying ? "Pause" : "Play"}
</Button>
<Button
variant={isLooping ? "contained" : "outlined"}
color="secondary"
onClick={handleToggleLoop}
startIcon={<Loop />}
>
Loop {isLooping ? "On" : "Off"}
</Button>
<Stack direction="row" spacing={1}>
<Button variant="outlined" onClick={() => handleSeek(-60)} startIcon={<FastRewind />}>
-1m
</Button>
<Button variant="outlined" onClick={() => handleSeek(-10)} startIcon={<Replay10 />}>
-10s
</Button>
<Button variant="outlined" onClick={() => handleSeek(10)} endIcon={<Forward10 />}>
+10s
</Button>
<Button variant="outlined" onClick={() => handleSeek(60)} endIcon={<FastForward />}>
+1m
</Button>
</Stack>
</Box>
</Box>
<Box sx={{ mt: 2 }}>
@@ -342,17 +437,23 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
<Divider sx={{ my: 2 }} />
<Box sx={{ bgcolor: 'background.paper', p: 2, borderRadius: 2 }}>
<Typography variant="body2" paragraph>
<strong>Source:</strong> {video.source === 'bilibili' ? 'Bilibili' : 'YouTube'}
</Typography>
{video.sourceUrl && (
<Typography variant="body2" paragraph>
<strong>Original Link:</strong>{' '}
<a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: theme.palette.primary.main }}>
{video.sourceUrl}
</a>
<Stack direction="row" spacing={3} alignItems="center" flexWrap="wrap">
{video.sourceUrl && (
<Typography variant="body2">
<a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: theme.palette.primary.main, textDecoration: 'none' }}>
<strong>Original Link</strong>
</a>
</Typography>
)}
<Typography variant="body2">
<strong>Source:</strong> {video.source === 'bilibili' ? 'Bilibili' : 'YouTube'}
</Typography>
)}
{video.addedAt && (
<Typography variant="body2">
<strong>Added Date:</strong> {new Date(video.addedAt).toLocaleDateString()}
</Typography>
)}
</Stack>
{videoCollections.length > 0 && (
<Box sx={{ mt: 2 }}>
@@ -374,6 +475,46 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
</Box>
)}
</Box>
{/* Comments Section */}
<Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom fontWeight="bold">
Latest Comments
</Typography>
{loadingComments ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={24} />
</Box>
) : comments.length > 0 ? (
<Stack spacing={2}>
{comments.map((comment) => (
<Box key={comment.id} sx={{ display: 'flex', gap: 2 }}>
<Avatar src={comment.avatar} alt={comment.author}>
{comment.author.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" fontWeight="bold">
{comment.author}
</Typography>
<Typography variant="caption" color="text.secondary">
{comment.date}
</Typography>
</Box>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{comment.content}
</Typography>
</Box>
</Box>
))}
</Stack>
) : (
<Typography variant="body2" color="text.secondary">
No comments available.
</Typography>
)}
</Box>
</Box>
</Grid>

View File

@@ -31,3 +31,11 @@ export interface DownloadInfo {
title: string;
timestamp?: number;
}
export interface Comment {
id: string;
author: string;
content: string;
date: string;
avatar?: string;
}