feat: Add Bilibili multi-part download functionality

This commit is contained in:
Peifan Li
2025-03-12 23:21:52 -04:00
parent 6e953d073d
commit 22571be6c7
9 changed files with 1101 additions and 229 deletions

View File

@@ -1,5 +1,5 @@
{
"isDownloading": false,
"title": "",
"timestamp": 1741832171094
"timestamp": 1741836072302
}

View File

@@ -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}`);

View File

@@ -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;
}

View File

@@ -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}
/>
}
/>

View 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;

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;