feat: Add Bilibili collection handling functionality

This commit is contained in:
Peifan Li
2025-11-21 18:22:50 -05:00
parent ff265d9088
commit 13f352d041
10 changed files with 931 additions and 178 deletions

View File

@@ -36,7 +36,7 @@ const downloadManager = require("../services/downloadManager");
// Download video
const downloadVideo = async (req, res) => {
try {
const { youtubeUrl, downloadAllParts, collectionName } = req.body;
const { youtubeUrl, downloadAllParts, collectionName, downloadCollection, collectionInfo } = req.body;
let videoUrl = youtubeUrl;
if (!videoUrl) {
@@ -83,6 +83,28 @@ const downloadVideo = async (req, res) => {
videoUrl = trimBilibiliUrl(videoUrl);
console.log("Using trimmed Bilibili URL:", videoUrl);
// If downloadCollection is true, handle collection/series download
if (downloadCollection && collectionInfo) {
console.log("Downloading Bilibili collection/series");
const result = await downloadService.downloadBilibiliCollection(
collectionInfo,
collectionName,
downloadId
);
if (result.success) {
return {
success: true,
collectionId: result.collectionId,
videosDownloaded: result.videosDownloaded,
isCollection: true
};
} else {
throw new Error(result.error || "Failed to download collection/series");
}
}
// If downloadAllParts is true, handle multi-part download
if (downloadAllParts) {
const videoId = extractBilibiliVideoId(videoUrl);
@@ -310,6 +332,51 @@ const checkBilibiliParts = async (req, res) => {
}
};
// Check if Bilibili URL is a collection or series
const checkBilibiliCollection = async (req, res) => {
try {
const { url } = req.query;
if (!url) {
return res.status(400).json({ error: "URL is required" });
}
if (!isBilibiliUrl(url)) {
return res.status(400).json({ error: "Not a valid Bilibili URL" });
}
// Resolve shortened URLs (like b23.tv)
let videoUrl = url;
if (videoUrl.includes("b23.tv")) {
videoUrl = await resolveShortUrl(videoUrl);
console.log("Resolved shortened URL to:", videoUrl);
}
// Trim Bilibili URL if needed
videoUrl = trimBilibiliUrl(videoUrl);
// Extract video ID
const videoId = extractBilibiliVideoId(videoUrl);
if (!videoId) {
return res
.status(400)
.json({ error: "Could not extract Bilibili video ID" });
}
// Check if it's a collection or series
const result = await downloadService.checkBilibiliCollectionOrSeries(videoId);
res.status(200).json(result);
} catch (error) {
console.error("Error checking Bilibili collection/series:", error);
res.status(500).json({
error: "Failed to check Bilibili collection/series",
details: error.message,
});
}
};
module.exports = {
searchVideos,
downloadVideo,
@@ -318,4 +385,5 @@ module.exports = {
deleteVideo,
getDownloadStatus,
checkBilibiliParts,
checkBilibiliCollection,
};

View File

@@ -11,6 +11,7 @@ router.get("/videos/:id", videoController.getVideoById);
router.delete("/videos/:id", videoController.deleteVideo);
router.get("/download-status", videoController.getDownloadStatus);
router.get("/check-bilibili-parts", videoController.checkBilibiliParts);
router.get("/check-bilibili-collection", videoController.checkBilibiliCollection);
// Collection routes
router.get("/collections", collectionController.getCollections);

View File

@@ -175,6 +175,169 @@ async function checkBilibiliVideoParts(videoId) {
}
}
// Helper function to check if a Bilibili video belongs to a collection or series
async function checkBilibiliCollectionOrSeries(videoId) {
try {
const apiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`;
console.log("Checking if video belongs to collection/series:", apiUrl);
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'
}
});
if (response.data && response.data.data) {
const videoInfo = response.data.data;
const mid = videoInfo.owner?.mid;
// Check for collection (ugc_season)
if (videoInfo.ugc_season) {
const season = videoInfo.ugc_season;
console.log(`Video belongs to collection: ${season.title}`);
return {
success: true,
type: 'collection',
id: season.id,
title: season.title,
count: season.ep_count || 0,
mid: mid
};
}
// If no collection found, return none
return { success: true, type: 'none' };
}
return { success: false, type: 'none' };
} catch (error) {
console.error("Error checking collection/series:", error);
return { success: false, type: 'none' };
}
}
// Helper function to get all videos from a Bilibili collection
async function getBilibiliCollectionVideos(mid, seasonId) {
try {
const allVideos = [];
let pageNum = 1;
const pageSize = 30;
let hasMore = true;
console.log(`Fetching collection videos for mid=${mid}, season_id=${seasonId}`);
while (hasMore) {
const apiUrl = `https://api.bilibili.com/x/polymer/web-space/seasons_archives_list`;
const params = {
mid: mid,
season_id: seasonId,
page_num: pageNum,
page_size: pageSize,
sort_reverse: false
};
console.log(`Fetching page ${pageNum} of collection...`);
const response = await axios.get(apiUrl, {
params,
headers: {
'Referer': 'https://www.bilibili.com',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
}
});
if (response.data && response.data.data) {
const data = response.data.data;
const archives = data.archives || [];
console.log(`Got ${archives.length} videos from page ${pageNum}`);
archives.forEach(video => {
allVideos.push({
bvid: video.bvid,
title: video.title,
aid: video.aid
});
});
// Check if there are more pages
const total = data.page?.total || 0;
hasMore = allVideos.length < total;
pageNum++;
} else {
hasMore = false;
}
}
console.log(`Total videos in collection: ${allVideos.length}`);
return { success: true, videos: allVideos };
} catch (error) {
console.error("Error fetching collection videos:", error);
return { success: false, videos: [] };
}
}
// Helper function to get all videos from a Bilibili series
async function getBilibiliSeriesVideos(mid, seriesId) {
try {
const allVideos = [];
let pageNum = 1;
const pageSize = 30;
let hasMore = true;
console.log(`Fetching series videos for mid=${mid}, series_id=${seriesId}`);
while (hasMore) {
const apiUrl = `https://api.bilibili.com/x/series/archives`;
const params = {
mid: mid,
series_id: seriesId,
pn: pageNum,
ps: pageSize
};
console.log(`Fetching page ${pageNum} of series...`);
const response = await axios.get(apiUrl, {
params,
headers: {
'Referer': 'https://www.bilibili.com',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
}
});
if (response.data && response.data.data) {
const data = response.data.data;
const archives = data.archives || [];
console.log(`Got ${archives.length} videos from page ${pageNum}`);
archives.forEach(video => {
allVideos.push({
bvid: video.bvid,
title: video.title,
aid: video.aid
});
});
// Check if there are more pages
const page = data.page || {};
hasMore = archives.length === pageSize && allVideos.length < (page.total || 0);
pageNum++;
} else {
hasMore = false;
}
}
console.log(`Total videos in series: ${allVideos.length}`);
return { success: true, videos: allVideos };
} catch (error) {
console.error("Error fetching series videos:", error);
return { success: false, videos: [] };
}
}
// Helper function to download a single Bilibili part
async function downloadSingleBilibiliPart(
url,
@@ -288,6 +451,125 @@ async function downloadSingleBilibiliPart(
}
}
// Helper function to download all videos from a Bilibili collection or series
async function downloadBilibiliCollection(
collectionInfo,
collectionName,
downloadId
) {
try {
const { type, id, mid, title, count } = collectionInfo;
console.log(`Starting download of ${type}: ${title} (${count} videos)`);
// Add to active downloads
if (downloadId) {
storageService.addActiveDownload(
downloadId,
`Downloading ${type}: ${title}`
);
}
// Fetch all videos from the collection/series
let videosResult;
if (type === 'collection') {
videosResult = await getBilibiliCollectionVideos(mid, id);
} else if (type === 'series') {
videosResult = await getBilibiliSeriesVideos(mid, id);
} else {
throw new Error(`Unknown type: ${type}`);
}
if (!videosResult.success || videosResult.videos.length === 0) {
throw new Error(`Failed to fetch videos from ${type}`);
}
const videos = videosResult.videos;
console.log(`Found ${videos.length} videos to download`);
// Create a MyTube collection for these videos
const mytubeCollection = {
id: Date.now().toString(),
name: collectionName || title,
videos: [],
createdAt: new Date().toISOString(),
};
storageService.saveCollection(mytubeCollection);
const mytubeCollectionId = mytubeCollection.id;
console.log(`Created MyTube collection: ${mytubeCollection.name}`);
// Download each video sequentially
for (let i = 0; i < videos.length; i++) {
const video = videos[i];
const videoNumber = i + 1;
// Update status
if (downloadId) {
storageService.addActiveDownload(
downloadId,
`Downloading ${videoNumber}/${videos.length}: ${video.title}`
);
}
console.log(`Downloading video ${videoNumber}/${videos.length}: ${video.title}`);
// Construct video URL
const videoUrl = `https://www.bilibili.com/video/${video.bvid}`;
try {
// Download this video
const result = await downloadSingleBilibiliPart(
videoUrl,
videoNumber,
videos.length,
title
);
// If download was successful, add to collection
if (result.success && result.videoData) {
storageService.atomicUpdateCollection(mytubeCollectionId, (collection) => {
collection.videos.push(result.videoData.id);
return collection;
});
console.log(`Added video ${videoNumber}/${videos.length} to collection`);
} else {
console.error(`Failed to download video ${videoNumber}/${videos.length}: ${video.title}`);
}
} catch (videoError) {
console.error(`Error downloading video ${videoNumber}/${videos.length}:`, videoError);
// Continue with next video even if one fails
}
// Small delay between downloads to avoid rate limiting
await new Promise((resolve) => setTimeout(resolve, 2000));
}
// All videos downloaded, remove from active downloads
if (downloadId) {
storageService.removeActiveDownload(downloadId);
}
console.log(`Finished downloading ${type}: ${title}`);
return {
success: true,
collectionId: mytubeCollectionId,
videosDownloaded: videos.length
};
} catch (error) {
console.error(`Error downloading ${collectionInfo.type}:`, error);
if (downloadId) {
storageService.removeActiveDownload(downloadId);
}
return {
success: false,
error: error.message
};
}
}
// Helper function to download remaining Bilibili parts in sequence
async function downloadRemainingBilibiliParts(
baseUrl,
@@ -540,6 +822,10 @@ async function downloadYouTubeVideo(videoUrl) {
module.exports = {
downloadBilibiliVideo,
checkBilibiliVideoParts,
checkBilibiliCollectionOrSeries,
getBilibiliCollectionVideos,
getBilibiliSeriesVideos,
downloadBilibiliCollection,
downloadSingleBilibiliPart,
downloadRemainingBilibiliParts,
searchYouTube,

View File

@@ -110,6 +110,46 @@ function sanitizeFilename(filename) {
.replace(/\s+/g, "_"); // Replace spaces with underscores
}
// Helper function to extract user mid from Bilibili URL
function extractBilibiliMid(url) {
// Try to extract from space URL pattern: space.bilibili.com/{mid}
const spaceMatch = url.match(/space\.bilibili\.com\/(\d+)/i);
if (spaceMatch && spaceMatch[1]) {
return spaceMatch[1];
}
// Try to extract from URL parameters
const urlObj = new URL(url);
const midParam = urlObj.searchParams.get('mid');
if (midParam) {
return midParam;
}
return null;
}
// Helper function to extract season_id from Bilibili URL
function extractBilibiliSeasonId(url) {
try {
const urlObj = new URL(url);
const seasonId = urlObj.searchParams.get('season_id');
return seasonId;
} catch (error) {
return null;
}
}
// Helper function to extract series_id from Bilibili URL
function extractBilibiliSeriesId(url) {
try {
const urlObj = new URL(url);
const seriesId = urlObj.searchParams.get('series_id');
return seriesId;
} catch (error) {
return null;
}
}
module.exports = {
isValidUrl,
isBilibiliUrl,
@@ -118,4 +158,7 @@ module.exports = {
trimBilibiliUrl,
extractBilibiliVideoId,
sanitizeFilename,
extractBilibiliMid,
extractBilibiliSeasonId,
extractBilibiliSeriesId,
};

View File

@@ -1484,37 +1484,233 @@ body {
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
background-color: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(4px);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
padding: 20px;
animation: overlayFadeIn 0.2s ease-out;
}
@keyframes overlayFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-content {
background-color: #1e1e1e;
border-radius: 8px;
background: linear-gradient(135deg, #1e1e1e 0%, #2a2a2a 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
width: 100%;
max-width: 500px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
overflow: hidden;
max-width: 550px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7), 0 0 1px rgba(255, 255, 255, 0.1);
animation: modalFadeIn 0.3s ease-out;
overflow: visible;
}
.modal-header {
padding: 24px 24px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.modal-header h2 {
margin: 0;
font-size: 1.4rem;
font-weight: 600;
color: var(--text-color);
display: flex;
align-items: center;
gap: 10px;
}
.close-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 28px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s ease;
line-height: 1;
}
.close-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
color: var(--text-color);
}
.modal-body {
padding: 24px;
}
.modal-body p {
margin: 0 0 12px 0;
color: var(--text-secondary);
font-size: 0.95rem;
line-height: 1.6;
}
.modal-body p strong {
color: var(--text-color);
font-weight: 600;
}
.modal-body .video-count-badge {
display: inline-flex;
align-items: center;
background: linear-gradient(135deg, var(--primary-color) 0%, #ff6b6b 100%);
color: white;
padding: 4px 12px;
border-radius: 12px;
font-weight: 600;
font-size: 0.9rem;
margin: 0 4px;
}
.form-group {
margin-top: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
font-weight: 500;
font-size: 0.95rem;
}
.form-group .collection-input {
width: 100%;
padding: 12px 16px;
background-color: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: var(--text-color);
font-size: 1rem;
transition: all 0.2s ease;
}
.form-group .collection-input:focus {
outline: none;
border-color: var(--primary-color);
background-color: rgba(0, 0, 0, 0.4);
box-shadow: 0 0 0 3px rgba(255, 62, 62, 0.1);
}
.form-group .collection-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-group small {
display: block;
margin-top: 6px;
color: var(--text-secondary);
font-size: 0.85rem;
}
.modal-footer {
padding: 20px 24px 24px;
display: flex;
gap: 12px;
background: rgba(0, 0, 0, 0.2);
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.modal-footer .btn {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.modal-footer .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.modal-footer .primary-btn {
background: linear-gradient(135deg, var(--primary-color) 0%, #ff6b6b 100%);
color: white;
box-shadow: 0 4px 12px rgba(255, 62, 62, 0.3);
}
.modal-footer .primary-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(255, 62, 62, 0.4);
}
.modal-footer .primary-btn:active:not(:disabled) {
transform: translateY(0);
}
.modal-footer .secondary-btn {
background-color: rgba(255, 255, 255, 0.08);
color: var(--text-color);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-footer .secondary-btn:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
}
/* Responsive modal styles */
@media (max-width: 768px) {
.modal-overlay {
padding: 16px;
}
.modal-content {
max-width: 100%;
border-radius: 12px;
}
.modal-header {
padding: 20px 20px 16px;
}
.modal-header h2 {
font-size: 1.25rem;
}
.modal-body {
padding: 20px;
}
}
.modal-content h2 {
margin: 0 0 20px 0;
font-size: 1.5rem;
color: var(--text-color);
border-bottom: 1px solid var(--border-color);
padding-bottom: 15px;
}
.modal-footer {
flex-direction: column;
padding: 16px 20px 20px;
}
.modal-content h3 {
margin: 15px 0 10px 0;
font-size: 1.1rem;
color: var(--text-color);
.modal-footer .btn {
width: 100%;
}
}
@keyframes modalFadeIn {

View File

@@ -54,7 +54,9 @@ function App() {
const [bilibiliPartsInfo, setBilibiliPartsInfo] = useState({
videosNumber: 0,
title: '',
url: ''
url: '',
type: 'parts', // 'parts', 'collection', or 'series'
collectionInfo: null // For collection/series, stores the API response
});
const [isCheckingParts, setIsCheckingParts] = useState(false);
@@ -207,30 +209,58 @@ function App() {
}
};
const handleVideoSubmit = async (videoUrl) => {
const handleVideoSubmit = async (videoUrl, skipCollectionCheck = false) => {
try {
// Check if it's a Bilibili URL
if (videoUrl.includes('bilibili.com') || videoUrl.includes('b23.tv')) {
// Check if it has multiple parts
setIsCheckingParts(true);
try {
const response = await axios.get(`${API_URL}/check-bilibili-parts`, {
// Only check for collection/series if not explicitly skipped
if (!skipCollectionCheck) {
// First, check if it's a collection or series
const collectionResponse = await axios.get(`${API_URL}/check-bilibili-collection`, {
params: { url: videoUrl }
});
if (response.data.success && response.data.videosNumber > 1) {
if (collectionResponse.data.success && collectionResponse.data.type !== 'none') {
// It's a collection or series
const { type, title, count, id, mid } = collectionResponse.data;
console.log(`Detected Bilibili ${type}:`, title, `with ${count} videos`);
setBilibiliPartsInfo({
videosNumber: count,
title: title,
url: videoUrl,
type: type,
collectionInfo: { type, id, mid, title, count }
});
setShowBilibiliPartsModal(true);
setIsCheckingParts(false);
return { success: true };
}
}
// If not a collection/series (or check was skipped), check if it has multiple parts
const partsResponse = await axios.get(`${API_URL}/check-bilibili-parts`, {
params: { url: videoUrl }
});
if (partsResponse.data.success && partsResponse.data.videosNumber > 1) {
// Show modal to ask user if they want to download all parts
setBilibiliPartsInfo({
videosNumber: response.data.videosNumber,
title: response.data.title,
url: videoUrl
videosNumber: partsResponse.data.videosNumber,
title: partsResponse.data.title,
url: videoUrl,
type: 'parts',
collectionInfo: null
});
setShowBilibiliPartsModal(true);
setIsCheckingParts(false);
return { success: true };
}
} catch (err) {
console.error('Error checking Bilibili parts:', err);
console.error('Error checking Bilibili parts/collection:', err);
// Continue with normal download if check fails
} finally {
setIsCheckingParts(false);
@@ -542,15 +572,19 @@ function App() {
}
};
// Handle downloading all parts of a Bilibili video
// Handle downloading all parts of a Bilibili video OR all videos from a collection/series
const handleDownloadAllBilibiliParts = async (collectionName) => {
try {
setLoading(true);
setShowBilibiliPartsModal(false);
const isCollection = bilibiliPartsInfo.type === 'collection' || bilibiliPartsInfo.type === 'series';
const response = await axios.post(`${API_URL}/download`, {
youtubeUrl: bilibiliPartsInfo.url,
downloadAllParts: true,
downloadAllParts: !isCollection, // Only set this for multi-part videos
downloadCollection: isCollection, // Set this for collections/series
collectionInfo: isCollection ? bilibiliPartsInfo.collectionInfo : null,
collectionName
});
@@ -566,11 +600,11 @@ function App() {
return { success: true };
} catch (err) {
console.error('Error downloading Bilibili parts:', err);
console.error('Error downloading Bilibili parts/collection:', err);
return {
success: false,
error: err.response?.data?.error || 'Failed to download video parts. Please try again.'
error: err.response?.data?.error || 'Failed to download. Please try again.'
};
} finally {
setLoading(false);
@@ -580,7 +614,8 @@ function App() {
// Handle downloading only the current part of a Bilibili video
const handleDownloadCurrentBilibiliPart = async () => {
setShowBilibiliPartsModal(false);
return await handleVideoSubmit(bilibiliPartsInfo.url);
// Pass true to skip collection/series check since we already know about it
return await handleVideoSubmit(bilibiliPartsInfo.url, true);
};
return (
@@ -604,6 +639,7 @@ function App() {
onDownloadAll={handleDownloadAllBilibiliParts}
onDownloadCurrent={handleDownloadCurrentBilibiliPart}
isLoading={loading || isCheckingParts}
type={bilibiliPartsInfo.type}
/>
<main className="main-content">

View File

@@ -7,7 +7,8 @@ const BilibiliPartsModal = ({
videoTitle,
onDownloadAll,
onDownloadCurrent,
isLoading
isLoading,
type = 'parts' // 'parts', 'collection', or 'series'
}) => {
const [collectionName, setCollectionName] = useState('');
@@ -17,21 +18,72 @@ const BilibiliPartsModal = ({
onDownloadAll(collectionName || videoTitle);
};
// Dynamic text based on type
const getHeaderText = () => {
switch (type) {
case 'collection':
return 'Bilibili Collection Detected';
case 'series':
return 'Bilibili Series Detected';
default:
return 'Multi-part Video Detected';
}
};
const getDescriptionText = () => {
switch (type) {
case 'collection':
return `This Bilibili collection has ${videosNumber} videos.`;
case 'series':
return `This Bilibili series has ${videosNumber} videos.`;
default:
return `This Bilibili video has ${videosNumber} parts.`;
}
};
const getDownloadAllButtonText = () => {
if (isLoading) return 'Processing...';
switch (type) {
case 'collection':
return `Download All ${videosNumber} Videos`;
case 'series':
return `Download All ${videosNumber} Videos`;
default:
return `Download All ${videosNumber} Parts`;
}
};
const getCurrentButtonText = () => {
if (isLoading) return 'Processing...';
switch (type) {
case 'collection':
return 'Download This Video Only';
case 'series':
return 'Download This Video Only';
default:
return 'Download Current Part Only';
}
};
const showCurrentButton = true; // Always show the current/single download option
return (
<div className="modal-overlay">
<div className="modal-content">
<div className="modal-header">
<h2>Multi-part Video Detected</h2>
<h2>{getHeaderText()}</h2>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<p>
This Bilibili video has <strong>{videosNumber}</strong> parts.
{getDescriptionText()}
</p>
<p>
<strong>Title:</strong> {videoTitle}
</p>
<p>Would you like to download all parts?</p>
<p>Would you like to download all {type === 'parts' ? 'parts' : 'videos'}?</p>
<div className="form-group">
<label htmlFor="collection-name">Collection Name:</label>
@@ -44,7 +96,7 @@ const BilibiliPartsModal = ({
placeholder={videoTitle}
disabled={isLoading}
/>
<small>All parts will be added to this collection</small>
<small>All {type === 'parts' ? 'parts' : 'videos'} will be added to this collection</small>
</div>
</div>
<div className="modal-footer">
@@ -53,14 +105,14 @@ const BilibiliPartsModal = ({
onClick={onDownloadCurrent}
disabled={isLoading}
>
{isLoading ? 'Processing...' : 'Download Current Part Only'}
{getCurrentButtonText()}
</button>
<button
className="btn primary-btn"
onClick={handleDownloadAll}
disabled={isLoading}
>
{isLoading ? 'Processing...' : `Download All ${videosNumber} Parts`}
{getDownloadAllButtonText()}
</button>
</div>
</div>

View File

@@ -1,4 +1,3 @@
import React from 'react';
const DeleteCollectionModal = ({
isOpen,
@@ -11,32 +10,47 @@ const DeleteCollectionModal = ({
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div className="modal-content">
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Delete Collection</h2>
<p>
Are you sure you want to delete the collection "{collectionName}"?
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<p style={{ marginBottom: '12px', fontSize: '0.95rem' }}>
Are you sure you want to delete the collection <strong>"{collectionName}"</strong>?
</p>
<p>
This collection contains {videoCount} video{videoCount !== 1 ? 's' : ''}.
<p style={{ marginBottom: '20px', fontSize: '0.95rem', color: 'var(--text-secondary)' }}>
This collection contains <strong>{videoCount}</strong> video{videoCount !== 1 ? 's' : ''}.
</p>
<div className="modal-buttons">
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<button
className="modal-button delete-collection-only"
className="btn secondary-btn"
onClick={onDeleteCollectionOnly}
style={{ width: '100%' }}
>
Delete Collection Only
</button>
{videoCount > 0 && (
<button
className="modal-button delete-all danger"
className="btn primary-btn"
onClick={onDeleteCollectionAndVideos}
style={{
width: '100%',
background: 'linear-gradient(135deg, #ff3e3e 0%, #ff6b6b 100%)'
}}
>
Delete Collection and All Videos
Delete Collection and All Videos
</button>
)}
</div>
</div>
<div className="modal-footer">
<button
className="modal-button cancel"
className="btn secondary-btn"
onClick={onClose}
>
Cancel

View File

@@ -53,30 +53,53 @@ const ManagePage = ({ videos, onDeleteVideo, collections = [], onDeleteCollectio
{collectionToDelete && (
<div className="modal-overlay" onClick={() => !isDeletingCollection && setCollectionToDelete(null)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Delete Collection</h2>
<p style={{ marginBottom: '0.5rem' }}>
<button
className="close-btn"
onClick={() => setCollectionToDelete(null)}
disabled={isDeletingCollection}
>
×
</button>
</div>
<div className="modal-body">
<p style={{ marginBottom: '12px', fontSize: '0.95rem' }}>
You are about to delete the collection <strong>"{collectionToDelete.name}"</strong>.
</p>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginBottom: '1.5rem' }}>
This collection contains {collectionToDelete.videos.length} video{collectionToDelete.videos.length !== 1 ? 's' : ''}.
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginBottom: '20px' }}>
This collection contains <strong>{collectionToDelete.videos.length}</strong> video{collectionToDelete.videos.length !== 1 ? 's' : ''}.
</p>
<div className="modal-actions" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<button
className="modal-btn secondary-btn"
className="btn secondary-btn"
onClick={() => handleCollectionDelete(false)}
disabled={isDeletingCollection}
style={{ width: '100%' }}
>
{isDeletingCollection ? 'Deleting...' : 'Delete Collection Only'}
</button>
{collectionToDelete.videos.length > 0 && (
<button
className="modal-btn danger-btn"
className="btn primary-btn"
onClick={() => handleCollectionDelete(true)}
disabled={isDeletingCollection}
style={{
width: '100%',
background: 'linear-gradient(135deg, #ff3e3e 0%, #ff6b6b 100%)'
}}
>
{isDeletingCollection ? 'Deleting...' : 'Delete Collection & Videos'}
{isDeletingCollection ? 'Deleting...' : '⚠️ Delete Collection & Videos'}
</button>
)}
</div>
</div>
<div className="modal-footer">
<button
className="modal-btn cancel-btn"
className="btn secondary-btn"
onClick={() => setCollectionToDelete(null)}
disabled={isDeletingCollection}
>

View File

@@ -302,18 +302,22 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
{showCollectionModal && (
<div className="modal-overlay" onClick={handleCloseModal}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Add to Collection</h2>
<button className="close-btn" onClick={handleCloseModal}>×</button>
</div>
<div className="modal-body">
{videoCollections.length > 0 && (
<div className="current-collection" style={{
marginBottom: '1.5rem',
padding: '1rem',
backgroundColor: 'rgba(62, 166, 255, 0.1)',
background: 'linear-gradient(135deg, rgba(62, 166, 255, 0.1) 0%, rgba(62, 166, 255, 0.05) 100%)',
borderRadius: '8px',
border: '1px solid rgba(62, 166, 255, 0.3)'
}}>
<p style={{ margin: '0 0 0.5rem 0', color: 'var(--text-color)' }}>
Currently in: <strong>{videoCollections[0].name}</strong>
<p style={{ margin: '0 0 0.5rem 0', color: 'var(--text-color)', fontWeight: '500' }}>
📁 Currently in: <strong>{videoCollections[0].name}</strong>
</p>
<p style={{ margin: '0 0 1rem 0', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
Adding to a different collection will remove it from the current one.
@@ -330,10 +334,23 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
{collections && collections.length > 0 && (
<div className="existing-collections" style={{ marginBottom: '1.5rem' }}>
<h3>Add to existing collection:</h3>
<h3 style={{ margin: '0 0 10px 0', fontSize: '1rem', fontWeight: '600', color: 'var(--text-color)' }}>
Add to existing collection:
</h3>
<select
value={selectedCollection}
onChange={(e) => setSelectedCollection(e.target.value)}
style={{
width: '100%',
padding: '12px 16px',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '8px',
color: 'var(--text-color)',
fontSize: '1rem',
marginBottom: '0.5rem',
cursor: 'pointer'
}}
>
<option value="">Select a collection</option>
{collections.map(collection => (
@@ -348,7 +365,7 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
</select>
<button
className="modal-btn primary-btn"
style={{ width: '100%', marginTop: '0.5rem' }}
style={{ width: '100%' }}
onClick={handleAddToExistingCollection}
disabled={!selectedCollection}
>
@@ -357,30 +374,47 @@ const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, on
</div>
)}
<div className="new-collection" style={{ marginBottom: '1.5rem' }}>
<h3>Create new collection:</h3>
<div className="new-collection">
<h3 style={{ margin: '0 0 10px 0', fontSize: '1rem', fontWeight: '600', color: 'var(--text-color)' }}>
Create new collection:
</h3>
<input
type="text"
className="collection-input"
placeholder="Collection name"
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && newCollectionName.trim() && handleCreateCollection()}
style={{
width: '100%',
padding: '12px 16px',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '8px',
color: 'var(--text-color)',
fontSize: '1rem',
marginBottom: '0.5rem',
transition: 'all 0.2s ease'
}}
/>
<button
className="modal-btn primary-btn"
style={{ width: '100%', marginTop: '0.5rem' }}
style={{ width: '100%' }}
onClick={handleCreateCollection}
disabled={!newCollectionName.trim()}
>
Create Collection
</button>
</div>
</div>
<button className="modal-btn cancel-btn" style={{ width: '100%' }} onClick={handleCloseModal}>
<div className="modal-footer">
<button className="btn secondary-btn" onClick={handleCloseModal}>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);