feat: Add Bilibili collection handling functionality
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user