diff --git a/backend/src/controllers/videoController.js b/backend/src/controllers/videoController.js index 5aa5eac..d451c2c 100644 --- a/backend/src/controllers/videoController.js +++ b/backend/src/controllers/videoController.js @@ -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, }; diff --git a/backend/src/routes/api.js b/backend/src/routes/api.js index 30af505..4cc9260 100644 --- a/backend/src/routes/api.js +++ b/backend/src/routes/api.js @@ -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); diff --git a/backend/src/services/downloadService.js b/backend/src/services/downloadService.js index 9cd1bd3..f5c8956 100644 --- a/backend/src/services/downloadService.js +++ b/backend/src/services/downloadService.js @@ -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, diff --git a/backend/src/utils/helpers.js b/backend/src/utils/helpers.js index c8cb39e..eb86db7 100644 --- a/backend/src/utils/helpers.js +++ b/backend/src/utils/helpers.js @@ -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, }; diff --git a/frontend/src/App.css b/frontend/src/App.css index 727ae83..d20ef22 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; - padding: 20px; + overflow: visible; } -.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-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-content h3 { - margin: 15px 0 10px 0; - font-size: 1.1rem; +.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-footer { + flex-direction: column; + padding: 16px 20px 20px; + } + + .modal-footer .btn { + width: 100%; + } } @keyframes modalFadeIn { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 23857d0..ee11155 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (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 (response.data.success && response.data.videosNumber > 1) { + 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} />
diff --git a/frontend/src/components/BilibiliPartsModal.jsx b/frontend/src/components/BilibiliPartsModal.jsx index bbe70cc..b824e05 100644 --- a/frontend/src/components/BilibiliPartsModal.jsx +++ b/frontend/src/components/BilibiliPartsModal.jsx @@ -1,38 +1,90 @@ import { useState } from 'react'; -const BilibiliPartsModal = ({ - isOpen, - onClose, - videosNumber, - videoTitle, - onDownloadAll, +const BilibiliPartsModal = ({ + isOpen, + onClose, + videosNumber, + videoTitle, + onDownloadAll, onDownloadCurrent, - isLoading + isLoading, + type = 'parts' // 'parts', 'collection', or 'series' }) => { const [collectionName, setCollectionName] = useState(''); - + if (!isOpen) return null; - + const handleDownloadAll = () => { 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 (
-

Multi-part Video Detected

+

{getHeaderText()}

- This Bilibili video has {videosNumber} parts. + {getDescriptionText()}

Title: {videoTitle}

-

Would you like to download all parts?

- +

Would you like to download all {type === 'parts' ? 'parts' : 'videos'}?

+
- All parts will be added to this collection + All {type === 'parts' ? 'parts' : 'videos'} will be added to this collection
- -
diff --git a/frontend/src/components/DeleteCollectionModal.jsx b/frontend/src/components/DeleteCollectionModal.jsx index 9ede862..ad074ff 100644 --- a/frontend/src/components/DeleteCollectionModal.jsx +++ b/frontend/src/components/DeleteCollectionModal.jsx @@ -1,42 +1,56 @@ -import React from 'react'; -const DeleteCollectionModal = ({ - isOpen, - onClose, - onDeleteCollectionOnly, - onDeleteCollectionAndVideos, - collectionName, - videoCount +const DeleteCollectionModal = ({ + isOpen, + onClose, + onDeleteCollectionOnly, + onDeleteCollectionAndVideos, + collectionName, + videoCount }) => { if (!isOpen) return null; return ( -
-
-

Delete Collection

-

- Are you sure you want to delete the collection "{collectionName}"? -

-

- This collection contains {videoCount} video{videoCount !== 1 ? 's' : ''}. -

-
- - {videoCount > 0 && ( - +
+ +
+

+ Are you sure you want to delete the collection "{collectionName}"? +

+

+ This collection contains {videoCount} video{videoCount !== 1 ? 's' : ''}. +

+ +
+ - )} - + )} +
+
+ +
+ +
+ +
+

+ You are about to delete the collection "{collectionToDelete.name}". +

+

+ This collection contains {collectionToDelete.videos.length} video{collectionToDelete.videos.length !== 1 ? 's' : ''}. +

+ +
+ + {collectionToDelete.videos.length > 0 && ( + + )} +
+
+ +
- -
- )} - - {collections && collections.length > 0 && ( -
-

Add to existing collection:

- - -
- )} - -
-

Create new collection:

- setNewCollectionName(e.target.value)} - onKeyPress={(e) => e.key === 'Enter' && newCollectionName.trim() && handleCreateCollection()} - /> - +
+

Add to Collection

+
- +
+ {videoCollections.length > 0 && ( +
+

+ 📁 Currently in: {videoCollections[0].name} +

+

+ Adding to a different collection will remove it from the current one. +

+ +
+ )} + + {collections && collections.length > 0 && ( +
+

+ Add to existing collection: +

+ + +
+ )} + +
+

+ Create new collection: +

+ 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' + }} + /> + +
+
+ +
+ +
)}