feat: Add active downloads indicator
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"isDownloading": false,
|
||||
"title": "",
|
||||
"timestamp": 1763754827104
|
||||
"timestamp": 1763754827104,
|
||||
"activeDownloads": []
|
||||
}
|
||||
@@ -29,6 +29,10 @@ const searchVideos = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
const downloadManager = require("../services/downloadManager");
|
||||
|
||||
// ... (imports remain the same)
|
||||
|
||||
// Download video
|
||||
const downloadVideo = async (req, res) => {
|
||||
try {
|
||||
@@ -55,151 +59,152 @@ const downloadVideo = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Set download status to true with initial title
|
||||
let initialTitle = "Downloading video...";
|
||||
// Determine initial title for the download task
|
||||
let initialTitle = "Video";
|
||||
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be")) {
|
||||
initialTitle = "Downloading YouTube video...";
|
||||
initialTitle = "YouTube Video";
|
||||
} else if (isBilibiliUrl(videoUrl)) {
|
||||
initialTitle = "Downloading Bilibili video...";
|
||||
}
|
||||
storageService.updateDownloadStatus(true, initialTitle);
|
||||
|
||||
// Resolve shortened URLs (like b23.tv)
|
||||
if (videoUrl.includes("b23.tv")) {
|
||||
videoUrl = await resolveShortUrl(videoUrl);
|
||||
console.log("Resolved shortened URL to:", videoUrl);
|
||||
initialTitle = "Bilibili Video";
|
||||
}
|
||||
|
||||
// Trim Bilibili URL if needed
|
||||
if (isBilibiliUrl(videoUrl)) {
|
||||
videoUrl = trimBilibiliUrl(videoUrl);
|
||||
console.log("Using trimmed Bilibili URL:", videoUrl);
|
||||
// Generate a unique ID for this download task
|
||||
const downloadId = Date.now().toString();
|
||||
|
||||
// If downloadAllParts is true, handle multi-part download
|
||||
if (downloadAllParts) {
|
||||
const videoId = extractBilibiliVideoId(videoUrl);
|
||||
if (!videoId) {
|
||||
storageService.updateDownloadStatus(false);
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Could not extract Bilibili video ID" });
|
||||
}
|
||||
|
||||
// Get video info to determine number of parts
|
||||
const partsInfo = await downloadService.checkBilibiliVideoParts(videoId);
|
||||
|
||||
if (!partsInfo.success) {
|
||||
storageService.updateDownloadStatus(false);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get video parts information" });
|
||||
}
|
||||
|
||||
const { videosNumber, title } = partsInfo;
|
||||
|
||||
// Create a collection for the multi-part video if collectionName is provided
|
||||
let collectionId = null;
|
||||
if (collectionName) {
|
||||
const newCollection = {
|
||||
id: Date.now().toString(),
|
||||
name: collectionName,
|
||||
videos: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
storageService.saveCollection(newCollection);
|
||||
collectionId = newCollection.id;
|
||||
}
|
||||
|
||||
// Start downloading the first part
|
||||
const baseUrl = videoUrl.split("?")[0];
|
||||
const firstPartUrl = `${baseUrl}?p=1`;
|
||||
|
||||
storageService.updateDownloadStatus(
|
||||
true,
|
||||
`Downloading part 1/${videosNumber}: ${title}`
|
||||
);
|
||||
|
||||
// Download the first part
|
||||
const firstPartResult = await downloadService.downloadSingleBilibiliPart(
|
||||
firstPartUrl,
|
||||
1,
|
||||
videosNumber,
|
||||
title
|
||||
);
|
||||
|
||||
// Add to collection if needed
|
||||
if (collectionId && firstPartResult.videoData) {
|
||||
storageService.atomicUpdateCollection(collectionId, (collection) => {
|
||||
collection.videos.push(firstPartResult.videoData.id);
|
||||
return collection;
|
||||
});
|
||||
}
|
||||
|
||||
// Set up background download for remaining parts
|
||||
if (videosNumber > 1) {
|
||||
downloadService.downloadRemainingBilibiliParts(
|
||||
baseUrl,
|
||||
2,
|
||||
videosNumber,
|
||||
title,
|
||||
collectionId
|
||||
);
|
||||
} else {
|
||||
storageService.updateDownloadStatus(false);
|
||||
}
|
||||
|
||||
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");
|
||||
storageService.updateDownloadStatus(true, "Downloading Bilibili video...");
|
||||
|
||||
// Use downloadSingleBilibiliPart for consistency, but treat as single part
|
||||
// Or use the logic from server.js which called downloadBilibiliVideo directly
|
||||
// server.js logic for single video was slightly different (it handled renaming and saving)
|
||||
// I'll use downloadSingleBilibiliPart with part 1/1 to simplify
|
||||
// Wait, downloadSingleBilibiliPart adds "Part 1/1" to title if totalParts > 1.
|
||||
// If totalParts is 1, it uses original title.
|
||||
|
||||
// We need to get the title first to pass to downloadSingleBilibiliPart?
|
||||
// No, downloadSingleBilibiliPart fetches info.
|
||||
|
||||
// Let's use downloadSingleBilibiliPart with totalParts=1.
|
||||
// But we don't have seriesTitle.
|
||||
// downloadSingleBilibiliPart uses seriesTitle only if totalParts > 1.
|
||||
|
||||
const result = await downloadService.downloadSingleBilibiliPart(
|
||||
videoUrl,
|
||||
1,
|
||||
1,
|
||||
"" // seriesTitle not used when totalParts is 1
|
||||
);
|
||||
|
||||
storageService.updateDownloadStatus(false);
|
||||
|
||||
if (result.success) {
|
||||
return res.status(200).json({ success: true, video: result.videoData });
|
||||
} else {
|
||||
throw new Error(result.error || "Failed to download Bilibili video");
|
||||
}
|
||||
// Define the download task function
|
||||
const downloadTask = async () => {
|
||||
// Resolve shortened URLs (like b23.tv)
|
||||
if (videoUrl.includes("b23.tv")) {
|
||||
videoUrl = await resolveShortUrl(videoUrl);
|
||||
console.log("Resolved shortened URL to:", videoUrl);
|
||||
}
|
||||
} else {
|
||||
// YouTube download
|
||||
const videoData = await downloadService.downloadYouTubeVideo(videoUrl);
|
||||
return res.status(200).json({ success: true, video: videoData });
|
||||
}
|
||||
|
||||
// Trim Bilibili URL if needed
|
||||
if (isBilibiliUrl(videoUrl)) {
|
||||
videoUrl = trimBilibiliUrl(videoUrl);
|
||||
console.log("Using trimmed Bilibili URL:", videoUrl);
|
||||
|
||||
// If downloadAllParts is true, handle multi-part download
|
||||
if (downloadAllParts) {
|
||||
const videoId = extractBilibiliVideoId(videoUrl);
|
||||
if (!videoId) {
|
||||
throw new Error("Could not extract Bilibili video ID");
|
||||
}
|
||||
|
||||
// Get video info to determine number of parts
|
||||
const partsInfo = await downloadService.checkBilibiliVideoParts(videoId);
|
||||
|
||||
if (!partsInfo.success) {
|
||||
throw new Error("Failed to get video parts information");
|
||||
}
|
||||
|
||||
const { videosNumber, title } = partsInfo;
|
||||
|
||||
// Update title in storage
|
||||
storageService.addActiveDownload(downloadId, title);
|
||||
|
||||
// Create a collection for the multi-part video if collectionName is provided
|
||||
let collectionId = null;
|
||||
if (collectionName) {
|
||||
const newCollection = {
|
||||
id: Date.now().toString(),
|
||||
name: collectionName,
|
||||
videos: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
storageService.saveCollection(newCollection);
|
||||
collectionId = newCollection.id;
|
||||
}
|
||||
|
||||
// Start downloading the first part
|
||||
const baseUrl = videoUrl.split("?")[0];
|
||||
const firstPartUrl = `${baseUrl}?p=1`;
|
||||
|
||||
// Download the first part
|
||||
const firstPartResult = await downloadService.downloadSingleBilibiliPart(
|
||||
firstPartUrl,
|
||||
1,
|
||||
videosNumber,
|
||||
title
|
||||
);
|
||||
|
||||
// Add to collection if needed
|
||||
if (collectionId && firstPartResult.videoData) {
|
||||
storageService.atomicUpdateCollection(collectionId, (collection) => {
|
||||
collection.videos.push(firstPartResult.videoData.id);
|
||||
return collection;
|
||||
});
|
||||
}
|
||||
|
||||
// Set up background download for remaining parts
|
||||
// Note: We don't await this, it runs in background
|
||||
// But we should probably track it? For now, let's keep it simple
|
||||
// and only track the first part as the "main" download
|
||||
if (videosNumber > 1) {
|
||||
downloadService.downloadRemainingBilibiliParts(
|
||||
baseUrl,
|
||||
2,
|
||||
videosNumber,
|
||||
title,
|
||||
collectionId
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
video: firstPartResult.videoData,
|
||||
isMultiPart: true,
|
||||
totalParts: videosNumber,
|
||||
collectionId,
|
||||
};
|
||||
} else {
|
||||
// Regular single video download for Bilibili
|
||||
console.log("Downloading single Bilibili video part");
|
||||
|
||||
const result = await downloadService.downloadSingleBilibiliPart(
|
||||
videoUrl,
|
||||
1,
|
||||
1,
|
||||
"" // seriesTitle not used when totalParts is 1
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
return { success: true, video: result.videoData };
|
||||
} else {
|
||||
throw new Error(result.error || "Failed to download Bilibili video");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// YouTube download
|
||||
const videoData = await downloadService.downloadYouTubeVideo(videoUrl);
|
||||
return { success: true, video: videoData };
|
||||
}
|
||||
};
|
||||
|
||||
// Add to download manager
|
||||
// We don't await the result here because we want to return immediately
|
||||
// that the download has been queued/started
|
||||
downloadManager.addDownload(downloadTask, downloadId, initialTitle)
|
||||
.then(result => {
|
||||
console.log("Download completed successfully:", result);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Download failed:", error);
|
||||
});
|
||||
|
||||
// Return success immediately indicating the download is queued/started
|
||||
// We can't return the video object yet because it hasn't been downloaded
|
||||
// The frontend will need to refresh or listen for updates
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Download queued",
|
||||
downloadId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error downloading video:", error);
|
||||
storageService.updateDownloadStatus(false);
|
||||
console.error("Error queuing download:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to download video", details: error.message });
|
||||
.json({ error: "Failed to queue download", details: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
82
backend/src/services/downloadManager.js
Normal file
82
backend/src/services/downloadManager.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const storageService = require("./storageService");
|
||||
|
||||
class DownloadManager {
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.activeDownloads = 0;
|
||||
this.maxConcurrentDownloads = 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a download task to the manager
|
||||
* @param {Function} downloadFn - Async function that performs the download
|
||||
* @param {string} id - Unique ID for the download
|
||||
* @param {string} title - Title of the video being downloaded
|
||||
* @returns {Promise} - Resolves when the download is complete
|
||||
*/
|
||||
async addDownload(downloadFn, id, title) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const task = {
|
||||
downloadFn,
|
||||
id,
|
||||
title,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
|
||||
this.queue.push(task);
|
||||
this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the download queue
|
||||
*/
|
||||
async processQueue() {
|
||||
if (
|
||||
this.activeDownloads >= this.maxConcurrentDownloads ||
|
||||
this.queue.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const task = this.queue.shift();
|
||||
this.activeDownloads++;
|
||||
|
||||
// Update status in storage
|
||||
storageService.addActiveDownload(task.id, task.title);
|
||||
|
||||
try {
|
||||
console.log(`Starting download: ${task.title} (${task.id})`);
|
||||
const result = await task.downloadFn();
|
||||
|
||||
// Download complete
|
||||
storageService.removeActiveDownload(task.id);
|
||||
this.activeDownloads--;
|
||||
task.resolve(result);
|
||||
} catch (error) {
|
||||
console.error(`Error downloading ${task.title}:`, error);
|
||||
|
||||
// Download failed
|
||||
storageService.removeActiveDownload(task.id);
|
||||
this.activeDownloads--;
|
||||
task.reject(error);
|
||||
} finally {
|
||||
// Process next item in queue
|
||||
this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current status
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
active: this.activeDownloads,
|
||||
queued: this.queue.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
module.exports = new DownloadManager();
|
||||
@@ -294,15 +294,23 @@ async function downloadRemainingBilibiliParts(
|
||||
startPart,
|
||||
totalParts,
|
||||
seriesTitle,
|
||||
collectionId
|
||||
collectionId,
|
||||
downloadId
|
||||
) {
|
||||
try {
|
||||
// Add to active downloads if ID is provided
|
||||
if (downloadId) {
|
||||
storageService.addActiveDownload(downloadId, `Downloading ${seriesTitle}`);
|
||||
}
|
||||
|
||||
for (let part = startPart; part <= totalParts; part++) {
|
||||
// Update status to show which part is being downloaded
|
||||
storageService.updateDownloadStatus(
|
||||
true,
|
||||
`Downloading part ${part}/${totalParts}: ${seriesTitle}`
|
||||
);
|
||||
if (downloadId) {
|
||||
storageService.addActiveDownload(
|
||||
downloadId,
|
||||
`Downloading part ${part}/${totalParts}: ${seriesTitle}`
|
||||
);
|
||||
}
|
||||
|
||||
// Construct URL for this part
|
||||
const partUrl = `${baseUrl}?p=${part}`;
|
||||
@@ -338,14 +346,18 @@ async function downloadRemainingBilibiliParts(
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// All parts downloaded, update status
|
||||
storageService.updateDownloadStatus(false);
|
||||
// All parts downloaded, remove from active downloads
|
||||
if (downloadId) {
|
||||
storageService.removeActiveDownload(downloadId);
|
||||
}
|
||||
console.log(
|
||||
`All ${totalParts} parts of "${seriesTitle}" downloaded successfully`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error downloading remaining Bilibili parts:", error);
|
||||
storageService.updateDownloadStatus(false);
|
||||
if (downloadId) {
|
||||
storageService.removeActiveDownload(downloadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,7 +400,7 @@ async function searchYouTube(query) {
|
||||
// Download YouTube video
|
||||
async function downloadYouTubeVideo(videoUrl) {
|
||||
console.log("Detected YouTube URL");
|
||||
storageService.updateDownloadStatus(true, "Downloading YouTube video...");
|
||||
// storageService.updateDownloadStatus(true, "Downloading YouTube video..."); // Removed
|
||||
|
||||
// Create a safe base filename (without extension)
|
||||
const timestamp = Date.now();
|
||||
@@ -430,7 +442,7 @@ async function downloadYouTubeVideo(videoUrl) {
|
||||
thumbnailUrl = info.thumbnail;
|
||||
|
||||
// Update download status with actual title
|
||||
storageService.updateDownloadStatus(true, `Downloading: ${videoTitle}`);
|
||||
// storageService.updateDownloadStatus(true, `Downloading: ${videoTitle}`); // Removed
|
||||
|
||||
// Update the safe base filename with the actual title
|
||||
const newSafeBaseFilename = `${sanitizeFilename(
|
||||
@@ -491,7 +503,7 @@ async function downloadYouTubeVideo(videoUrl) {
|
||||
} catch (youtubeError) {
|
||||
console.error("Error in YouTube download process:", youtubeError);
|
||||
// Set download status to false on error
|
||||
storageService.updateDownloadStatus(false);
|
||||
// storageService.updateDownloadStatus(false); // Removed
|
||||
throw youtubeError;
|
||||
}
|
||||
|
||||
@@ -520,7 +532,7 @@ async function downloadYouTubeVideo(videoUrl) {
|
||||
console.log("Video added to database");
|
||||
|
||||
// Set download status to false when complete
|
||||
storageService.updateDownloadStatus(false);
|
||||
// storageService.updateDownloadStatus(false); // Removed
|
||||
|
||||
return videoData;
|
||||
}
|
||||
|
||||
@@ -17,48 +17,89 @@ function initializeStorage() {
|
||||
fs.ensureDirSync(IMAGES_DIR);
|
||||
fs.ensureDirSync(DATA_DIR);
|
||||
|
||||
// Initialize status.json if it doesn't exist
|
||||
// Initialize status.json if it doesn't exist
|
||||
if (!fs.existsSync(STATUS_DATA_PATH)) {
|
||||
fs.writeFileSync(
|
||||
STATUS_DATA_PATH,
|
||||
JSON.stringify({ isDownloading: false, title: "" }, null, 2)
|
||||
JSON.stringify({ activeDownloads: [] }, null, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update download status
|
||||
function updateDownloadStatus(isDownloading, title = "") {
|
||||
// Add an active download
|
||||
function addActiveDownload(id, title) {
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
STATUS_DATA_PATH,
|
||||
JSON.stringify({ isDownloading, title, timestamp: Date.now() }, null, 2)
|
||||
);
|
||||
console.log(
|
||||
`Download status updated: isDownloading=${isDownloading}, title=${title}`
|
||||
);
|
||||
const status = getDownloadStatus();
|
||||
const existingIndex = status.activeDownloads.findIndex(d => d.id === id);
|
||||
|
||||
const downloadInfo = {
|
||||
id,
|
||||
title,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
status.activeDownloads[existingIndex] = downloadInfo;
|
||||
} else {
|
||||
status.activeDownloads.push(downloadInfo);
|
||||
}
|
||||
|
||||
fs.writeFileSync(STATUS_DATA_PATH, JSON.stringify(status, null, 2));
|
||||
console.log(`Added/Updated active download: ${title} (${id})`);
|
||||
} catch (error) {
|
||||
console.error("Error updating download status:", error);
|
||||
console.error("Error adding active download:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove an active download
|
||||
function removeActiveDownload(id) {
|
||||
try {
|
||||
const status = getDownloadStatus();
|
||||
const initialLength = status.activeDownloads.length;
|
||||
status.activeDownloads = status.activeDownloads.filter(d => d.id !== id);
|
||||
|
||||
if (status.activeDownloads.length !== initialLength) {
|
||||
fs.writeFileSync(STATUS_DATA_PATH, JSON.stringify(status, null, 2));
|
||||
console.log(`Removed active download: ${id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error removing active download:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get download status
|
||||
function getDownloadStatus() {
|
||||
if (!fs.existsSync(STATUS_DATA_PATH)) {
|
||||
updateDownloadStatus(false);
|
||||
return { isDownloading: false, title: "" };
|
||||
const initialStatus = { activeDownloads: [] };
|
||||
fs.writeFileSync(STATUS_DATA_PATH, JSON.stringify(initialStatus, null, 2));
|
||||
return initialStatus;
|
||||
}
|
||||
|
||||
const status = JSON.parse(fs.readFileSync(STATUS_DATA_PATH, "utf8"));
|
||||
try {
|
||||
const status = JSON.parse(fs.readFileSync(STATUS_DATA_PATH, "utf8"));
|
||||
|
||||
// Ensure activeDownloads exists
|
||||
if (!status.activeDownloads) {
|
||||
status.activeDownloads = [];
|
||||
}
|
||||
|
||||
// Check if the status is stale (older than 5 minutes)
|
||||
const now = Date.now();
|
||||
if (status.timestamp && now - status.timestamp > 5 * 60 * 1000) {
|
||||
console.log("Download status is stale, resetting to false");
|
||||
updateDownloadStatus(false);
|
||||
return { isDownloading: false, title: "" };
|
||||
// Check for stale downloads (older than 30 minutes)
|
||||
const now = Date.now();
|
||||
const validDownloads = status.activeDownloads.filter(d => {
|
||||
return d.timestamp && (now - d.timestamp < 30 * 60 * 1000);
|
||||
});
|
||||
|
||||
if (validDownloads.length !== status.activeDownloads.length) {
|
||||
console.log("Removed stale downloads");
|
||||
status.activeDownloads = validDownloads;
|
||||
fs.writeFileSync(STATUS_DATA_PATH, JSON.stringify(status, null, 2));
|
||||
}
|
||||
|
||||
return status;
|
||||
} catch (error) {
|
||||
console.error("Error reading download status:", error);
|
||||
return { activeDownloads: [] };
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
// Get all videos
|
||||
@@ -192,7 +233,8 @@ function deleteCollection(id) {
|
||||
|
||||
module.exports = {
|
||||
initializeStorage,
|
||||
updateDownloadStatus,
|
||||
addActiveDownload,
|
||||
removeActiveDownload,
|
||||
getDownloadStatus,
|
||||
getVideos,
|
||||
getVideoById,
|
||||
|
||||
@@ -63,72 +63,191 @@ body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
font-weight: 800;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
/* Header Form */
|
||||
.url-form {
|
||||
flex: 1;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
max-width: 800px;
|
||||
/* Increased from 600px to accommodate download indicator */
|
||||
margin-left: var(--spacing-xl);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.url-form .form-group {
|
||||
.form-group {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm);
|
||||
width: 100%;
|
||||
padding: 12px 20px;
|
||||
border-radius: 24px 0 0 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--background-light);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 0;
|
||||
/* Prevents flex item from overflowing */
|
||||
background-color: var(--background-lighter);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.url-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(255, 62, 62, 0.2);
|
||||
}
|
||||
|
||||
.url-input.downloading {
|
||||
background-color: rgba(255, 62, 62, 0.1);
|
||||
border-color: var(--primary-color);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-color);
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: 1rem;
|
||||
padding: 0 24px;
|
||||
background-color: var(--background-light);
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: none;
|
||||
border-radius: 0 24px 24px 0;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background-color: var(--primary-hover);
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background-color: var(--hover-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
background-color: var(--secondary-color);
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 8px;
|
||||
color: var(--primary-color);
|
||||
font-size: 0.875rem;
|
||||
margin-top: var(--spacing-xs);
|
||||
text-align: right;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* Downloads Indicator */
|
||||
.downloads-indicator-container {
|
||||
position: relative;
|
||||
margin-left: 10px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.downloads-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: var(--background-light);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.downloads-summary:hover {
|
||||
background-color: var(--hover-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.download-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.download-count {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.download-arrow {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.downloads-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
width: 300px;
|
||||
background-color: var(--background-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
animation: slideDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.download-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.download-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.download-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 62, 62, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--primary-color);
|
||||
animation: spin 1s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.download-title {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
|
||||
@@ -20,15 +20,15 @@ const getStoredDownloadStatus = () => {
|
||||
try {
|
||||
const savedStatus = localStorage.getItem(DOWNLOAD_STATUS_KEY);
|
||||
if (!savedStatus) return null;
|
||||
|
||||
|
||||
const parsedStatus = JSON.parse(savedStatus);
|
||||
|
||||
|
||||
// Check if the saved status is too old (stale)
|
||||
if (parsedStatus.timestamp && Date.now() - parsedStatus.timestamp > DOWNLOAD_TIMEOUT) {
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return parsedStatus;
|
||||
} catch (error) {
|
||||
console.error('Error parsing download status from localStorage:', error);
|
||||
@@ -47,7 +47,7 @@ function App() {
|
||||
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [collections, setCollections] = useState([]);
|
||||
|
||||
|
||||
// Bilibili multi-part video state
|
||||
const [showBilibiliPartsModal, setShowBilibiliPartsModal] = useState(false);
|
||||
const [bilibiliPartsInfo, setBilibiliPartsInfo] = useState({
|
||||
@@ -56,14 +56,14 @@ function App() {
|
||||
url: ''
|
||||
});
|
||||
const [isCheckingParts, setIsCheckingParts] = useState(false);
|
||||
|
||||
|
||||
// Reference to the current search request's abort controller
|
||||
const searchAbortController = useRef(null);
|
||||
|
||||
|
||||
// Get initial download status from localStorage
|
||||
const initialStatus = getStoredDownloadStatus();
|
||||
const [downloadingTitle, setDownloadingTitle] = useState(
|
||||
initialStatus ? initialStatus.title || '' : ''
|
||||
const [activeDownloads, setActiveDownloads] = useState(
|
||||
initialStatus ? initialStatus.activeDownloads || [] : []
|
||||
);
|
||||
|
||||
// Fetch collections from the server
|
||||
@@ -80,24 +80,25 @@ function App() {
|
||||
const checkBackendDownloadStatus = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/download-status`);
|
||||
|
||||
if (response.data.isDownloading) {
|
||||
// If backend is downloading, update the local status
|
||||
setDownloadingTitle(response.data.title || 'Downloading...');
|
||||
|
||||
|
||||
if (response.data.activeDownloads && response.data.activeDownloads.length > 0) {
|
||||
// If backend has active downloads, update the local status
|
||||
setActiveDownloads(response.data.activeDownloads);
|
||||
|
||||
// Save to localStorage for persistence
|
||||
const statusData = {
|
||||
title: response.data.title || 'Downloading...',
|
||||
const statusData = {
|
||||
activeDownloads: response.data.activeDownloads,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
|
||||
} else {
|
||||
// If backend says download is not in progress, clear the status
|
||||
// This ensures the header returns to normal after download completes
|
||||
if (downloadingTitle) {
|
||||
console.log('Backend says download is complete, clearing status');
|
||||
// If backend says no downloads are in progress, clear the status
|
||||
if (activeDownloads.length > 0) {
|
||||
console.log('Backend says downloads are complete, clearing status');
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
setDownloadingTitle('');
|
||||
setActiveDownloads([]);
|
||||
// Refresh videos list when downloads complete
|
||||
fetchVideos();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -109,14 +110,14 @@ function App() {
|
||||
useEffect(() => {
|
||||
// Check immediately on mount
|
||||
checkBackendDownloadStatus();
|
||||
|
||||
// Then check every 5 seconds
|
||||
const statusCheckInterval = setInterval(checkBackendDownloadStatus, 5000);
|
||||
|
||||
|
||||
// Then check every 2 seconds (faster polling for better UX)
|
||||
const statusCheckInterval = setInterval(checkBackendDownloadStatus, 2000);
|
||||
|
||||
return () => {
|
||||
clearInterval(statusCheckInterval);
|
||||
};
|
||||
}, []);
|
||||
}, [activeDownloads.length]); // Depend on length to trigger refresh when downloads finish
|
||||
|
||||
// Fetch collections on component mount
|
||||
useEffect(() => {
|
||||
@@ -131,47 +132,47 @@ function App() {
|
||||
// Set up localStorage and event listeners
|
||||
useEffect(() => {
|
||||
console.log('Setting up localStorage and event listeners');
|
||||
|
||||
|
||||
// Set up event listener for storage changes (for multi-tab support)
|
||||
const handleStorageChange = (e) => {
|
||||
if (e.key === DOWNLOAD_STATUS_KEY) {
|
||||
try {
|
||||
const newStatus = e.newValue ? JSON.parse(e.newValue) : { title: '' };
|
||||
const newStatus = e.newValue ? JSON.parse(e.newValue) : { activeDownloads: [] };
|
||||
console.log('Storage changed, new status:', newStatus);
|
||||
setDownloadingTitle(newStatus.title || '');
|
||||
setActiveDownloads(newStatus.activeDownloads || []);
|
||||
} catch (error) {
|
||||
console.error('Error handling storage change:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
|
||||
// Set up periodic check for stale download status
|
||||
const checkDownloadStatus = () => {
|
||||
const status = getStoredDownloadStatus();
|
||||
if (!status && downloadingTitle) {
|
||||
if (!status && activeDownloads.length > 0) {
|
||||
console.log('Clearing stale download status');
|
||||
setDownloadingTitle('');
|
||||
setActiveDownloads([]);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Check every minute
|
||||
const statusCheckInterval = setInterval(checkDownloadStatus, 60000);
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
clearInterval(statusCheckInterval);
|
||||
};
|
||||
}, [downloadingTitle]);
|
||||
|
||||
// Update localStorage whenever downloadingTitle changes
|
||||
}, [activeDownloads]);
|
||||
|
||||
// Update localStorage whenever activeDownloads changes
|
||||
useEffect(() => {
|
||||
console.log('Download title changed:', downloadingTitle);
|
||||
|
||||
if (downloadingTitle) {
|
||||
const statusData = {
|
||||
title: downloadingTitle,
|
||||
console.log('Active downloads changed:', activeDownloads);
|
||||
|
||||
if (activeDownloads.length > 0) {
|
||||
const statusData = {
|
||||
activeDownloads,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
console.log('Saving to localStorage:', statusData);
|
||||
@@ -180,7 +181,7 @@ function App() {
|
||||
console.log('Removing from localStorage');
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
}
|
||||
}, [downloadingTitle]);
|
||||
}, [activeDownloads]);
|
||||
|
||||
const fetchVideos = async () => {
|
||||
try {
|
||||
@@ -188,13 +189,13 @@ function App() {
|
||||
const response = await axios.get(`${API_URL}/videos`);
|
||||
setVideos(response.data);
|
||||
setError(null);
|
||||
|
||||
|
||||
// Check if we need to clear a stale download status
|
||||
if (downloadingTitle) {
|
||||
if (activeDownloads.length > 0) {
|
||||
const status = getStoredDownloadStatus();
|
||||
if (!status) {
|
||||
console.log('Clearing download status after fetching videos');
|
||||
setDownloadingTitle('');
|
||||
setActiveDownloads([]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -215,7 +216,7 @@ function App() {
|
||||
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({
|
||||
@@ -234,45 +235,39 @@ function App() {
|
||||
setIsCheckingParts(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Normal download flow
|
||||
setLoading(true);
|
||||
// Extract title from URL for display during download
|
||||
let displayTitle = videoUrl;
|
||||
if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
|
||||
displayTitle = 'YouTube video';
|
||||
} else if (videoUrl.includes('bilibili.com') || videoUrl.includes('b23.tv')) {
|
||||
displayTitle = 'Bilibili video';
|
||||
}
|
||||
|
||||
// Set download status before making the API call
|
||||
setDownloadingTitle(displayTitle);
|
||||
|
||||
|
||||
// We don't set activeDownloads here immediately because the backend will queue it
|
||||
// and we'll pick it up via polling
|
||||
|
||||
const response = await axios.post(`${API_URL}/download`, { youtubeUrl: videoUrl });
|
||||
setVideos(prevVideos => [response.data.video, ...prevVideos]);
|
||||
|
||||
// If the response contains a downloadId, it means it was queued/started
|
||||
if (response.data.downloadId) {
|
||||
// Trigger an immediate status check
|
||||
checkBackendDownloadStatus();
|
||||
} else if (response.data.video) {
|
||||
// If it returned a video immediately (shouldn't happen with new logic but safe to keep)
|
||||
setVideos(prevVideos => [response.data.video, ...prevVideos]);
|
||||
}
|
||||
|
||||
setIsSearchMode(false);
|
||||
|
||||
// Explicitly clear download status after successful download
|
||||
setDownloadingTitle('');
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error downloading video:', err);
|
||||
|
||||
// Always clear download status on error
|
||||
setDownloadingTitle('');
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
|
||||
|
||||
// Check if the error is because the input is a search term
|
||||
if (err.response?.data?.isSearchTerm) {
|
||||
// Handle as search term
|
||||
return await handleSearch(err.response.data.searchTerm);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.response?.data?.error || 'Failed to download video. Please try again.'
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.response?.data?.error || 'Failed to download video. Please try again.'
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -281,11 +276,11 @@ function App() {
|
||||
|
||||
const searchLocalVideos = (query) => {
|
||||
if (!query || !videos.length) return [];
|
||||
|
||||
|
||||
const searchTermLower = query.toLowerCase();
|
||||
|
||||
return videos.filter(video =>
|
||||
video.title.toLowerCase().includes(searchTermLower) ||
|
||||
|
||||
return videos.filter(video =>
|
||||
video.title.toLowerCase().includes(searchTermLower) ||
|
||||
video.author.toLowerCase().includes(searchTermLower)
|
||||
);
|
||||
};
|
||||
@@ -296,35 +291,35 @@ function App() {
|
||||
resetSearch();
|
||||
return { success: false, error: 'Please enter a search term' };
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Cancel any previous search request
|
||||
if (searchAbortController.current) {
|
||||
searchAbortController.current.abort();
|
||||
}
|
||||
|
||||
|
||||
// Create a new abort controller for this request
|
||||
searchAbortController.current = new AbortController();
|
||||
const signal = searchAbortController.current.signal;
|
||||
|
||||
|
||||
// Set search mode and term immediately
|
||||
setIsSearchMode(true);
|
||||
setSearchTerm(query);
|
||||
|
||||
|
||||
// Search local videos first (synchronously)
|
||||
const localResults = searchLocalVideos(query);
|
||||
setLocalSearchResults(localResults);
|
||||
|
||||
|
||||
// Set loading state only for YouTube results
|
||||
setYoutubeLoading(true);
|
||||
|
||||
|
||||
// Then search YouTube asynchronously
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/search`, {
|
||||
const response = await axios.get(`${API_URL}/search`, {
|
||||
params: { query },
|
||||
signal: signal // Pass the abort signal to axios
|
||||
});
|
||||
|
||||
|
||||
// Only update results if the request wasn't aborted
|
||||
if (!signal.aborted) {
|
||||
setSearchResults(response.data.results);
|
||||
@@ -341,13 +336,13 @@ function App() {
|
||||
setYoutubeLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Don't handle if it's an abort error
|
||||
if (err.name !== 'CanceledError' && err.name !== 'AbortError') {
|
||||
console.error('Error in search process:', err);
|
||||
|
||||
|
||||
// Even if there's an error in the overall process,
|
||||
// we still want to show local results if available
|
||||
const localResults = searchLocalVideos(query);
|
||||
@@ -357,7 +352,7 @@ function App() {
|
||||
setSearchTerm(query);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to search. Please try again.'
|
||||
@@ -376,16 +371,16 @@ function App() {
|
||||
const handleDeleteVideo = async (id) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
// First, remove the video from any collections
|
||||
await handleRemoveFromCollection(id);
|
||||
|
||||
|
||||
// Then delete the video
|
||||
await axios.delete(`${API_URL}/videos/${id}`);
|
||||
|
||||
|
||||
// Update the videos state
|
||||
setVideos(prevVideos => prevVideos.filter(video => video.id !== id));
|
||||
|
||||
|
||||
setLoading(false);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -403,39 +398,25 @@ function App() {
|
||||
searchAbortController.current.abort();
|
||||
searchAbortController.current = null;
|
||||
}
|
||||
|
||||
|
||||
setIsSearchMode(false);
|
||||
// If title is provided, use it for the downloading message
|
||||
if (title) {
|
||||
setDownloadingTitle(title);
|
||||
}
|
||||
|
||||
|
||||
const result = await handleVideoSubmit(videoUrl);
|
||||
|
||||
// Ensure download status is cleared after download completes
|
||||
if (!result.success) {
|
||||
setDownloadingTitle('');
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error in handleDownloadFromSearch:', error);
|
||||
// Always clear download status on error
|
||||
setDownloadingTitle('');
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
return { success: false, error: 'Failed to download video' };
|
||||
}
|
||||
};
|
||||
|
||||
// For debugging
|
||||
useEffect(() => {
|
||||
console.log('Current download status:', {
|
||||
downloadingTitle,
|
||||
isDownloading: !!downloadingTitle,
|
||||
console.log('Current download status:', {
|
||||
activeDownloads,
|
||||
count: activeDownloads.length,
|
||||
localStorage: localStorage.getItem(DOWNLOAD_STATUS_KEY)
|
||||
});
|
||||
}, [downloadingTitle]);
|
||||
}, [activeDownloads]);
|
||||
|
||||
// Cleanup effect to abort any pending search requests when unmounting
|
||||
useEffect(() => {
|
||||
@@ -455,7 +436,7 @@ function App() {
|
||||
searchAbortController.current.abort();
|
||||
searchAbortController.current = null;
|
||||
}
|
||||
|
||||
|
||||
// Reset search-related state
|
||||
setIsSearchMode(false);
|
||||
setSearchTerm('');
|
||||
@@ -471,10 +452,10 @@ function App() {
|
||||
name,
|
||||
videoId
|
||||
});
|
||||
|
||||
|
||||
// Update the collections state with the new collection from the server
|
||||
setCollections(prevCollections => [...prevCollections, response.data]);
|
||||
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error creating collection:', error);
|
||||
@@ -487,18 +468,18 @@ function App() {
|
||||
try {
|
||||
// If videoId is provided, remove it from any other collections first
|
||||
// This is handled on the server side now
|
||||
|
||||
|
||||
// Add the video to the selected collection
|
||||
const response = await axios.put(`${API_URL}/collections/${collectionId}`, {
|
||||
videoId,
|
||||
action: "add"
|
||||
});
|
||||
|
||||
|
||||
// Update the collections state with the updated collection from the server
|
||||
setCollections(prevCollections => prevCollections.map(collection =>
|
||||
collection.id === collectionId ? response.data : collection
|
||||
));
|
||||
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error adding video to collection:', error);
|
||||
@@ -510,10 +491,10 @@ function App() {
|
||||
const handleRemoveFromCollection = async (videoId) => {
|
||||
try {
|
||||
// Get all collections
|
||||
const collectionsWithVideo = collections.filter(collection =>
|
||||
const collectionsWithVideo = collections.filter(collection =>
|
||||
collection.videos.includes(videoId)
|
||||
);
|
||||
|
||||
|
||||
// For each collection that contains the video, remove it
|
||||
for (const collection of collectionsWithVideo) {
|
||||
await axios.put(`${API_URL}/collections/${collection.id}`, {
|
||||
@@ -521,13 +502,13 @@ function App() {
|
||||
action: "remove"
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Update the collections state
|
||||
setCollections(prevCollections => prevCollections.map(collection => ({
|
||||
...collection,
|
||||
videos: collection.videos.filter(v => v !== videoId)
|
||||
})));
|
||||
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error removing video from collection:', error);
|
||||
@@ -540,26 +521,26 @@ function App() {
|
||||
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
|
||||
setCollections(prevCollections =>
|
||||
setCollections(prevCollections =>
|
||||
prevCollections.filter(collection => collection.id !== collectionId)
|
||||
);
|
||||
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting collection:', error);
|
||||
@@ -572,46 +553,36 @@ function App() {
|
||||
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`, {
|
||||
|
||||
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]);
|
||||
|
||||
|
||||
// Trigger immediate status check
|
||||
checkBackendDownloadStatus();
|
||||
|
||||
// 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.'
|
||||
|
||||
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);
|
||||
@@ -621,16 +592,15 @@ function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="app">
|
||||
<Header
|
||||
onSearch={handleSearch}
|
||||
<Header
|
||||
onSearch={handleSearch}
|
||||
onSubmit={handleVideoSubmit}
|
||||
isDownloading={!!downloadingTitle}
|
||||
downloadingTitle={downloadingTitle}
|
||||
activeDownloads={activeDownloads}
|
||||
isSearchMode={isSearchMode}
|
||||
searchTerm={searchTerm}
|
||||
onResetSearch={resetSearch}
|
||||
/>
|
||||
|
||||
|
||||
{/* Bilibili Parts Modal */}
|
||||
<BilibiliPartsModal
|
||||
isOpen={showBilibiliPartsModal}
|
||||
@@ -641,15 +611,15 @@ function App() {
|
||||
onDownloadCurrent={handleDownloadCurrentBilibiliPart}
|
||||
isLoading={loading || isCheckingParts}
|
||||
/>
|
||||
|
||||
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
videos={videos}
|
||||
loading={loading}
|
||||
<Home
|
||||
videos={videos}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
@@ -660,46 +630,46 @@ function App() {
|
||||
searchResults={searchResults}
|
||||
onDownload={handleDownloadFromSearch}
|
||||
/>
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/video/:id"
|
||||
<Route
|
||||
path="/video/:id"
|
||||
element={
|
||||
<VideoPlayer
|
||||
videos={videos}
|
||||
<VideoPlayer
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
onAddToCollection={handleAddToCollection}
|
||||
onCreateCollection={handleCreateCollection}
|
||||
onRemoveFromCollection={handleRemoveFromCollection}
|
||||
/>
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/author/:author"
|
||||
<Route
|
||||
path="/author/:author"
|
||||
element={
|
||||
<AuthorVideos
|
||||
<AuthorVideos
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
/>
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collection/:id"
|
||||
<Route
|
||||
path="/collection/:id"
|
||||
element={
|
||||
<CollectionPage
|
||||
<CollectionPage
|
||||
collections={collections}
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
onDeleteCollection={handleDeleteCollection}
|
||||
/>
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/search"
|
||||
<Route
|
||||
path="/search"
|
||||
element={
|
||||
<SearchResults
|
||||
<SearchResults
|
||||
results={searchResults}
|
||||
localResults={localSearchResults}
|
||||
loading={youtubeLoading}
|
||||
@@ -709,7 +679,7 @@ function App() {
|
||||
onResetSearch={resetSearch}
|
||||
collections={collections}
|
||||
/>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
const Header = ({ onSubmit, onSearch, downloadingTitle, isDownloading }) => {
|
||||
const Header = ({ onSubmit, onSearch, activeDownloads = [] }) => {
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showDownloads, setShowDownloads] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
const isDownloading = activeDownloads.length > 0;
|
||||
|
||||
// Log props for debugging
|
||||
useEffect(() => {
|
||||
console.log('Header props:', { downloadingTitle, isDownloading });
|
||||
}, [downloadingTitle, isDownloading]);
|
||||
console.log('Header props:', { activeDownloads });
|
||||
}, [activeDownloads]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!videoUrl.trim()) {
|
||||
setError('Please enter a video URL or search term');
|
||||
return;
|
||||
@@ -23,10 +26,10 @@ const Header = ({ onSubmit, onSearch, downloadingTitle, isDownloading }) => {
|
||||
// Simple validation for YouTube or Bilibili URL
|
||||
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
|
||||
const bilibiliRegex = /^(https?:\/\/)?(www\.)?(bilibili\.com|b23\.tv)\/.+$/;
|
||||
|
||||
|
||||
// Check if input is a URL
|
||||
const isUrl = youtubeRegex.test(videoUrl) || bilibiliRegex.test(videoUrl);
|
||||
|
||||
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
@@ -34,7 +37,7 @@ const Header = ({ onSubmit, onSearch, downloadingTitle, isDownloading }) => {
|
||||
if (isUrl) {
|
||||
// Handle as URL for download
|
||||
const result = await onSubmit(videoUrl);
|
||||
|
||||
|
||||
if (result.success) {
|
||||
setVideoUrl('');
|
||||
} else if (result.isSearchTerm) {
|
||||
@@ -52,7 +55,7 @@ const Header = ({ onSubmit, onSearch, downloadingTitle, isDownloading }) => {
|
||||
} else {
|
||||
// Handle as search term
|
||||
const result = await onSearch(videoUrl);
|
||||
|
||||
|
||||
if (result.success) {
|
||||
setVideoUrl('');
|
||||
// Stay on homepage to show search results
|
||||
@@ -69,14 +72,6 @@ const Header = ({ onSubmit, onSearch, downloadingTitle, isDownloading }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Determine the input placeholder text based on download status
|
||||
const getPlaceholderText = () => {
|
||||
if (isDownloading && downloadingTitle) {
|
||||
return `Downloading: ${downloadingTitle}...`;
|
||||
}
|
||||
return "Enter YouTube/Bilibili URL or search term";
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-content">
|
||||
@@ -84,24 +79,24 @@ const Header = ({ onSubmit, onSearch, downloadingTitle, isDownloading }) => {
|
||||
<span style={{ color: '#ff3e3e' }}>My</span>
|
||||
<span style={{ color: '#f0f0f0' }}>Tube</span>
|
||||
</Link>
|
||||
|
||||
|
||||
<form className="url-form" onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="text"
|
||||
className={`url-input ${isDownloading ? 'downloading' : ''}`}
|
||||
placeholder={getPlaceholderText()}
|
||||
value={isDownloading ? '' : videoUrl}
|
||||
className="url-input"
|
||||
placeholder="Enter YouTube/Bilibili URL or search term"
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
disabled={isSubmitting || isDownloading}
|
||||
disabled={isSubmitting}
|
||||
aria-label="Video URL or search term"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
<button
|
||||
type="submit"
|
||||
className="submit-btn"
|
||||
disabled={isSubmitting || isDownloading}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Processing...' : isDownloading ? 'Downloading...' : 'Submit'}
|
||||
{isSubmitting ? 'Processing...' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
@@ -109,9 +104,33 @@ const Header = ({ onSubmit, onSearch, downloadingTitle, isDownloading }) => {
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Downloads Indicator */}
|
||||
{isDownloading && (
|
||||
<div className="download-status">
|
||||
Downloading: {downloadingTitle}...
|
||||
<div className="downloads-indicator-container">
|
||||
<div
|
||||
className="downloads-summary"
|
||||
onClick={() => setShowDownloads(!showDownloads)}
|
||||
>
|
||||
<span className="download-icon">⬇️</span>
|
||||
<span className="download-count">
|
||||
{activeDownloads.length} Downloading
|
||||
</span>
|
||||
<span className="download-arrow">{showDownloads ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
|
||||
{showDownloads && (
|
||||
<div className="downloads-dropdown">
|
||||
{activeDownloads.map((download) => (
|
||||
<div key={download.id} className="download-item">
|
||||
<div className="download-spinner"></div>
|
||||
<div className="download-title" title={download.title}>
|
||||
{download.title}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
@@ -120,4 +139,4 @@ const Header = ({ onSubmit, onSearch, downloadingTitle, isDownloading }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
export default Header;
|
||||
|
||||
Reference in New Issue
Block a user