feat: Add Bilibili multi-part download functionality
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"isDownloading": false,
|
||||
"title": "",
|
||||
"timestamp": 1741832171094
|
||||
"timestamp": 1741836072302
|
||||
}
|
||||
@@ -310,6 +310,235 @@ async function downloadBilibiliVideo(url, videoPath, thumbnailPath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a Bilibili video has multiple parts
|
||||
async function checkBilibiliVideoParts(videoId) {
|
||||
try {
|
||||
// Try to get video info from Bilibili API
|
||||
const apiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`;
|
||||
console.log("Fetching video info from API to check parts:", apiUrl);
|
||||
|
||||
const response = await axios.get(apiUrl);
|
||||
|
||||
if (response.data && response.data.data) {
|
||||
const videoInfo = response.data.data;
|
||||
const videosNumber = videoInfo.videos || 1;
|
||||
|
||||
console.log(`Bilibili video has ${videosNumber} parts`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
videosNumber,
|
||||
title: videoInfo.title || "Bilibili Video",
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, videosNumber: 1 };
|
||||
} catch (error) {
|
||||
console.error("Error checking Bilibili video parts:", error);
|
||||
return { success: false, videosNumber: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to download a single Bilibili part
|
||||
async function downloadSingleBilibiliPart(
|
||||
url,
|
||||
partNumber,
|
||||
totalParts,
|
||||
seriesTitle
|
||||
) {
|
||||
try {
|
||||
console.log(
|
||||
`Downloading Bilibili part ${partNumber}/${totalParts}: ${url}`
|
||||
);
|
||||
|
||||
// Create a safe base filename (without extension)
|
||||
const timestamp = Date.now();
|
||||
const safeBaseFilename = `video_${timestamp}`;
|
||||
|
||||
// Add extensions for video and thumbnail
|
||||
const videoFilename = `${safeBaseFilename}.mp4`;
|
||||
const thumbnailFilename = `${safeBaseFilename}.jpg`;
|
||||
|
||||
// Set full paths for video and thumbnail
|
||||
const videoPath = path.join(videosDir, videoFilename);
|
||||
const thumbnailPath = path.join(imagesDir, thumbnailFilename);
|
||||
|
||||
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
|
||||
let finalVideoFilename = videoFilename;
|
||||
let finalThumbnailFilename = thumbnailFilename;
|
||||
|
||||
// Download Bilibili video
|
||||
const bilibiliInfo = await downloadBilibiliVideo(
|
||||
url,
|
||||
videoPath,
|
||||
thumbnailPath
|
||||
);
|
||||
|
||||
if (!bilibiliInfo) {
|
||||
throw new Error("Failed to get Bilibili video info");
|
||||
}
|
||||
|
||||
console.log("Bilibili download info:", bilibiliInfo);
|
||||
|
||||
// For multi-part videos, include the part number in the title
|
||||
videoTitle =
|
||||
totalParts > 1
|
||||
? `${seriesTitle} - Part ${partNumber}/${totalParts}`
|
||||
: bilibiliInfo.title || "Bilibili Video";
|
||||
|
||||
videoAuthor = bilibiliInfo.author || "Bilibili User";
|
||||
videoDate =
|
||||
bilibiliInfo.date ||
|
||||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
thumbnailUrl = bilibiliInfo.thumbnailUrl;
|
||||
thumbnailSaved = bilibiliInfo.thumbnailSaved;
|
||||
|
||||
// Update the safe base filename with the actual title
|
||||
const newSafeBaseFilename = `${sanitizeFilename(videoTitle)}_${timestamp}`;
|
||||
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
|
||||
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
|
||||
|
||||
// Rename the files
|
||||
const newVideoPath = path.join(videosDir, newVideoFilename);
|
||||
const newThumbnailPath = path.join(imagesDir, newThumbnailFilename);
|
||||
|
||||
if (fs.existsSync(videoPath)) {
|
||||
fs.renameSync(videoPath, newVideoPath);
|
||||
console.log("Renamed video file to:", newVideoFilename);
|
||||
finalVideoFilename = newVideoFilename;
|
||||
} else {
|
||||
console.log("Video file not found at:", videoPath);
|
||||
}
|
||||
|
||||
if (thumbnailSaved && fs.existsSync(thumbnailPath)) {
|
||||
fs.renameSync(thumbnailPath, newThumbnailPath);
|
||||
console.log("Renamed thumbnail file to:", newThumbnailFilename);
|
||||
finalThumbnailFilename = newThumbnailFilename;
|
||||
}
|
||||
|
||||
// Create metadata for the video
|
||||
const videoData = {
|
||||
id: timestamp.toString(),
|
||||
title: videoTitle,
|
||||
author: videoAuthor,
|
||||
date: videoDate,
|
||||
source: "bilibili",
|
||||
sourceUrl: url,
|
||||
videoFilename: finalVideoFilename,
|
||||
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : null,
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
videoPath: `/videos/${finalVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? `/images/${finalThumbnailFilename}`
|
||||
: null,
|
||||
addedAt: new Date().toISOString(),
|
||||
partNumber: partNumber,
|
||||
totalParts: totalParts,
|
||||
seriesTitle: seriesTitle,
|
||||
};
|
||||
|
||||
// Read existing videos data
|
||||
let videos = [];
|
||||
if (fs.existsSync(videosDataPath)) {
|
||||
videos = JSON.parse(fs.readFileSync(videosDataPath, "utf8"));
|
||||
}
|
||||
|
||||
// Add new video to the list
|
||||
videos.unshift(videoData);
|
||||
|
||||
// Save updated videos data
|
||||
fs.writeFileSync(videosDataPath, JSON.stringify(videos, null, 2));
|
||||
|
||||
console.log(`Part ${partNumber}/${totalParts} added to database`);
|
||||
|
||||
return { success: true, videoData };
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error downloading Bilibili part ${partNumber}/${totalParts}:`,
|
||||
error
|
||||
);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to download remaining Bilibili parts in sequence
|
||||
async function downloadRemainingBilibiliParts(
|
||||
baseUrl,
|
||||
startPart,
|
||||
totalParts,
|
||||
seriesTitle,
|
||||
collectionId
|
||||
) {
|
||||
try {
|
||||
for (let part = startPart; part <= totalParts; part++) {
|
||||
// Update status to show which part is being downloaded
|
||||
updateDownloadStatus(
|
||||
true,
|
||||
`Downloading part ${part}/${totalParts}: ${seriesTitle}`
|
||||
);
|
||||
|
||||
// Construct URL for this part
|
||||
const partUrl = `${baseUrl}?p=${part}`;
|
||||
|
||||
// Download this part
|
||||
const result = await downloadSingleBilibiliPart(
|
||||
partUrl,
|
||||
part,
|
||||
totalParts,
|
||||
seriesTitle
|
||||
);
|
||||
|
||||
// If download was successful and we have a collection ID, add to collection
|
||||
if (result.success && collectionId && result.videoData) {
|
||||
try {
|
||||
// Read existing collections
|
||||
const collectionsDataPath = path.join(dataDir, "collections.json");
|
||||
let collections = JSON.parse(
|
||||
fs.readFileSync(collectionsDataPath, "utf8")
|
||||
);
|
||||
|
||||
// Find the collection
|
||||
const collectionIndex = collections.findIndex(
|
||||
(c) => c.id === collectionId
|
||||
);
|
||||
|
||||
if (collectionIndex !== -1) {
|
||||
// Add video to collection
|
||||
collections[collectionIndex].videos.push(result.videoData.id);
|
||||
|
||||
// Save updated collections
|
||||
fs.writeFileSync(
|
||||
collectionsDataPath,
|
||||
JSON.stringify(collections, null, 2)
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Added part ${part}/${totalParts} to collection ${collectionId}`
|
||||
);
|
||||
}
|
||||
} catch (collectionError) {
|
||||
console.error(
|
||||
`Error adding part ${part}/${totalParts} to collection:`,
|
||||
collectionError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay between downloads to avoid overwhelming the server
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// All parts downloaded, update status
|
||||
updateDownloadStatus(false);
|
||||
console.log(
|
||||
`All ${totalParts} parts of "${seriesTitle}" downloaded successfully`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error downloading remaining Bilibili parts:", error);
|
||||
updateDownloadStatus(false);
|
||||
}
|
||||
}
|
||||
|
||||
// API endpoint to search for videos on YouTube
|
||||
app.get("/api/search", async (req, res) => {
|
||||
try {
|
||||
@@ -363,7 +592,7 @@ app.get("/api/search", async (req, res) => {
|
||||
// API endpoint to download a video (YouTube or Bilibili)
|
||||
app.post("/api/download", async (req, res) => {
|
||||
try {
|
||||
const { youtubeUrl } = req.body;
|
||||
const { youtubeUrl, downloadAllParts, collectionName } = req.body;
|
||||
let videoUrl = youtubeUrl; // Keep the parameter name for backward compatibility
|
||||
|
||||
if (!videoUrl) {
|
||||
@@ -408,91 +637,267 @@ app.post("/api/download", async (req, res) => {
|
||||
if (isBilibiliUrl(videoUrl)) {
|
||||
videoUrl = trimBilibiliUrl(videoUrl);
|
||||
console.log("Using trimmed Bilibili URL:", videoUrl);
|
||||
}
|
||||
|
||||
// Create a safe base filename (without extension)
|
||||
const timestamp = Date.now();
|
||||
const safeBaseFilename = `video_${timestamp}`;
|
||||
// If downloadAllParts is true, handle multi-part download
|
||||
if (downloadAllParts) {
|
||||
const videoId = extractBilibiliVideoId(videoUrl);
|
||||
if (!videoId) {
|
||||
updateDownloadStatus(false);
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Could not extract Bilibili video ID" });
|
||||
}
|
||||
|
||||
// Add extensions for video and thumbnail
|
||||
const videoFilename = `${safeBaseFilename}.mp4`;
|
||||
const thumbnailFilename = `${safeBaseFilename}.jpg`;
|
||||
// Get video info to determine number of parts
|
||||
const partsInfo = await checkBilibiliVideoParts(videoId);
|
||||
|
||||
// Set full paths for video and thumbnail
|
||||
const videoPath = path.join(videosDir, videoFilename);
|
||||
const thumbnailPath = path.join(imagesDir, thumbnailFilename);
|
||||
if (!partsInfo.success) {
|
||||
updateDownloadStatus(false);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get video parts information" });
|
||||
}
|
||||
|
||||
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
|
||||
let finalVideoFilename = videoFilename;
|
||||
let finalThumbnailFilename = thumbnailFilename;
|
||||
const { videosNumber, title } = partsInfo;
|
||||
|
||||
// Check if it's a Bilibili URL
|
||||
if (isBilibiliUrl(videoUrl)) {
|
||||
console.log("Detected Bilibili URL");
|
||||
updateDownloadStatus(true, "Downloading Bilibili video...");
|
||||
// Create a collection for the multi-part video if collectionName is provided
|
||||
let collectionId = null;
|
||||
if (collectionName) {
|
||||
// Read existing collections
|
||||
const collectionsDataPath = path.join(dataDir, "collections.json");
|
||||
let collections = [];
|
||||
|
||||
try {
|
||||
// Download Bilibili video
|
||||
const bilibiliInfo = await downloadBilibiliVideo(
|
||||
videoUrl,
|
||||
videoPath,
|
||||
thumbnailPath
|
||||
if (fs.existsSync(collectionsDataPath)) {
|
||||
collections = JSON.parse(
|
||||
fs.readFileSync(collectionsDataPath, "utf8")
|
||||
);
|
||||
}
|
||||
|
||||
// Create a new collection
|
||||
const newCollection = {
|
||||
id: Date.now().toString(),
|
||||
name: collectionName,
|
||||
videos: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Add the new collection
|
||||
collections.push(newCollection);
|
||||
|
||||
// Save the updated collections
|
||||
fs.writeFileSync(
|
||||
collectionsDataPath,
|
||||
JSON.stringify(collections, null, 2)
|
||||
);
|
||||
|
||||
collectionId = newCollection.id;
|
||||
}
|
||||
|
||||
// Start downloading the first part
|
||||
const baseUrl = videoUrl.split("?")[0];
|
||||
const firstPartUrl = `${baseUrl}?p=1`;
|
||||
|
||||
updateDownloadStatus(
|
||||
true,
|
||||
`Downloading part 1/${videosNumber}: ${title}`
|
||||
);
|
||||
|
||||
if (!bilibiliInfo) {
|
||||
throw new Error("Failed to get Bilibili video info");
|
||||
// Download the first part
|
||||
const firstPartResult = await downloadSingleBilibiliPart(
|
||||
firstPartUrl,
|
||||
1,
|
||||
videosNumber,
|
||||
title
|
||||
);
|
||||
|
||||
// Add to collection if needed
|
||||
if (collectionId && firstPartResult.videoData) {
|
||||
// Read existing collections
|
||||
const collectionsDataPath = path.join(dataDir, "collections.json");
|
||||
let collections = JSON.parse(
|
||||
fs.readFileSync(collectionsDataPath, "utf8")
|
||||
);
|
||||
|
||||
// Find the collection
|
||||
const collectionIndex = collections.findIndex(
|
||||
(c) => c.id === collectionId
|
||||
);
|
||||
|
||||
if (collectionIndex !== -1) {
|
||||
// Add video to collection
|
||||
collections[collectionIndex].videos.push(
|
||||
firstPartResult.videoData.id
|
||||
);
|
||||
|
||||
// Save updated collections
|
||||
fs.writeFileSync(
|
||||
collectionsDataPath,
|
||||
JSON.stringify(collections, null, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Bilibili download info:", bilibiliInfo);
|
||||
|
||||
videoTitle = bilibiliInfo.title || "Bilibili Video";
|
||||
videoAuthor = bilibiliInfo.author || "Bilibili User";
|
||||
videoDate =
|
||||
bilibiliInfo.date ||
|
||||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
thumbnailUrl = bilibiliInfo.thumbnailUrl;
|
||||
thumbnailSaved = bilibiliInfo.thumbnailSaved;
|
||||
|
||||
// Update download status with actual title
|
||||
updateDownloadStatus(true, `Downloading: ${videoTitle}`);
|
||||
|
||||
// Update the safe base filename with the actual title
|
||||
const newSafeBaseFilename = `${sanitizeFilename(
|
||||
videoTitle
|
||||
)}_${timestamp}`;
|
||||
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
|
||||
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
|
||||
|
||||
// Rename the files
|
||||
const newVideoPath = path.join(videosDir, newVideoFilename);
|
||||
const newThumbnailPath = path.join(imagesDir, newThumbnailFilename);
|
||||
|
||||
if (fs.existsSync(videoPath)) {
|
||||
fs.renameSync(videoPath, newVideoPath);
|
||||
console.log("Renamed video file to:", newVideoFilename);
|
||||
finalVideoFilename = newVideoFilename;
|
||||
// Set up background download for remaining parts
|
||||
if (videosNumber > 1) {
|
||||
// We'll handle the remaining parts in the background
|
||||
// The client will poll the download status endpoint to check progress
|
||||
downloadRemainingBilibiliParts(
|
||||
baseUrl,
|
||||
2,
|
||||
videosNumber,
|
||||
title,
|
||||
collectionId
|
||||
);
|
||||
} else {
|
||||
console.log("Video file not found at:", videoPath);
|
||||
// Only one part, we're done
|
||||
updateDownloadStatus(false);
|
||||
}
|
||||
|
||||
if (thumbnailSaved && fs.existsSync(thumbnailPath)) {
|
||||
fs.renameSync(thumbnailPath, newThumbnailPath);
|
||||
console.log("Renamed thumbnail file to:", newThumbnailFilename);
|
||||
finalThumbnailFilename = newThumbnailFilename;
|
||||
}
|
||||
} catch (bilibiliError) {
|
||||
console.error("Error in Bilibili download process:", bilibiliError);
|
||||
// Set download status to false on error
|
||||
updateDownloadStatus(false);
|
||||
return res.status(500).json({
|
||||
error: "Failed to download Bilibili video",
|
||||
details: bilibiliError.message,
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
video: firstPartResult.videoData,
|
||||
isMultiPart: true,
|
||||
totalParts: videosNumber,
|
||||
collectionId,
|
||||
});
|
||||
} else {
|
||||
// Regular single video download for Bilibili
|
||||
console.log("Downloading single Bilibili video part");
|
||||
|
||||
// Create a safe base filename (without extension)
|
||||
const timestamp = Date.now();
|
||||
const safeBaseFilename = `video_${timestamp}`;
|
||||
|
||||
// Add extensions for video and thumbnail
|
||||
const videoFilename = `${safeBaseFilename}.mp4`;
|
||||
const thumbnailFilename = `${safeBaseFilename}.jpg`;
|
||||
|
||||
// Set full paths for video and thumbnail
|
||||
const videoPath = path.join(videosDir, videoFilename);
|
||||
const thumbnailPath = path.join(imagesDir, thumbnailFilename);
|
||||
|
||||
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
|
||||
let finalVideoFilename = videoFilename;
|
||||
let finalThumbnailFilename = thumbnailFilename;
|
||||
|
||||
updateDownloadStatus(true, "Downloading Bilibili video...");
|
||||
|
||||
try {
|
||||
// Download Bilibili video
|
||||
const bilibiliInfo = await downloadBilibiliVideo(
|
||||
videoUrl,
|
||||
videoPath,
|
||||
thumbnailPath
|
||||
);
|
||||
|
||||
if (!bilibiliInfo) {
|
||||
throw new Error("Failed to get Bilibili video info");
|
||||
}
|
||||
|
||||
console.log("Bilibili download info:", bilibiliInfo);
|
||||
|
||||
videoTitle = bilibiliInfo.title || "Bilibili Video";
|
||||
videoAuthor = bilibiliInfo.author || "Bilibili User";
|
||||
videoDate =
|
||||
bilibiliInfo.date ||
|
||||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
thumbnailUrl = bilibiliInfo.thumbnailUrl;
|
||||
thumbnailSaved = bilibiliInfo.thumbnailSaved;
|
||||
|
||||
// Update download status with actual title
|
||||
updateDownloadStatus(true, `Downloading: ${videoTitle}`);
|
||||
|
||||
// Update the safe base filename with the actual title
|
||||
const newSafeBaseFilename = `${sanitizeFilename(
|
||||
videoTitle
|
||||
)}_${timestamp}`;
|
||||
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
|
||||
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
|
||||
|
||||
// Rename the files
|
||||
const newVideoPath = path.join(videosDir, newVideoFilename);
|
||||
const newThumbnailPath = path.join(imagesDir, newThumbnailFilename);
|
||||
|
||||
if (fs.existsSync(videoPath)) {
|
||||
fs.renameSync(videoPath, newVideoPath);
|
||||
console.log("Renamed video file to:", newVideoFilename);
|
||||
finalVideoFilename = newVideoFilename;
|
||||
} else {
|
||||
console.log("Video file not found at:", videoPath);
|
||||
}
|
||||
|
||||
if (thumbnailSaved && fs.existsSync(thumbnailPath)) {
|
||||
fs.renameSync(thumbnailPath, newThumbnailPath);
|
||||
console.log("Renamed thumbnail file to:", newThumbnailFilename);
|
||||
finalThumbnailFilename = newThumbnailFilename;
|
||||
}
|
||||
} catch (bilibiliError) {
|
||||
console.error("Error in Bilibili download process:", bilibiliError);
|
||||
// Set download status to false on error
|
||||
updateDownloadStatus(false);
|
||||
return res.status(500).json({
|
||||
error: "Failed to download Bilibili video",
|
||||
details: bilibiliError.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Create metadata for the video
|
||||
const videoData = {
|
||||
id: timestamp.toString(),
|
||||
title: videoTitle || "Video",
|
||||
author: videoAuthor || "Unknown",
|
||||
date:
|
||||
videoDate ||
|
||||
new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
source: "bilibili",
|
||||
sourceUrl: videoUrl,
|
||||
videoFilename: finalVideoFilename,
|
||||
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : null,
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
videoPath: `/videos/${finalVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? `/images/${finalThumbnailFilename}`
|
||||
: null,
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Read existing videos data
|
||||
let videos = [];
|
||||
if (fs.existsSync(videosDataPath)) {
|
||||
videos = JSON.parse(fs.readFileSync(videosDataPath, "utf8"));
|
||||
}
|
||||
|
||||
// Add new video to the list
|
||||
videos.unshift(videoData);
|
||||
|
||||
// Save updated videos data
|
||||
fs.writeFileSync(videosDataPath, JSON.stringify(videos, null, 2));
|
||||
|
||||
// Set download status to false when complete
|
||||
updateDownloadStatus(false);
|
||||
|
||||
return res.status(200).json({ success: true, video: videoData });
|
||||
}
|
||||
} else {
|
||||
console.log("Detected YouTube URL");
|
||||
updateDownloadStatus(true, "Downloading YouTube video...");
|
||||
|
||||
// Create a safe base filename (without extension)
|
||||
const timestamp = Date.now();
|
||||
const safeBaseFilename = `video_${timestamp}`;
|
||||
|
||||
// Add extensions for video and thumbnail
|
||||
const videoFilename = `${safeBaseFilename}.mp4`;
|
||||
const thumbnailFilename = `${safeBaseFilename}.jpg`;
|
||||
|
||||
// Set full paths for video and thumbnail
|
||||
const videoPath = path.join(videosDir, videoFilename);
|
||||
const thumbnailPath = path.join(imagesDir, thumbnailFilename);
|
||||
|
||||
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
|
||||
let finalVideoFilename = videoFilename;
|
||||
let finalThumbnailFilename = thumbnailFilename;
|
||||
|
||||
try {
|
||||
// Get YouTube video info first
|
||||
const info = await youtubedl(videoUrl, {
|
||||
@@ -584,47 +989,47 @@ app.post("/api/download", async (req, res) => {
|
||||
details: youtubeError.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Create metadata for the video
|
||||
const videoData = {
|
||||
id: timestamp.toString(),
|
||||
title: videoTitle || "Video",
|
||||
author: videoAuthor || "Unknown",
|
||||
date:
|
||||
videoDate || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
source: "youtube",
|
||||
sourceUrl: videoUrl,
|
||||
videoFilename: finalVideoFilename,
|
||||
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : null,
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
videoPath: `/videos/${finalVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? `/images/${finalThumbnailFilename}`
|
||||
: null,
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log("Video metadata:", videoData);
|
||||
|
||||
// Read existing videos data
|
||||
let videos = [];
|
||||
if (fs.existsSync(videosDataPath)) {
|
||||
videos = JSON.parse(fs.readFileSync(videosDataPath, "utf8"));
|
||||
}
|
||||
|
||||
// Add new video to the list
|
||||
videos.unshift(videoData);
|
||||
|
||||
// Save updated videos data
|
||||
fs.writeFileSync(videosDataPath, JSON.stringify(videos, null, 2));
|
||||
|
||||
console.log("Video added to database");
|
||||
|
||||
// Set download status to false when complete
|
||||
updateDownloadStatus(false);
|
||||
|
||||
return res.status(200).json({ success: true, video: videoData });
|
||||
}
|
||||
|
||||
// Create metadata for the video
|
||||
const videoData = {
|
||||
id: timestamp.toString(),
|
||||
title: videoTitle || "Video",
|
||||
author: videoAuthor || "Unknown",
|
||||
date:
|
||||
videoDate || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
source: isBilibiliUrl(videoUrl) ? "bilibili" : "youtube",
|
||||
sourceUrl: videoUrl,
|
||||
videoFilename: finalVideoFilename,
|
||||
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : null,
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
videoPath: `/videos/${finalVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? `/images/${finalThumbnailFilename}`
|
||||
: null,
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log("Video metadata:", videoData);
|
||||
|
||||
// Read existing videos data
|
||||
let videos = [];
|
||||
if (fs.existsSync(videosDataPath)) {
|
||||
videos = JSON.parse(fs.readFileSync(videosDataPath, "utf8"));
|
||||
}
|
||||
|
||||
// Add new video to the list
|
||||
videos.unshift(videoData);
|
||||
|
||||
// Save updated videos data
|
||||
fs.writeFileSync(videosDataPath, JSON.stringify(videos, null, 2));
|
||||
|
||||
console.log("Video added to database");
|
||||
|
||||
// Set download status to false when complete
|
||||
updateDownloadStatus(false);
|
||||
|
||||
res.status(200).json({ success: true, video: videoData });
|
||||
} catch (error) {
|
||||
console.error("Error downloading video:", error);
|
||||
// Set download status to false on error
|
||||
@@ -917,6 +1322,50 @@ app.get("/api/download-status", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// API endpoint to check if a Bilibili video has multiple parts
|
||||
app.get("/api/check-bilibili-parts", 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" });
|
||||
}
|
||||
|
||||
const result = await checkBilibiliVideoParts(videoId);
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error("Error checking Bilibili video parts:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to check Bilibili video parts",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Start the server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
|
||||
@@ -143,6 +143,7 @@ body {
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
width: 100%;
|
||||
align-items: start; /* Align items to the start of the grid cell */
|
||||
}
|
||||
|
||||
/* Home Container */
|
||||
@@ -183,7 +184,8 @@ body {
|
||||
box-shadow: var(--shadow);
|
||||
transition: box-shadow 0.2s;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
height: 330px;
|
||||
/* max-height: 400px; */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -199,6 +201,7 @@ body {
|
||||
padding-top: 56.25%; /* 16:9 aspect ratio */
|
||||
overflow: hidden;
|
||||
background-color: var(--background-darker);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
@@ -212,9 +215,10 @@ body {
|
||||
|
||||
.video-info {
|
||||
padding: var(--spacing-md);
|
||||
flex-grow: 1;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Clickable elements */
|
||||
@@ -239,6 +243,7 @@ body {
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
color: var(--text-color);
|
||||
max-height: 42px; /* Approximately 2 lines of text with line-height 1.3 */
|
||||
}
|
||||
|
||||
.video-title.clickable:hover {
|
||||
@@ -1121,75 +1126,131 @@ body {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--background-lighter);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--spacing-lg);
|
||||
width: 90%;
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
animation: modalFadeIn 0.3s ease-out;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin-top: 0;
|
||||
color: var(--text-color);
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--text-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin: 15px 0 10px 0;
|
||||
font-size: 1.1rem;
|
||||
margin: var(--spacing-md) 0 var(--spacing-sm);
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.existing-collections,
|
||||
.new-collection {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
@keyframes modalFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.existing-collections select,
|
||||
.new-collection input {
|
||||
/* Existing collections section */
|
||||
.existing-collections {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.existing-collections select {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
padding: 10px;
|
||||
background-color: var(--background-darker);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 1rem;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
background-color: var(--background-dark);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.modal-content button {
|
||||
.existing-collections select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.existing-collections button,
|
||||
.new-collection button {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
cursor: pointer;
|
||||
padding: 10px 15px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.modal-content button:hover {
|
||||
.existing-collections button:hover,
|
||||
.new-collection button:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.modal-content button:disabled {
|
||||
.existing-collections button:disabled,
|
||||
.new-collection button:disabled {
|
||||
background-color: var(--secondary-color);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* New collection section */
|
||||
.new-collection {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.new-collection input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: var(--background-darker);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.new-collection input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Close modal button */
|
||||
.close-modal {
|
||||
background-color: var(--secondary-color) !important;
|
||||
margin-top: var(--spacing-sm);
|
||||
background-color: var(--secondary-color);
|
||||
color: var(--text-color);
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 10px 15px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.close-modal:hover {
|
||||
background-color: var(--hover-color) !important;
|
||||
background-color: var(--hover-color);
|
||||
}
|
||||
|
||||
/* Video collections info */
|
||||
@@ -1379,39 +1440,38 @@ body {
|
||||
|
||||
/* Current collection in modal */
|
||||
.current-collection {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding: var(--spacing-md);
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: rgba(255, 62, 62, 0.05);
|
||||
border-left: 3px solid var(--primary-color);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.collection-note {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.collection-warning {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
margin: 0 0 15px 0;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.remove-from-collection {
|
||||
background-color: var(--primary-color) !important;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-color);
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s;
|
||||
margin-top: var(--spacing-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.remove-from-collection:hover {
|
||||
background-color: var(--primary-hover) !important;
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* Delete Collection Modal */
|
||||
@@ -1467,3 +1527,137 @@ body {
|
||||
.modal-button.danger:hover {
|
||||
background-color: #f44336;
|
||||
}
|
||||
|
||||
/* Part badge for multi-part videos */
|
||||
.part-badge {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Video collections in video card */
|
||||
.video-card .video-collections {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.video-card .video-collection-tag {
|
||||
display: inline-block;
|
||||
background-color: var(--background-lighter);
|
||||
color: var(--primary-color);
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.video-card .video-collection-tag:hover {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Collection badge for videos that are first in a collection */
|
||||
.collection-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
z-index: 2;
|
||||
border: 1px solid var(--primary-color);
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.collection-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Style for collection tags that are first in collection */
|
||||
.video-collection-tag.first-in-collection {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.first-badge {
|
||||
display: inline-block;
|
||||
margin-left: 3px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Collection title styling */
|
||||
.collection-title-prefix {
|
||||
color: var(--primary-color);
|
||||
font-weight: bold;
|
||||
font-size: 0.85em;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.more-collections {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
/* Make collection-first cards more prominent */
|
||||
.video-card.collection-first {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-card.collection-first::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: var(--primary-color);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Improve collection badge visibility */
|
||||
.collection-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
z-index: 2;
|
||||
border: 1px solid var(--primary-color);
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.collection-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Style for collection tags that are first in collection */
|
||||
.video-collection-tag.first-in-collection {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.first-badge {
|
||||
display: inline-block;
|
||||
margin-left: 3px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios from 'axios';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Route, BrowserRouter as Router, Routes } from 'react-router-dom';
|
||||
import './App.css';
|
||||
import BilibiliPartsModal from './components/BilibiliPartsModal';
|
||||
import Header from './components/Header';
|
||||
import AuthorVideos from './pages/AuthorVideos';
|
||||
import CollectionPage from './pages/CollectionPage';
|
||||
@@ -47,6 +48,15 @@ function App() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [collections, setCollections] = useState([]);
|
||||
|
||||
// Bilibili multi-part video state
|
||||
const [showBilibiliPartsModal, setShowBilibiliPartsModal] = useState(false);
|
||||
const [bilibiliPartsInfo, setBilibiliPartsInfo] = useState({
|
||||
videosNumber: 0,
|
||||
title: '',
|
||||
url: ''
|
||||
});
|
||||
const [isCheckingParts, setIsCheckingParts] = useState(false);
|
||||
|
||||
// Reference to the current search request's abort controller
|
||||
const searchAbortController = useRef(null);
|
||||
|
||||
@@ -197,6 +207,35 @@ function App() {
|
||||
|
||||
const handleVideoSubmit = async (videoUrl) => {
|
||||
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`, {
|
||||
params: { url: videoUrl }
|
||||
});
|
||||
|
||||
if (response.data.success && response.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
|
||||
});
|
||||
setShowBilibiliPartsModal(true);
|
||||
setIsCheckingParts(false);
|
||||
return { success: true };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking Bilibili parts:', err);
|
||||
// Continue with normal download if check fails
|
||||
} finally {
|
||||
setIsCheckingParts(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Normal download flow
|
||||
setLoading(true);
|
||||
// Extract title from URL for display during download
|
||||
let displayTitle = videoUrl;
|
||||
@@ -449,11 +488,14 @@ function App() {
|
||||
// This is handled on the server side now
|
||||
|
||||
// Add the video to the selected collection
|
||||
const response = await axios.put(`${API_URL}/collections/${collectionId}/videos/${videoId}`);
|
||||
const response = await axios.put(`${API_URL}/collections/${collectionId}`, {
|
||||
videoId,
|
||||
action: "add"
|
||||
});
|
||||
|
||||
// Update the collections state with the new video
|
||||
// Update the collections state with the updated collection from the server
|
||||
setCollections(prevCollections => prevCollections.map(collection =>
|
||||
collection.id === collectionId ? { ...collection, videos: [...collection.videos, response.data] } : collection
|
||||
collection.id === collectionId ? response.data : collection
|
||||
));
|
||||
|
||||
return response.data;
|
||||
@@ -493,8 +535,23 @@ function App() {
|
||||
};
|
||||
|
||||
// Delete a collection
|
||||
const handleDeleteCollection = async (collectionId) => {
|
||||
const handleDeleteCollection = async (collectionId, deleteVideos = false) => {
|
||||
try {
|
||||
// Get the collection before deleting it
|
||||
const collection = collections.find(c => c.id === collectionId);
|
||||
|
||||
if (!collection) {
|
||||
return { success: false, error: 'Collection not found' };
|
||||
}
|
||||
|
||||
// If deleteVideos is true, delete all videos in the collection
|
||||
if (deleteVideos && collection.videos.length > 0) {
|
||||
for (const videoId of collection.videos) {
|
||||
await handleDeleteVideo(videoId);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the collection
|
||||
await axios.delete(`${API_URL}/collections/${collectionId}`);
|
||||
|
||||
// Update the collections state
|
||||
@@ -509,6 +566,57 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle downloading all parts of a Bilibili video
|
||||
const handleDownloadAllBilibiliParts = async (collectionName) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setShowBilibiliPartsModal(false);
|
||||
|
||||
// Set download status before making the API call
|
||||
setDownloadingTitle(`Downloading ${bilibiliPartsInfo.title}...`);
|
||||
|
||||
const response = await axios.post(`${API_URL}/download`, {
|
||||
youtubeUrl: bilibiliPartsInfo.url,
|
||||
downloadAllParts: true,
|
||||
collectionName
|
||||
});
|
||||
|
||||
// Add the first video to the list
|
||||
setVideos(prevVideos => [response.data.video, ...prevVideos]);
|
||||
|
||||
// If a collection was created, refresh collections
|
||||
if (response.data.collectionId) {
|
||||
await fetchCollections();
|
||||
}
|
||||
|
||||
setIsSearchMode(false);
|
||||
|
||||
// Note: We don't clear download status here because the backend will continue
|
||||
// downloading parts in the background. The status will be updated via polling.
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error downloading Bilibili parts:', err);
|
||||
|
||||
// Clear download status on error
|
||||
setDownloadingTitle('');
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.response?.data?.error || 'Failed to download video parts. Please try again.'
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle downloading only the current part of a Bilibili video
|
||||
const handleDownloadCurrentBilibiliPart = async () => {
|
||||
setShowBilibiliPartsModal(false);
|
||||
return await handleVideoSubmit(bilibiliPartsInfo.url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="app">
|
||||
@@ -522,6 +630,17 @@ function App() {
|
||||
onResetSearch={resetSearch}
|
||||
/>
|
||||
|
||||
{/* Bilibili Parts Modal */}
|
||||
<BilibiliPartsModal
|
||||
isOpen={showBilibiliPartsModal}
|
||||
onClose={() => setShowBilibiliPartsModal(false)}
|
||||
videosNumber={bilibiliPartsInfo.videosNumber}
|
||||
videoTitle={bilibiliPartsInfo.title}
|
||||
onDownloadAll={handleDownloadAllBilibiliParts}
|
||||
onDownloadCurrent={handleDownloadCurrentBilibiliPart}
|
||||
isLoading={loading || isCheckingParts}
|
||||
/>
|
||||
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -555,6 +674,7 @@ function App() {
|
||||
<AuthorVideos
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -578,6 +698,9 @@ function App() {
|
||||
loading={youtubeLoading}
|
||||
searchTerm={searchTerm}
|
||||
onDownload={handleDownloadFromSearch}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
onResetSearch={resetSearch}
|
||||
collections={collections}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
71
frontend/src/components/BilibiliPartsModal.jsx
Normal file
71
frontend/src/components/BilibiliPartsModal.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const BilibiliPartsModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
videosNumber,
|
||||
videoTitle,
|
||||
onDownloadAll,
|
||||
onDownloadCurrent,
|
||||
isLoading
|
||||
}) => {
|
||||
const [collectionName, setCollectionName] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleDownloadAll = () => {
|
||||
onDownloadAll(collectionName || videoTitle);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h2>Multi-part Video Detected</h2>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p>
|
||||
This Bilibili video has <strong>{videosNumber}</strong> parts.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Title:</strong> {videoTitle}
|
||||
</p>
|
||||
<p>Would you like to download all parts?</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="collection-name">Collection Name:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="collection-name"
|
||||
className="collection-input"
|
||||
value={collectionName}
|
||||
onChange={(e) => setCollectionName(e.target.value)}
|
||||
placeholder={videoTitle}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<small>All parts will be added to this collection</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn secondary-btn"
|
||||
onClick={onDownloadCurrent}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Processing...' : 'Download Current Part Only'}
|
||||
</button>
|
||||
<button
|
||||
className="btn primary-btn"
|
||||
onClick={handleDownloadAll}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Processing...' : `Download All ${videosNumber} Parts`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BilibiliPartsModal;
|
||||
@@ -2,7 +2,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
|
||||
const VideoCard = ({ video }) => {
|
||||
const VideoCard = ({ video, collections = [] }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Format the date (assuming format YYYYMMDD from youtube-dl)
|
||||
@@ -29,10 +29,37 @@ const VideoCard = ({ video }) => {
|
||||
navigate(`/author/${encodeURIComponent(video.author)}`);
|
||||
};
|
||||
|
||||
// Find collections this video belongs to
|
||||
const videoCollections = collections.filter(collection =>
|
||||
collection.videos.includes(video.id)
|
||||
);
|
||||
|
||||
// Check if this video is the first in any collection
|
||||
const isFirstInAnyCollection = videoCollections.some(collection =>
|
||||
collection.videos[0] === video.id
|
||||
);
|
||||
|
||||
// Get collection names where this video is the first
|
||||
const firstInCollectionNames = videoCollections
|
||||
.filter(collection => collection.videos[0] === video.id)
|
||||
.map(collection => collection.name);
|
||||
|
||||
// Get the first collection ID where this video is the first video
|
||||
const firstCollectionId = isFirstInAnyCollection
|
||||
? videoCollections.find(collection => collection.videos[0] === video.id)?.id
|
||||
: null;
|
||||
|
||||
// Handle video navigation
|
||||
const handleVideoNavigation = () => {
|
||||
navigate(`/video/${video.id}`);
|
||||
// If this is the first video in a collection, navigate to the collection page
|
||||
if (isFirstInAnyCollection && firstCollectionId) {
|
||||
navigate(`/collection/${firstCollectionId}`);
|
||||
} else {
|
||||
// Otherwise navigate to the video player page
|
||||
navigate(`/video/${video.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Get source icon
|
||||
const getSourceIcon = () => {
|
||||
if (video.source === 'bilibili') {
|
||||
@@ -50,11 +77,13 @@ const VideoCard = ({ video }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="video-card">
|
||||
<div className={`video-card ${isFirstInAnyCollection ? 'collection-first' : ''}`}>
|
||||
<div
|
||||
className="thumbnail-container clickable"
|
||||
onClick={handleVideoNavigation}
|
||||
aria-label={`Play ${video.title}`}
|
||||
aria-label={isFirstInAnyCollection
|
||||
? `View collection: ${firstInCollectionNames[0]}${firstInCollectionNames.length > 1 ? ' and others' : ''}`
|
||||
: `Play ${video.title}`}
|
||||
>
|
||||
<img
|
||||
src={thumbnailSrc}
|
||||
@@ -67,13 +96,34 @@ const VideoCard = ({ video }) => {
|
||||
}}
|
||||
/>
|
||||
{getSourceIcon()}
|
||||
|
||||
{/* Show part number for multi-part videos */}
|
||||
{video.partNumber && video.totalParts > 1 && (
|
||||
<div className="part-badge">
|
||||
Part {video.partNumber}/{video.totalParts}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show collection badge if this is the first video in a collection */}
|
||||
{isFirstInAnyCollection && (
|
||||
<div className="collection-badge" title={`Collection${firstInCollectionNames.length > 1 ? 's' : ''}: ${firstInCollectionNames.join(', ')}`}>
|
||||
<span className="collection-icon">📁</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="video-info">
|
||||
<h3
|
||||
className="video-title clickable"
|
||||
onClick={handleVideoNavigation}
|
||||
>
|
||||
{video.title}
|
||||
{isFirstInAnyCollection ? (
|
||||
<>
|
||||
{firstInCollectionNames[0]}
|
||||
{firstInCollectionNames.length > 1 && <span className="more-collections"> +{firstInCollectionNames.length - 1}</span>}
|
||||
</>
|
||||
) : (
|
||||
video.title
|
||||
)}
|
||||
</h3>
|
||||
<div className="video-meta">
|
||||
<span
|
||||
@@ -87,6 +137,7 @@ const VideoCard = ({ video }) => {
|
||||
</span>
|
||||
<span className="video-date">{formatDate(video.date)}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import VideoCard from '../components/VideoCard';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
const AuthorVideos = ({ videos: allVideos, onDeleteVideo }) => {
|
||||
const AuthorVideos = ({ videos: allVideos, onDeleteVideo, collections = [] }) => {
|
||||
const { author } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [authorVideos, setAuthorVideos] = useState([]);
|
||||
@@ -56,6 +56,26 @@ const AuthorVideos = ({ videos: allVideos, onDeleteVideo }) => {
|
||||
return <div className="error">{error}</div>;
|
||||
}
|
||||
|
||||
// Filter videos to only show the first video from each collection
|
||||
const filteredVideos = authorVideos.filter(video => {
|
||||
// If the video is not in any collection, show it
|
||||
const videoCollections = collections.filter(collection =>
|
||||
collection.videos.includes(video.id)
|
||||
);
|
||||
|
||||
if (videoCollections.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For each collection this video is in, check if it's the first video
|
||||
return videoCollections.some(collection => {
|
||||
// Get the first video ID in this collection
|
||||
const firstVideoId = collection.videos[0];
|
||||
// Show this video if it's the first in at least one collection
|
||||
return video.id === firstVideoId;
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="author-videos-container">
|
||||
<div className="author-header">
|
||||
@@ -72,12 +92,13 @@ const AuthorVideos = ({ videos: allVideos, onDeleteVideo }) => {
|
||||
<div className="no-videos">No videos found for this author.</div>
|
||||
) : (
|
||||
<div className="videos-grid">
|
||||
{authorVideos.map(video => (
|
||||
{filteredVideos.map(video => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
video={video}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
showDeleteButton={true}
|
||||
collections={collections}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import AuthorsList from '../components/AuthorsList';
|
||||
import Collections from '../components/Collections';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
|
||||
const Home = ({ videos = [], loading, error, onDeleteVideo, collections }) => {
|
||||
const Home = ({ videos = [], loading, error, onDeleteVideo, collections = [] }) => {
|
||||
// Add default empty array to ensure videos is always an array
|
||||
const videoArray = Array.isArray(videos) ? videos : [];
|
||||
|
||||
@@ -14,6 +14,26 @@ const Home = ({ videos = [], loading, error, onDeleteVideo, collections }) => {
|
||||
return <div className="error">{error}</div>;
|
||||
}
|
||||
|
||||
// Filter videos to only show the first video from each collection
|
||||
const filteredVideos = videoArray.filter(video => {
|
||||
// If the video is not in any collection, show it
|
||||
const videoCollections = collections.filter(collection =>
|
||||
collection.videos.includes(video.id)
|
||||
);
|
||||
|
||||
if (videoCollections.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For each collection this video is in, check if it's the first video
|
||||
return videoCollections.some(collection => {
|
||||
// Get the first video ID in this collection
|
||||
const firstVideoId = collection.videos[0];
|
||||
// Show this video if it's the first in at least one collection
|
||||
return video.id === firstVideoId;
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
{videoArray.length === 0 ? (
|
||||
@@ -33,12 +53,13 @@ const Home = ({ videos = [], loading, error, onDeleteVideo, collections }) => {
|
||||
|
||||
{/* Videos grid */}
|
||||
<div className="videos-grid">
|
||||
{videoArray.map(video => (
|
||||
{filteredVideos.map(video => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
video={video}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
showDeleteButton={true}
|
||||
collections={collections}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
|
||||
// Define the API base URL
|
||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
@@ -12,7 +13,8 @@ const SearchResults = ({
|
||||
youtubeLoading,
|
||||
onDownload,
|
||||
onDeleteVideo,
|
||||
onResetSearch
|
||||
onResetSearch,
|
||||
collections = []
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -33,14 +35,6 @@ const SearchResults = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await onDeleteVideo(id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting video:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackClick = () => {
|
||||
// Call the onResetSearch function to reset search mode
|
||||
if (onResetSearch) {
|
||||
@@ -99,49 +93,13 @@ const SearchResults = ({
|
||||
<h3 className="section-title">From Your Library</h3>
|
||||
<div className="search-results-grid">
|
||||
{localResults.map((video) => (
|
||||
<div key={video.id} className="search-result-card local-result">
|
||||
<Link to={`/video/${video.id}`} className="video-link">
|
||||
<div className="search-result-thumbnail">
|
||||
{video.thumbnailPath ? (
|
||||
<img
|
||||
src={`${API_BASE_URL}${video.thumbnailPath}`}
|
||||
alt={video.title}
|
||||
onError={(e) => {
|
||||
e.target.onerror = null;
|
||||
e.target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="thumbnail-placeholder">No Thumbnail</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="search-result-info">
|
||||
<Link to={`/video/${video.id}`} className="video-link">
|
||||
<h3 className="search-result-title">{video.title}</h3>
|
||||
</Link>
|
||||
<Link to={`/author/${encodeURIComponent(video.author)}`} className="author-link">
|
||||
<p className="search-result-author">{video.author}</p>
|
||||
</Link>
|
||||
<div className="search-result-meta">
|
||||
<span className="search-result-date">{formatDate(video.date)}</span>
|
||||
<span className={`source-badge ${video.source}`}>
|
||||
{video.source}
|
||||
</span>
|
||||
</div>
|
||||
<div className="search-result-actions">
|
||||
<Link to={`/video/${video.id}`} className="play-btn">
|
||||
Play
|
||||
</Link>
|
||||
<button
|
||||
className="delete-btn"
|
||||
onClick={() => handleDelete(video.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
video={video}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
showDeleteButton={true}
|
||||
collections={collections}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,20 +189,4 @@ const formatViewCount = (count) => {
|
||||
return `${(count / 1000000).toFixed(1)}M`;
|
||||
};
|
||||
|
||||
// Helper function to format date
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
|
||||
// Handle YYYYMMDD format
|
||||
if (dateStr.length === 8) {
|
||||
const year = dateStr.substring(0, 4);
|
||||
const month = dateStr.substring(4, 6);
|
||||
const day = dateStr.substring(6, 8);
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Return as is if it's already formatted
|
||||
return dateStr;
|
||||
};
|
||||
|
||||
export default SearchResults;
|
||||
Reference in New Issue
Block a user