feat: Initialize status.json for tracking download status

This commit is contained in:
Peifan Li
2025-03-12 22:16:27 -04:00
parent 8f6d9a6e9a
commit 6e953d073d
4 changed files with 658 additions and 84 deletions

5
backend/data/status.json Normal file
View File

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

View File

@@ -35,6 +35,31 @@ fs.ensureDirSync(dataDir);
// Define path for videos.json in the data directory for persistence
const videosDataPath = path.join(dataDir, "videos.json");
// Define path for status.json to track download status
const statusDataPath = path.join(dataDir, "status.json");
// Initialize status.json if it doesn't exist
if (!fs.existsSync(statusDataPath)) {
fs.writeFileSync(
statusDataPath,
JSON.stringify({ isDownloading: false, title: "" }, null, 2)
);
}
// Helper function to update download status
function updateDownloadStatus(isDownloading, title = "") {
try {
fs.writeFileSync(
statusDataPath,
JSON.stringify({ isDownloading, title, timestamp: Date.now() }, null, 2)
);
console.log(
`Download status updated: isDownloading=${isDownloading}, title=${title}`
);
} catch (error) {
console.error("Error updating download status:", error);
}
}
// Serve static files from the uploads directory
app.use("/videos", express.static(videosDir));
@@ -361,6 +386,18 @@ app.post("/api/download", async (req, res) => {
});
}
// Set download status to true with initial title
let initialTitle = "Downloading video...";
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be")) {
initialTitle = "Downloading YouTube video...";
} else if (
videoUrl.includes("bilibili.com") ||
videoUrl.includes("b23.tv")
) {
initialTitle = "Downloading Bilibili video...";
}
updateDownloadStatus(true, initialTitle);
// Resolve shortened URLs (like b23.tv)
if (videoUrl.includes("b23.tv")) {
videoUrl = await resolveShortUrl(videoUrl);
@@ -392,6 +429,7 @@ app.post("/api/download", async (req, res) => {
// Check if it's a Bilibili URL
if (isBilibiliUrl(videoUrl)) {
console.log("Detected Bilibili URL");
updateDownloadStatus(true, "Downloading Bilibili video...");
try {
// Download Bilibili video
@@ -415,6 +453,9 @@ app.post("/api/download", async (req, res) => {
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
@@ -441,6 +482,8 @@ app.post("/api/download", async (req, res) => {
}
} 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,
@@ -448,6 +491,7 @@ app.post("/api/download", async (req, res) => {
}
} else {
console.log("Detected YouTube URL");
updateDownloadStatus(true, "Downloading YouTube video...");
try {
// Get YouTube video info first
@@ -472,6 +516,9 @@ app.post("/api/download", async (req, res) => {
new Date().toISOString().slice(0, 10).replace(/-/g, "");
thumbnailUrl = info.thumbnail;
// Update download status with actual title
updateDownloadStatus(true, `Downloading: ${videoTitle}`);
// Update the safe base filename with the actual title
const newSafeBaseFilename = `${sanitizeFilename(
videoTitle
@@ -530,6 +577,8 @@ app.post("/api/download", async (req, res) => {
}
} catch (youtubeError) {
console.error("Error in YouTube download process:", youtubeError);
// Set download status to false on error
updateDownloadStatus(false);
return res.status(500).json({
error: "Failed to download YouTube video",
details: youtubeError.message,
@@ -572,9 +621,14 @@ app.post("/api/download", async (req, res) => {
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
updateDownloadStatus(false);
res
.status(500)
.json({ error: "Failed to download video", details: error.message });
@@ -838,6 +892,31 @@ app.delete("/api/collections/:id", (req, res) => {
}
});
// API endpoint to get download status
app.get("/api/download-status", (req, res) => {
try {
if (!fs.existsSync(statusDataPath)) {
updateDownloadStatus(false);
return res.status(200).json({ isDownloading: false, title: "" });
}
const status = JSON.parse(fs.readFileSync(statusDataPath, "utf8"));
// 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 res.status(200).json({ isDownloading: false, title: "" });
}
res.status(200).json(status);
} catch (error) {
console.error("Error fetching download status:", error);
res.status(500).json({ error: "Failed to fetch download status" });
}
});
// Start the server
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);

View File

@@ -66,6 +66,48 @@ function App() {
}
};
// Add a function to check download status from the backend
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...');
// Save to localStorage for persistence
const statusData = {
title: response.data.title || 'Downloading...',
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');
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
setDownloadingTitle('');
}
}
} catch (error) {
console.error('Error checking backend download status:', error);
}
};
// Check backend download status periodically
useEffect(() => {
// Check immediately on mount
checkBackendDownloadStatus();
// Then check every 5 seconds
const statusCheckInterval = setInterval(checkBackendDownloadStatus, 5000);
return () => {
clearInterval(statusCheckInterval);
};
}, []);
// Fetch collections on component mount
useEffect(() => {
fetchCollections();
@@ -74,30 +116,8 @@ function App() {
// Fetch videos on component mount
useEffect(() => {
fetchVideos();
// Also check for stale download status
if (downloadingTitle) {
const checkDownloadStatus = async () => {
try {
// Make a simple API call to check if the server is still processing the download
await axios.get(`${API_URL}/videos`);
// If we've been downloading for more than 3 minutes, assume it's done or failed
const status = getStoredDownloadStatus();
if (status && status.timestamp && Date.now() - status.timestamp > 3 * 60 * 1000) {
console.log('Download has been running too long, clearing status');
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
setDownloadingTitle('');
}
} catch (error) {
console.error('Error checking download status:', error);
}
};
checkDownloadStatus();
}
}, [downloadingTitle]);
}, []);
// Set up localStorage and event listeners
useEffect(() => {
console.log('Setting up localStorage and event listeners');
@@ -192,10 +212,19 @@ function App() {
const response = await axios.post(`${API_URL}/download`, { youtubeUrl: videoUrl });
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
@@ -208,7 +237,6 @@ function App() {
};
} finally {
setLoading(false);
setDownloadingTitle('');
}
};
@@ -329,18 +357,35 @@ function App() {
};
const handleDownloadFromSearch = async (videoUrl, title) => {
// Abort any ongoing search request
if (searchAbortController.current) {
searchAbortController.current.abort();
searchAbortController.current = null;
try {
// Abort any ongoing search request
if (searchAbortController.current) {
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' };
}
setIsSearchMode(false);
// If title is provided, use it for the downloading message
if (title) {
setDownloadingTitle(title);
}
return await handleVideoSubmit(videoUrl);
};
// For debugging
@@ -404,43 +449,41 @@ 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}`, {
videoId,
action: 'add'
});
const response = await axios.put(`${API_URL}/collections/${collectionId}/videos/${videoId}`);
// Update the collections state with the updated collection
setCollections(prevCollections =>
prevCollections.map(collection =>
collection.id === collectionId ? response.data : collection
)
);
// Update the collections state with the new video
setCollections(prevCollections => prevCollections.map(collection =>
collection.id === collectionId ? { ...collection, videos: [...collection.videos, response.data] } : collection
));
return true;
return response.data;
} catch (error) {
console.error('Error adding video to collection:', error);
return false;
return null;
}
};
// Remove a video from its collection
// Remove a video from a collection
const handleRemoveFromCollection = async (videoId) => {
try {
// Find all collections that contain this video
const collectionsWithVideo = collections.filter(collection =>
// Get all collections
const collectionsWithVideo = collections.filter(collection =>
collection.videos.includes(videoId)
);
// Remove the video from each collection
// For each collection that contains the video, remove it
for (const collection of collectionsWithVideo) {
await axios.put(`${API_URL}/collections/${collection.id}`, {
videoId,
action: 'remove'
action: "remove"
});
}
// Refresh collections from the server
fetchCollections();
// Update the collections state
setCollections(prevCollections => prevCollections.map(collection => ({
...collection,
videos: collection.videos.filter(v => v !== videoId)
})));
return true;
} catch (error) {
@@ -450,41 +493,19 @@ function App() {
};
// Delete a collection
const handleDeleteCollection = async (collectionId, deleteVideos = false) => {
const handleDeleteCollection = async (collectionId) => {
try {
// Get the collection to be deleted
const collectionToDelete = collections.find(c => c.id === collectionId);
if (!collectionToDelete) {
console.error('Collection not found');
return false;
}
// If deleteVideos is true, delete all videos in the collection
if (deleteVideos && collectionToDelete.videos.length > 0) {
// Delete each video in the collection
for (const videoId of collectionToDelete.videos) {
try {
await axios.delete(`${API_URL}/videos/${videoId}`);
// Update the videos state
setVideos(prevVideos => prevVideos.filter(video => video.id !== videoId));
} catch (videoError) {
console.error(`Error deleting video ${videoId}:`, videoError);
// Continue with other videos even if one fails
}
}
}
// Delete the collection
await axios.delete(`${API_URL}/collections/${collectionId}`);
// Refresh collections from the server
fetchCollections();
// Update the collections state
setCollections(prevCollections =>
prevCollections.filter(collection => collection.id !== collectionId)
);
return true;
return { success: true };
} catch (error) {
console.error('Error deleting collection:', error);
return false;
return { success: false, error: 'Failed to delete collection' };
}
};

469
frontend/src/App.jsx.bak Normal file
View File

@@ -0,0 +1,469 @@
import axios from 'axios';
import { useEffect, useRef, useState } from 'react';
import { Route, BrowserRouter as Router, Routes } from 'react-router-dom';
import './App.css';
import AuthorVideos from './pages/AuthorVideos';
import CollectionPage from './pages/CollectionPage';
import Home from './pages/Home';
import SearchResults from './pages/SearchResults';
import VideoPlayer from './pages/VideoPlayer';
const API_URL = import.meta.env.VITE_API_URL;
const DOWNLOAD_STATUS_KEY = 'mytube_download_status';
const DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds
const COLLECTIONS_KEY = 'mytube_collections';
// Helper function to get download status from localStorage
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);
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
return null;
}
};
function App() {
const [videos, setVideos] = useState([]);
const [searchResults, setSearchResults] = useState([]);
const [localSearchResults, setLocalSearchResults] = useState([]);
const [loading, setLoading] = useState(true);
const [youtubeLoading, setYoutubeLoading] = useState(false);
const [error, setError] = useState(null);
const [isSearchMode, setIsSearchMode] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [collections, setCollections] = useState([]);
// 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 || '' : ''
);
// Fetch collections from the server
const fetchCollections = async () => {
try {
const response = await axios.get(`${API_URL}/collections`);
setCollections(response.data);
} catch (error) {
console.error('Error fetching collections:', error);
}
};
// Add a function to check download status from the backend
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...');
// Save to localStorage for persistence
const statusData = {
title: response.data.title || 'Downloading...',
timestamp: Date.now()
};
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
} else if (downloadingTitle && !response.data.isDownloading) {
// If we think we're downloading but backend says no, clear the status
console.log('Backend says download is complete, clearing status');
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
setDownloadingTitle('');
}
} catch (error) {
console.error('Error checking backend download status:', error);
}
};
// Check backend download status periodically
useEffect(() => {
// Check immediately on mount
checkBackendDownloadStatus();
// Then check every 5 seconds
const statusCheckInterval = setInterval(checkBackendDownloadStatus, 5000);
return () => {
clearInterval(statusCheckInterval);
};
}, []);
// Fetch collections on component mount
useEffect(() => {
fetchCollections();
}, []);
// Fetch videos on component mount
useEffect(() => {
fetchVideos();
}, []);
// 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: '' };
console.log('Storage changed, new status:', newStatus);
setDownloadingTitle(newStatus.title || '');
} 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) {
console.log('Clearing stale download status');
setDownloadingTitle('');
}
};
// Check every minute
const statusCheckInterval = setInterval(checkDownloadStatus, 60000);
return () => {
window.removeEventListener('storage', handleStorageChange);
clearInterval(statusCheckInterval);
};
}, [downloadingTitle]);
// Update localStorage whenever downloadingTitle changes
useEffect(() => {
console.log('Download title changed:', downloadingTitle);
if (downloadingTitle) {
const statusData = {
title: downloadingTitle,
timestamp: Date.now()
};
console.log('Saving to localStorage:', statusData);
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
} else {
console.log('Removing from localStorage');
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
}
}, [downloadingTitle]);
const fetchVideos = async () => {
try {
setLoading(true);
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) {
const status = getStoredDownloadStatus();
if (!status) {
console.log('Clearing download status after fetching videos');
setDownloadingTitle('');
}
}
} catch (err) {
console.error('Error fetching videos:', err);
setError('Failed to load videos. Please try again later.');
} finally {
setLoading(false);
}
};
const handleVideoSubmit = async (videoUrl) => {
try {
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);
const response = await axios.post(`${API_URL}/download`, { youtubeUrl: videoUrl });
setVideos(prevVideos => [response.data.video, ...prevVideos]);
setIsSearchMode(false);
return { success: true };
} catch (err) {
console.error('Error downloading video:', err);
// 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.'
};
} finally {
setLoading(false);
setDownloadingTitle('');
}
};
const searchLocalVideos = (query) => {
if (!query || !videos.length) return [];
const searchTermLower = query.toLowerCase();
return videos.filter(video =>
video.title.toLowerCase().includes(searchTermLower) ||
video.author.toLowerCase().includes(searchTermLower)
);
};
const handleSearch = async (query) => {
// Don't enter search mode if the query is empty
if (!query || query.trim() === '') {
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`, {
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);
}
} catch (youtubeErr) {
// Don't handle if it's an abort error
if (youtubeErr.name !== 'CanceledError' && youtubeErr.name !== 'AbortError') {
console.error('Error searching YouTube:', youtubeErr);
}
// Don't set overall error if only YouTube search fails
} finally {
// Only update loading state if the request wasn't aborted
if (!signal.aborted) {
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);
if (localResults.length > 0) {
setLocalSearchResults(localResults);
setIsSearchMode(true);
setSearchTerm(query);
return { success: true };
}
return {
success: false,
error: 'Failed to search. Please try again.'
};
}
} finally {
// Only update loading state if the request wasn't aborted
if (searchAbortController.current && !searchAbortController.current.signal.aborted) {
setLoading(false);
}
}
};
// Delete a video
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) {
console.error('Error deleting video:', error);
setError('Failed to delete video');
setLoading(false);
return { success: false, error: 'Failed to delete video' };
}
};
const handleDownloadFromSearch = async (videoUrl, title) => {
// Abort any ongoing search request
if (searchAbortController.current) {
searchAbortController.current.abort();
searchAbortController.current = null;
}
setIsSearchMode(false);
// If title is provided, use it for the downloading message
if (title) {
setDownloadingTitle(title);
}
return await handleVideoSubmit(videoUrl);
};
// For debugging
useEffect(() => {
console.log('Current download status:', {
downloadingTitle,
isDownloading: !!downloadingTitle,
localStorage: localStorage.getItem(DOWNLOAD_STATUS_KEY)
});
}, [downloadingTitle]);
// Cleanup effect to abort any pending search requests when unmounting
useEffect(() => {
return () => {
// Abort any ongoing search request when component unmounts
if (searchAbortController.current) {
searchAbortController.current.abort();
searchAbortController.current = null;
}
};
}, []);
// Update the resetSearch function to abort any ongoing search
const resetSearch = () => {
// Abort any ongoing search request
if (searchAbortController.current) {
searchAbortController.current.abort();
searchAbortController.current = null;
}
// Reset search-related state
setIsSearchMode(false);
setSearchTerm('');
setSearchResults([]);
setLocalSearchResults([]);
setYoutubeLoading(false);
};
// Create a new collection
const handleCreateCollection = async (name, videoId = null) => {
try {
const response = await axios.post(`${API_URL}/collections`, {
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);
return null;
}
};
// Add a video to a collection
const handleAddToCollection = async (collectionId, videoId) => {
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}/videos/${videoId}`);
// Update the collections state with the new video
setCollections(prevCollections => prevCollections.map(collection =>
collection.id === collectionId ? { ...collection, videos: [...collection.videos, response.data] } : collection
));
return response.data;
} catch (error) {
console.error('Error adding video to collection:', error);
return null;
}
};
// Remove a video from a collection
const handleRemoveFromCollection = async (videoId) => {
try {
// Remove the video from all collections
await axios.delete(`${API_URL}/collections/videos/${videoId}`);
// Update the collections state
setCollections(prevCollections => prevCollections.map(collection => ({
...collection,
videos: collection.videos.filter(v => v.id !== videoId)
})));
return true;
} catch (error) {
console.error('Error removing video from collection:', error);
return false;
}
};
return (
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/author-videos" element={<AuthorVideos />} />
<Route path="/collection-page" element={<CollectionPage />} />
<Route path="/search-results" element={<SearchResults />} />
<Route path="/video-player" element={<VideoPlayer />} />
</Routes>
</Router>
);
}
export default App;