feat: Add subscribe functionality to VideoPlayer page

This commit is contained in:
Peifan Li
2025-12-16 14:42:19 -05:00
parent a7a4eae713
commit 4624d121b7
8 changed files with 358 additions and 53 deletions

View File

@@ -12,8 +12,8 @@ export const createSubscription = async (
req: Request,
res: Response
): Promise<void> => {
const { url, interval } = req.body;
logger.info("Creating subscription:", { url, interval });
const { url, interval, authorName } = req.body;
logger.info("Creating subscription:", { url, interval, authorName });
if (!url || !interval) {
throw new ValidationError("URL and interval are required", "body");
@@ -21,7 +21,8 @@ export const createSubscription = async (
const subscription = await subscriptionService.subscribe(
url,
parseInt(interval)
parseInt(interval),
authorName
);
// Return subscription object directly for backward compatibility
res.status(201).json(subscription);

View File

@@ -225,7 +225,11 @@ export const getAuthorChannelUrl = async (
try {
// Check if it's a YouTube URL
if (sourceUrl.includes("youtube.com") || sourceUrl.includes("youtu.be")) {
const { executeYtDlpJson, getNetworkConfigFromUserConfig, getUserYtDlpConfig } = await import("../utils/ytDlpUtils");
const {
executeYtDlpJson,
getNetworkConfigFromUserConfig,
getUserYtDlpConfig,
} = await import("../utils/ytDlpUtils");
const userConfig = getUserYtDlpConfig(sourceUrl);
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
@@ -245,7 +249,7 @@ export const getAuthorChannelUrl = async (
if (sourceUrl.includes("bilibili.com") || sourceUrl.includes("b23.tv")) {
const axios = (await import("axios")).default;
const { extractBilibiliVideoId } = await import("../utils/helpers");
const videoId = extractBilibiliVideoId(sourceUrl);
if (videoId) {
try {
@@ -253,16 +257,24 @@ export const getAuthorChannelUrl = async (
const isBvId = videoId.startsWith("BV");
const apiUrl = isBvId
? `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`
: `https://api.bilibili.com/x/web-interface/view?aid=${videoId.replace("av", "")}`;
: `https://api.bilibili.com/x/web-interface/view?aid=${videoId.replace(
"av",
""
)}`;
const response = await axios.get(apiUrl, {
headers: {
Referer: "https://www.bilibili.com",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
},
});
if (response.data && response.data.data && response.data.data.owner?.mid) {
if (
response.data &&
response.data.data &&
response.data.data.owner?.mid
) {
const mid = response.data.data.owner.mid;
const spaceUrl = `https://space.bilibili.com/${mid}`;
res.status(200).json({ success: true, channelUrl: spaceUrl });

View File

@@ -39,42 +39,148 @@ export class SubscriptionService {
return SubscriptionService.instance;
}
async subscribe(authorUrl: string, interval: number): Promise<Subscription> {
async subscribe(
authorUrl: string,
interval: number,
providedAuthorName?: string
): Promise<Subscription> {
// Detect platform and validate URL
let platform: string;
let authorName = "Unknown Author";
let authorName = providedAuthorName || "Unknown Author";
if (isBilibiliSpaceUrl(authorUrl)) {
platform = "Bilibili";
// Extract mid from the space URL
const mid = extractBilibiliMid(authorUrl);
if (!mid) {
throw ValidationError.invalidBilibiliSpaceUrl(authorUrl);
}
// If author name not provided, try to get it from Bilibili API
if (!providedAuthorName) {
// Extract mid from the space URL
const mid = extractBilibiliMid(authorUrl);
if (!mid) {
throw ValidationError.invalidBilibiliSpaceUrl(authorUrl);
}
// Try to get author name from Bilibili API
try {
const authorInfo = await BilibiliDownloader.getAuthorInfo(mid);
authorName = authorInfo.name;
} catch (error) {
logger.error("Error fetching Bilibili author info:", error);
// Use mid as fallback author name
authorName = `Bilibili User ${mid}`;
// Try to get author name from Bilibili API
try {
const authorInfo = await BilibiliDownloader.getAuthorInfo(mid);
authorName = authorInfo.name;
} catch (error) {
logger.error("Error fetching Bilibili author info:", error);
// Use mid as fallback author name
authorName = `Bilibili User ${mid}`;
}
}
} else if (authorUrl.includes("youtube.com")) {
platform = "YouTube";
// Extract author from YouTube URL if possible
const match = authorUrl.match(/youtube\.com\/(@[^\/]+)/);
if (match && match[1]) {
authorName = match[1];
} else {
// Fallback: try to extract from other URL formats
const parts = authorUrl.split("/");
if (parts.length > 0) {
const lastPart = parts[parts.length - 1];
if (lastPart) authorName = lastPart;
// If author name not provided, try to get it from channel URL using yt-dlp
if (!providedAuthorName) {
try {
const {
executeYtDlpJson,
getNetworkConfigFromUserConfig,
getUserYtDlpConfig,
} = await import("../utils/ytDlpUtils");
const userConfig = getUserYtDlpConfig(authorUrl);
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
// Construct URL to get videos from the channel
let targetUrl = authorUrl;
if (
!targetUrl.includes("/videos") &&
!targetUrl.includes("/shorts") &&
!targetUrl.includes("/streams")
) {
// Append /videos to get the videos playlist
if (targetUrl.endsWith("/")) {
targetUrl = `${targetUrl}videos`;
} else {
targetUrl = `${targetUrl}/videos`;
}
}
// Try to get channel info from the channel URL
const info = await executeYtDlpJson(targetUrl, {
...networkConfig,
noWarnings: true,
flatPlaylist: true,
playlistEnd: 1,
});
// Try to get uploader/channel name from the first video or channel info
if (info.uploader) {
authorName = info.uploader;
} else if (info.channel) {
authorName = info.channel;
} else if (
info.channel_id &&
info.entries &&
info.entries.length > 0
) {
// If we have entries, try to get info from the first video
const firstVideo = info.entries[0];
if (firstVideo && firstVideo.url) {
try {
const videoInfo = await executeYtDlpJson(firstVideo.url, {
...networkConfig,
noWarnings: true,
});
if (videoInfo.uploader) {
authorName = videoInfo.uploader;
} else if (videoInfo.channel) {
authorName = videoInfo.channel;
}
} catch (videoError) {
logger.error(
"Error fetching video info for channel name:",
videoError
);
}
}
}
// Fallback: try to extract from URL if still not found
if (
authorName === "Unknown Author" ||
authorName === providedAuthorName
) {
const match = authorUrl.match(/youtube\.com\/(@[^\/]+)/);
if (match && match[1]) {
authorName = match[1];
} else {
const parts = authorUrl.split("/");
if (parts.length > 0) {
const lastPart = parts[parts.length - 1];
if (
lastPart &&
lastPart !== "videos" &&
lastPart !== "about" &&
lastPart !== "channel"
) {
authorName = lastPart;
}
}
}
}
} catch (error) {
logger.error("Error fetching YouTube channel info:", error);
// Fallback: try to extract from URL
const match = authorUrl.match(/youtube\.com\/(@[^\/]+)/);
if (match && match[1]) {
authorName = match[1];
} else {
const parts = authorUrl.split("/");
if (parts.length > 0) {
const lastPart = parts[parts.length - 1];
if (
lastPart &&
lastPart !== "videos" &&
lastPart !== "about" &&
lastPart !== "channel"
) {
authorName = lastPart;
}
}
}
}
}
} else {

View File

@@ -25,6 +25,9 @@ interface VideoInfoProps {
onCollectionClick: (id: string) => void;
availableTags: string[];
onTagsUpdate: (tags: string[]) => Promise<void>;
isSubscribed?: boolean;
onSubscribe?: () => void;
onUnsubscribe?: () => void;
}
const VideoInfo: React.FC<VideoInfoProps> = ({
@@ -39,7 +42,10 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
videoCollections,
onCollectionClick,
availableTags,
onTagsUpdate
onTagsUpdate,
isSubscribed,
onSubscribe,
onUnsubscribe
}) => {
const { videoRef, videoResolution } = useVideoResolution(video);
@@ -89,6 +95,10 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
author={video.author}
date={video.date}
onAuthorClick={onAuthorClick}
source={video.source}
isSubscribed={isSubscribed}
onSubscribe={onSubscribe}
onUnsubscribe={onUnsubscribe}
/>
<VideoActionButtons

View File

@@ -1,10 +1,16 @@
import { Avatar, Box, Typography } from '@mui/material';
import { Notifications, NotificationsActive } from '@mui/icons-material';
import { Avatar, Box, IconButton, Tooltip, Typography } from '@mui/material';
import React from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
interface VideoAuthorInfoProps {
author: string;
date: string | undefined;
onAuthorClick: () => void;
source?: 'youtube' | 'bilibili' | 'local' | 'missav';
isSubscribed?: boolean;
onSubscribe?: () => void;
onUnsubscribe?: () => void;
}
// Format the date (assuming format YYYYMMDD from youtube-dl)
@@ -20,18 +26,53 @@ const formatDate = (dateString?: string) => {
return `${year}-${month}-${day}`;
};
const VideoAuthorInfo: React.FC<VideoAuthorInfoProps> = ({ author, date, onAuthorClick }) => {
const VideoAuthorInfo: React.FC<VideoAuthorInfoProps> = ({
author,
date,
onAuthorClick,
source,
isSubscribed,
onSubscribe,
onUnsubscribe
}) => {
const { t } = useLanguage();
const showSubscribeButton = source === 'youtube' || source === 'bilibili';
const handleSubscribeClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (isSubscribed && onUnsubscribe) {
onUnsubscribe();
} else if (!isSubscribed && onSubscribe) {
onSubscribe();
}
};
return (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar sx={{ bgcolor: 'primary.main', mr: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar
sx={{
bgcolor: 'primary.main',
mr: { xs: 1, sm: 2 },
cursor: 'pointer',
'&:hover': { opacity: 0.8 }
}}
onClick={onAuthorClick}
>
{author ? author.charAt(0).toUpperCase() : 'A'}
</Avatar>
<Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="subtitle1"
fontWeight="bold"
onClick={onAuthorClick}
sx={{ cursor: 'pointer', '&:hover': { color: 'primary.main' } }}
sx={{
cursor: 'pointer',
'&:hover': { color: 'primary.main' },
maxWidth: { xs: '120px', sm: 'none' },
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{author}
</Typography>
@@ -39,6 +80,18 @@ const VideoAuthorInfo: React.FC<VideoAuthorInfoProps> = ({ author, date, onAutho
{formatDate(date)}
</Typography>
</Box>
{showSubscribeButton && (
<Tooltip title={isSubscribed ? t('unsubscribe') : t('subscribe')}>
<IconButton
size="small"
onClick={handleSubscribeClick}
color={isSubscribed ? 'primary' : 'default'}
sx={{ ml: { xs: 0, sm: 1 } }}
>
{isSubscribed ? <NotificationsActive /> : <Notifications />}
</IconButton>
</Tooltip>
)}
</Box>
);
};

View File

@@ -1,6 +1,6 @@
import { LocalOffer } from '@mui/icons-material';
import { Autocomplete, Box, Chip, TextField } from '@mui/material';
import React from 'react';
import React, { useState } from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
interface VideoTagsProps {
@@ -11,6 +11,7 @@ interface VideoTagsProps {
const VideoTags: React.FC<VideoTagsProps> = ({ tags, availableTags, onTagsUpdate }) => {
const { t } = useLanguage();
const [open, setOpen] = useState(false);
// Ensure tags and availableTags are always arrays
const tagsArray = Array.isArray(tags) ? tags : [];
@@ -21,6 +22,10 @@ const VideoTags: React.FC<VideoTagsProps> = ({ tags, availableTags, onTagsUpdate
<LocalOffer color="action" fontSize="small" />
<Autocomplete
multiple
open={open}
onOpen={() => setOpen(true)}
onClose={() => setOpen(false)}
disableCloseOnSelect
options={availableTagsArray}
value={tagsArray}
isOptionEqualToValue={(option, value) => option === value}
@@ -57,7 +62,11 @@ const VideoTags: React.FC<VideoTagsProps> = ({ tags, availableTags, onTagsUpdate
placeholder={tagsArray.length === 0 ? (t('tags') || 'Tags') : ''}
sx={{ minWidth: 200 }}
slotProps={{
input: { ...params.InputProps, disableUnderline: true }
input: {
...params.InputProps,
disableUnderline: true,
readOnly: true
}
}}
/>
)}

View File

@@ -39,7 +39,7 @@ html {
/* Smooth theme transition */
*, *::before, *::after {
transition-property: background-color, color, border-color, fill, stroke, box-shadow;
transition-property: background-color, border-color, fill, stroke, box-shadow;
transition-duration: 0.3s;
transition-timing-function: ease-out;
}

View File

@@ -12,6 +12,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import CollectionModal from '../components/CollectionModal';
import ConfirmationModal from '../components/ConfirmationModal';
import SubscribeModal from '../components/SubscribeModal';
import CommentsSection from '../components/VideoPlayer/CommentsSection';
import UpNextSidebar from '../components/VideoPlayer/UpNextSidebar';
import VideoControls from '../components/VideoPlayer/VideoControls';
@@ -44,6 +45,8 @@ const VideoPlayer: React.FC = () => {
const [videoCollections, setVideoCollections] = useState<Collection[]>([]);
const [activeCollectionVideoId, setActiveCollectionVideoId] = useState<string | null>(null);
const [showComments, setShowComments] = useState<boolean>(false);
const [showSubscribeModal, setShowSubscribeModal] = useState<boolean>(false);
const [authorChannelUrl, setAuthorChannelUrl] = useState<string | null>(null);
const [autoPlayNext, setAutoPlayNext] = useState<boolean>(() => {
const saved = localStorage.getItem('autoPlayNext');
return saved !== null ? JSON.parse(saved) : false;
@@ -112,6 +115,15 @@ const VideoPlayer: React.FC = () => {
enabled: showComments && !!id
});
// Fetch subscriptions
const { data: subscriptions = [] } = useQuery({
queryKey: ['subscriptions'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/subscriptions`);
return response.data;
}
});
const handleToggleComments = () => {
setShowComments(!showComments);
};
@@ -138,25 +150,60 @@ const VideoPlayer: React.FC = () => {
return [];
}, [collections, activeCollectionVideoId]);
// Handle navigation to author videos page or external channel
const handleAuthorClick = async () => {
if (!video) return;
// Get author channel URL and check subscription status
useEffect(() => {
const fetchChannelUrl = async () => {
if (!video || (video.source !== 'youtube' && video.source !== 'bilibili')) {
setAuthorChannelUrl(null);
return;
}
// If it's a YouTube or Bilibili video, try to get the channel URL
if (video.source === 'youtube' || video.source === 'bilibili') {
try {
const response = await axios.get(`${API_URL}/videos/author-channel-url`, {
params: { sourceUrl: video.sourceUrl }
});
if (response.data.success && response.data.channelUrl) {
// Open the channel URL in a new tab
window.open(response.data.channelUrl, '_blank', 'noopener,noreferrer');
return;
setAuthorChannelUrl(response.data.channelUrl);
} else {
setAuthorChannelUrl(null);
}
} catch (error) {
console.error('Error fetching author channel URL:', error);
// Fall through to default behavior
setAuthorChannelUrl(null);
}
};
fetchChannelUrl();
}, [video]);
// Check if author is subscribed
const isSubscribed = useMemo(() => {
if (!authorChannelUrl || !subscriptions || subscriptions.length === 0) {
return false;
}
return subscriptions.some((sub: any) => sub.authorUrl === authorChannelUrl);
}, [authorChannelUrl, subscriptions]);
// Get subscription ID if subscribed
const subscriptionId = useMemo(() => {
if (!authorChannelUrl || !subscriptions || subscriptions.length === 0) {
return null;
}
const subscription = subscriptions.find((sub: any) => sub.authorUrl === authorChannelUrl);
return subscription?.id || null;
}, [authorChannelUrl, subscriptions]);
// Handle navigation to author videos page or external channel
const handleAuthorClick = async () => {
if (!video) return;
// If it's a YouTube or Bilibili video, try to get the channel URL
if (video.source === 'youtube' || video.source === 'bilibili') {
if (authorChannelUrl) {
// Open the channel URL in a new tab
window.open(authorChannelUrl, '_blank', 'noopener,noreferrer');
return;
}
}
@@ -164,6 +211,62 @@ const VideoPlayer: React.FC = () => {
navigate(`/author/${encodeURIComponent(video.author)}`);
};
// Handle subscribe
const handleSubscribe = () => {
if (!authorChannelUrl) return;
setShowSubscribeModal(true);
};
// Handle subscribe confirmation
const handleSubscribeConfirm = async (interval: number) => {
if (!authorChannelUrl || !video) return;
try {
await axios.post(`${API_URL}/subscriptions`, {
url: authorChannelUrl,
interval,
authorName: video.author // Pass the author name from the video
});
showSnackbar(t('subscribedSuccessfully'));
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
setShowSubscribeModal(false);
} catch (error: any) {
console.error('Error subscribing:', error);
if (error.response && error.response.status === 409) {
showSnackbar(t('subscriptionAlreadyExists'), 'warning');
} else {
showSnackbar(t('error'), 'error');
}
setShowSubscribeModal(false);
}
};
// Handle unsubscribe
const handleUnsubscribe = () => {
if (!subscriptionId) return;
setConfirmationModal({
isOpen: true,
title: t('unsubscribe'),
message: t('confirmUnsubscribe', { author: video?.author || '' }),
onConfirm: async () => {
try {
await axios.delete(`${API_URL}/subscriptions/${subscriptionId}`);
showSnackbar(t('unsubscribedSuccessfully'));
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
setConfirmationModal({ ...confirmationModal, isOpen: false });
} catch (error) {
console.error('Error unsubscribing:', error);
showSnackbar(t('error'), 'error');
setConfirmationModal({ ...confirmationModal, isOpen: false });
}
},
confirmText: t('unsubscribe'),
cancelText: t('cancel'),
isDanger: true
});
};
const handleCollectionClick = (collectionId: string) => {
navigate(`/collection/${collectionId}`);
};
@@ -462,6 +565,9 @@ const VideoPlayer: React.FC = () => {
onCollectionClick={handleCollectionClick}
availableTags={availableTags}
onTagsUpdate={handleUpdateTags}
isSubscribed={isSubscribed}
onSubscribe={handleSubscribe}
onUnsubscribe={handleUnsubscribe}
/>
{(video.source === 'youtube' || video.source === 'bilibili') && (
@@ -507,6 +613,14 @@ const VideoPlayer: React.FC = () => {
cancelText={confirmationModal.cancelText}
isDanger={confirmationModal.isDanger}
/>
<SubscribeModal
open={showSubscribeModal}
onClose={() => setShowSubscribeModal(false)}
onConfirm={handleSubscribeConfirm}
authorName={video?.author}
url={authorChannelUrl || ''}
/>
</Container>
);
};