feat: Add functionality to fetch and display video comments
This commit is contained in:
@@ -3,12 +3,12 @@ import downloadManager from "../services/downloadManager";
|
|||||||
import * as downloadService from "../services/downloadService";
|
import * as downloadService from "../services/downloadService";
|
||||||
import * as storageService from "../services/storageService";
|
import * as storageService from "../services/storageService";
|
||||||
import {
|
import {
|
||||||
extractBilibiliVideoId,
|
extractBilibiliVideoId,
|
||||||
extractUrlFromText,
|
extractUrlFromText,
|
||||||
isBilibiliUrl,
|
isBilibiliUrl,
|
||||||
isValidUrl,
|
isValidUrl,
|
||||||
resolveShortUrl,
|
resolveShortUrl,
|
||||||
trimBilibiliUrl,
|
trimBilibiliUrl,
|
||||||
} from "../utils/helpers";
|
} from "../utils/helpers";
|
||||||
|
|
||||||
// Search for videos
|
// 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" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ router.post("/download", videoController.downloadVideo);
|
|||||||
router.get("/videos", videoController.getVideos);
|
router.get("/videos", videoController.getVideos);
|
||||||
router.get("/videos/:id", videoController.getVideoById);
|
router.get("/videos/:id", videoController.getVideoById);
|
||||||
router.delete("/videos/:id", videoController.deleteVideo);
|
router.delete("/videos/:id", videoController.deleteVideo);
|
||||||
|
router.get("/videos/:id/comments", videoController.getVideoComments);
|
||||||
|
|
||||||
router.get("/download-status", videoController.getDownloadStatus);
|
router.get("/download-status", videoController.getDownloadStatus);
|
||||||
router.get("/check-bilibili-parts", videoController.checkBilibiliParts);
|
router.get("/check-bilibili-parts", videoController.checkBilibiliParts);
|
||||||
router.get("/check-bilibili-collection", videoController.checkBilibiliCollection);
|
router.get("/check-bilibili-collection", videoController.checkBilibiliCollection);
|
||||||
|
|||||||
60
backend/src/services/commentService.ts
Normal file
60
backend/src/services/commentService.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
Add,
|
Add,
|
||||||
Delete,
|
Delete,
|
||||||
Folder
|
FastForward,
|
||||||
|
FastRewind,
|
||||||
|
Folder,
|
||||||
|
Forward10,
|
||||||
|
Loop,
|
||||||
|
Pause,
|
||||||
|
PlayArrow,
|
||||||
|
Replay10
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -30,10 +37,10 @@ import {
|
|||||||
useTheme
|
useTheme
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import ConfirmationModal from '../components/ConfirmationModal';
|
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 API_URL = import.meta.env.VITE_API_URL;
|
||||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_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 [newCollectionName, setNewCollectionName] = useState<string>('');
|
||||||
const [selectedCollection, setSelectedCollection] = useState<string>('');
|
const [selectedCollection, setSelectedCollection] = useState<string>('');
|
||||||
const [videoCollections, setVideoCollections] = useState<Collection[]>([]);
|
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
|
// Confirmation Modal State
|
||||||
const [confirmationModal, setConfirmationModal] = useState({
|
const [confirmationModal, setConfirmationModal] = useState({
|
||||||
@@ -79,6 +92,30 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
isDanger: false
|
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(() => {
|
useEffect(() => {
|
||||||
// Don't try to fetch the video if it's being deleted
|
// Don't try to fetch the video if it's being deleted
|
||||||
if (isDeleting) {
|
if (isDeleting) {
|
||||||
@@ -118,6 +155,26 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
fetchVideo();
|
fetchVideo();
|
||||||
}, [id, videos, navigate, isDeleting]);
|
}, [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
|
// Find collections that contain this video
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (collections && collections.length > 0 && id) {
|
if (collections && collections.length > 0 && id) {
|
||||||
@@ -273,13 +330,51 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
<Grid size={{ xs: 12, lg: 9 }}>
|
<Grid size={{ xs: 12, lg: 9 }}>
|
||||||
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: 2, overflow: 'hidden', boxShadow: 4 }}>
|
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: 2, overflow: 'hidden', boxShadow: 4 }}>
|
||||||
<video
|
<video
|
||||||
|
ref={videoRef}
|
||||||
style={{ width: '100%', aspectRatio: '16/9', display: 'block' }}
|
style={{ width: '100%', aspectRatio: '16/9', display: 'block' }}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
|
||||||
src={`${BACKEND_URL}${video.videoPath || video.sourceUrl}`}
|
src={`${BACKEND_URL}${video.videoPath || video.sourceUrl}`}
|
||||||
|
onPlay={() => setIsPlaying(true)}
|
||||||
|
onPause={() => setIsPlaying(false)}
|
||||||
>
|
>
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</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>
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
@@ -342,17 +437,23 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
<Divider sx={{ my: 2 }} />
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
<Box sx={{ bgcolor: 'background.paper', p: 2, borderRadius: 2 }}>
|
<Box sx={{ bgcolor: 'background.paper', p: 2, borderRadius: 2 }}>
|
||||||
<Typography variant="body2" paragraph>
|
<Stack direction="row" spacing={3} alignItems="center" flexWrap="wrap">
|
||||||
<strong>Source:</strong> {video.source === 'bilibili' ? 'Bilibili' : 'YouTube'}
|
{video.sourceUrl && (
|
||||||
</Typography>
|
<Typography variant="body2">
|
||||||
{video.sourceUrl && (
|
<a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: theme.palette.primary.main, textDecoration: 'none' }}>
|
||||||
<Typography variant="body2" paragraph>
|
<strong>Original Link</strong>
|
||||||
<strong>Original Link:</strong>{' '}
|
</a>
|
||||||
<a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: theme.palette.primary.main }}>
|
</Typography>
|
||||||
{video.sourceUrl}
|
)}
|
||||||
</a>
|
<Typography variant="body2">
|
||||||
|
<strong>Source:</strong> {video.source === 'bilibili' ? 'Bilibili' : 'YouTube'}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
{video.addedAt && (
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>Added Date:</strong> {new Date(video.addedAt).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
{videoCollections.length > 0 && (
|
{videoCollections.length > 0 && (
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
@@ -374,6 +475,46 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</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>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,11 @@ export interface DownloadInfo {
|
|||||||
title: string;
|
title: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
content: string;
|
||||||
|
date: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user