Files
MyTube/backend/server.js
2025-03-09 18:37:46 -04:00

674 lines
21 KiB
JavaScript

// Load environment variables from .env file
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const multer = require("multer");
const path = require("path");
const fs = require("fs-extra");
const youtubedl = require("youtube-dl-exec");
const axios = require("axios");
const { downloadByVedioPath } = require("bilibili-save-nodejs");
const app = express();
const PORT = process.env.PORT || 5551;
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Create uploads directory and subdirectories if they don't exist
const uploadsDir = path.join(__dirname, "uploads");
const videosDir = path.join(uploadsDir, "videos");
const imagesDir = path.join(uploadsDir, "images");
fs.ensureDirSync(uploadsDir);
fs.ensureDirSync(videosDir);
fs.ensureDirSync(imagesDir);
// Serve static files from the uploads directory
app.use("/videos", express.static(videosDir));
app.use("/images", express.static(imagesDir));
// Helper function to check if a string is a valid URL
function isValidUrl(string) {
try {
new URL(string);
return true;
} catch (_) {
return false;
}
}
// Helper function to check if a URL is from Bilibili
function isBilibiliUrl(url) {
return url.includes("bilibili.com");
}
// Helper function to trim Bilibili URL by removing query parameters
function trimBilibiliUrl(url) {
try {
// Extract the base URL and video ID - support both desktop and mobile URLs
const regex =
/(https?:\/\/(?:www\.|m\.)?bilibili\.com\/video\/(?:BV[\w]+|av\d+))/i;
const match = url.match(regex);
if (match && match[1]) {
console.log(`Trimmed Bilibili URL from "${url}" to "${match[1]}"`);
return match[1];
}
// If regex doesn't match, just remove query parameters
const urlObj = new URL(url);
const cleanUrl = `${urlObj.origin}${urlObj.pathname}`;
console.log(`Trimmed Bilibili URL from "${url}" to "${cleanUrl}"`);
return cleanUrl;
} catch (error) {
console.error("Error trimming Bilibili URL:", error);
return url; // Return original URL if there's an error
}
}
// Helper function to extract video ID from Bilibili URL
function extractBilibiliVideoId(url) {
// Extract BV ID from URL
const bvMatch = url.match(/BV\w+/);
if (bvMatch) {
return bvMatch[0];
}
// Extract av ID from URL
const avMatch = url.match(/av(\d+)/);
if (avMatch) {
return `av${avMatch[1]}`;
}
return null;
}
// Helper function to create a safe filename that preserves non-Latin characters
function sanitizeFilename(filename) {
// Replace only unsafe characters for filesystems
// This preserves non-Latin characters like Chinese, Japanese, Korean, etc.
return filename
.replace(/[\/\\:*?"<>|]/g, "_") // Replace unsafe filesystem characters
.replace(/\s+/g, "_"); // Replace spaces with underscores
}
// Helper function to download Bilibili video
async function downloadBilibiliVideo(url, videoPath, thumbnailPath) {
try {
// Create a temporary directory for the download
const tempDir = path.join(videosDir, "temp");
fs.ensureDirSync(tempDir);
console.log("Downloading Bilibili video to temp directory:", tempDir);
// Download the video using the package
await downloadByVedioPath({
url: url,
type: "mp4",
folder: tempDir,
});
console.log("Download completed, checking for video file");
// Find the downloaded file
const files = fs.readdirSync(tempDir);
console.log("Files in temp directory:", files);
const videoFile = files.find((file) => file.endsWith(".mp4"));
if (!videoFile) {
throw new Error("Downloaded video file not found");
}
console.log("Found video file:", videoFile);
// Move the file to the desired location
const tempVideoPath = path.join(tempDir, videoFile);
fs.moveSync(tempVideoPath, videoPath, { overwrite: true });
console.log("Moved video file to:", videoPath);
// Clean up temp directory
fs.removeSync(tempDir);
// Extract video title from filename (remove extension)
const videoTitle = videoFile.replace(".mp4", "") || "Bilibili Video";
// Try to get thumbnail from Bilibili
let thumbnailSaved = false;
let thumbnailUrl = null;
const videoId = extractBilibiliVideoId(url);
console.log("Extracted video ID:", videoId);
if (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:", apiUrl);
const response = await axios.get(apiUrl);
if (response.data && response.data.data) {
const videoInfo = response.data.data;
thumbnailUrl = videoInfo.pic;
console.log("Got video info from API:", {
title: videoInfo.title,
author: videoInfo.owner?.name,
thumbnailUrl: thumbnailUrl,
});
if (thumbnailUrl) {
// Download thumbnail
console.log("Downloading thumbnail from:", thumbnailUrl);
const thumbnailResponse = await axios({
method: "GET",
url: thumbnailUrl,
responseType: "stream",
});
const thumbnailWriter = fs.createWriteStream(thumbnailPath);
thumbnailResponse.data.pipe(thumbnailWriter);
await new Promise((resolve, reject) => {
thumbnailWriter.on("finish", () => {
thumbnailSaved = true;
resolve();
});
thumbnailWriter.on("error", reject);
});
console.log("Thumbnail saved to:", thumbnailPath);
return {
title: videoInfo.title || videoTitle,
author: videoInfo.owner?.name || "Bilibili User",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: thumbnailUrl,
thumbnailSaved,
};
}
}
} catch (thumbnailError) {
console.error("Error downloading Bilibili thumbnail:", thumbnailError);
}
}
console.log("Using basic video info");
// Return basic info if we couldn't get detailed info
return {
title: videoTitle,
author: "Bilibili User",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: null,
thumbnailSaved: false,
};
} catch (error) {
console.error("Error in downloadBilibiliVideo:", error);
// Make sure we clean up the temp directory if it exists
const tempDir = path.join(videosDir, "temp");
if (fs.existsSync(tempDir)) {
fs.removeSync(tempDir);
}
// Return a default object to prevent undefined errors
return {
title: "Bilibili Video",
author: "Bilibili User",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: null,
thumbnailSaved: false,
error: error.message,
};
}
}
// API endpoint to search for videos on YouTube
app.get("/api/search", async (req, res) => {
try {
const { query } = req.query;
if (!query) {
return res.status(400).json({ error: "Search query is required" });
}
console.log("Processing search request for query:", query);
// Use youtube-dl to search for videos
const searchResults = await youtubedl(`ytsearch5:${query}`, {
dumpSingleJson: true,
noWarnings: true,
noCallHome: true,
skipDownload: true,
playlistEnd: 5, // Limit to 5 results
});
if (!searchResults || !searchResults.entries) {
return res.status(200).json({ results: [] });
}
// Format the search results
const formattedResults = searchResults.entries.map((entry) => ({
id: entry.id,
title: entry.title,
author: entry.uploader,
thumbnailUrl: entry.thumbnail,
duration: entry.duration,
viewCount: entry.view_count,
sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`,
source: "youtube",
}));
console.log(
`Found ${formattedResults.length} search results for "${query}"`
);
res.status(200).json({ results: formattedResults });
} catch (error) {
console.error("Error searching for videos:", error);
res.status(500).json({
error: "Failed to search for videos",
details: error.message,
});
}
});
// API endpoint to download a video (YouTube or Bilibili)
app.post("/api/download", async (req, res) => {
try {
const { youtubeUrl } = req.body;
let videoUrl = youtubeUrl; // Keep the parameter name for backward compatibility
if (!videoUrl) {
return res.status(400).json({ error: "Video URL is required" });
}
console.log("Processing download request for URL:", videoUrl);
// Check if the input is a valid URL
if (!isValidUrl(videoUrl)) {
// If not a valid URL, treat it as a search term
return res.status(400).json({
error: "Not a valid URL",
isSearchTerm: true,
searchTerm: videoUrl,
});
}
// Trim Bilibili URL if needed
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}`;
// 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;
// Check if it's a Bilibili URL
if (isBilibiliUrl(videoUrl)) {
console.log("Detected Bilibili URL");
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 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);
return res.status(500).json({
error: "Failed to download Bilibili video",
details: bilibiliError.message,
});
}
} else {
console.log("Detected YouTube URL");
try {
// Get YouTube video info first
const info = await youtubedl(videoUrl, {
dumpSingleJson: true,
noWarnings: true,
noCallHome: true,
preferFreeFormats: true,
youtubeSkipDashManifest: true,
});
console.log("YouTube video info:", {
title: info.title,
uploader: info.uploader,
upload_date: info.upload_date,
});
videoTitle = info.title || "YouTube Video";
videoAuthor = info.uploader || "YouTube User";
videoDate =
info.upload_date ||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
thumbnailUrl = info.thumbnail;
// Update the safe base filename with the actual title
const newSafeBaseFilename = `${sanitizeFilename(
videoTitle
)}_${timestamp}`;
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
// Update the filenames
finalVideoFilename = newVideoFilename;
finalThumbnailFilename = newThumbnailFilename;
// Update paths
const newVideoPath = path.join(videosDir, finalVideoFilename);
const newThumbnailPath = path.join(imagesDir, finalThumbnailFilename);
// Download the YouTube video
console.log("Downloading YouTube video to:", newVideoPath);
await youtubedl(videoUrl, {
output: newVideoPath,
format: "mp4",
});
console.log("YouTube video downloaded successfully");
// Download and save the thumbnail
thumbnailSaved = false;
// Download the thumbnail image
if (thumbnailUrl) {
try {
console.log("Downloading thumbnail from:", thumbnailUrl);
const thumbnailResponse = await axios({
method: "GET",
url: thumbnailUrl,
responseType: "stream",
});
const thumbnailWriter = fs.createWriteStream(newThumbnailPath);
thumbnailResponse.data.pipe(thumbnailWriter);
await new Promise((resolve, reject) => {
thumbnailWriter.on("finish", () => {
thumbnailSaved = true;
resolve();
});
thumbnailWriter.on("error", reject);
});
console.log("Thumbnail saved to:", newThumbnailPath);
} catch (thumbnailError) {
console.error("Error downloading thumbnail:", thumbnailError);
// Continue even if thumbnail download fails
}
}
} catch (youtubeError) {
console.error("Error in YouTube download process:", youtubeError);
return res.status(500).json({
error: "Failed to download YouTube video",
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: 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 = [];
const videosDataPath = path.join(__dirname, "videos.json");
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");
res.status(200).json({ success: true, video: videoData });
} catch (error) {
console.error("Error downloading video:", error);
res
.status(500)
.json({ error: "Failed to download video", details: error.message });
}
});
// API endpoint to get all videos
app.get("/api/videos", (req, res) => {
try {
const videosDataPath = path.join(__dirname, "videos.json");
if (!fs.existsSync(videosDataPath)) {
return res.status(200).json([]);
}
const videos = JSON.parse(fs.readFileSync(videosDataPath, "utf8"));
res.status(200).json(videos);
} catch (error) {
console.error("Error fetching videos:", error);
res.status(500).json({ error: "Failed to fetch videos" });
}
});
// API endpoint to get a single video by ID
app.get("/api/videos/:id", (req, res) => {
try {
const { id } = req.params;
const videosDataPath = path.join(__dirname, "videos.json");
if (!fs.existsSync(videosDataPath)) {
return res.status(404).json({ error: "Video not found" });
}
const videos = JSON.parse(fs.readFileSync(videosDataPath, "utf8"));
const video = videos.find((v) => v.id === id);
if (!video) {
return res.status(404).json({ error: "Video not found" });
}
res.status(200).json(video);
} catch (error) {
console.error("Error fetching video:", error);
res.status(500).json({ error: "Failed to fetch video" });
}
});
// API endpoint to delete a video
app.delete("/api/videos/:id", (req, res) => {
try {
const { id } = req.params;
const videosDataPath = path.join(__dirname, "videos.json");
if (!fs.existsSync(videosDataPath)) {
return res.status(404).json({ error: "Video not found" });
}
// Read existing videos
let videos = JSON.parse(fs.readFileSync(videosDataPath, "utf8"));
// Find the video to delete
const videoToDelete = videos.find((v) => v.id === id);
if (!videoToDelete) {
return res.status(404).json({ error: "Video not found" });
}
// Remove the video file from the videos directory
if (videoToDelete.videoFilename) {
const videoFilePath = path.join(videosDir, videoToDelete.videoFilename);
if (fs.existsSync(videoFilePath)) {
fs.unlinkSync(videoFilePath);
}
}
// Remove the thumbnail file from the images directory
if (videoToDelete.thumbnailFilename) {
const thumbnailFilePath = path.join(
imagesDir,
videoToDelete.thumbnailFilename
);
if (fs.existsSync(thumbnailFilePath)) {
fs.unlinkSync(thumbnailFilePath);
}
}
// Filter out the deleted video from the videos array
videos = videos.filter((v) => v.id !== id);
// Save the updated videos array
fs.writeFileSync(videosDataPath, JSON.stringify(videos, null, 2));
res
.status(200)
.json({ success: true, message: "Video deleted successfully" });
} catch (error) {
console.error("Error deleting video:", error);
res.status(500).json({ error: "Failed to delete video" });
}
});
// Collections API endpoints
app.get("/api/collections", (req, res) => {
try {
// Collections are stored client-side in localStorage
// This endpoint is just a placeholder for future server-side implementation
res.json({ success: true, message: "Collections are managed client-side" });
} catch (error) {
console.error("Error getting collections:", error);
res
.status(500)
.json({ success: false, error: "Failed to get collections" });
}
});
app.post("/api/collections", (req, res) => {
try {
// Collections are stored client-side in localStorage
// This endpoint is just a placeholder for future server-side implementation
res.json({ success: true, message: "Collection created (client-side)" });
} catch (error) {
console.error("Error creating collection:", error);
res
.status(500)
.json({ success: false, error: "Failed to create collection" });
}
});
app.put("/api/collections/:id", (req, res) => {
try {
// Collections are stored client-side in localStorage
// This endpoint is just a placeholder for future server-side implementation
res.json({ success: true, message: "Collection updated (client-side)" });
} catch (error) {
console.error("Error updating collection:", error);
res
.status(500)
.json({ success: false, error: "Failed to update collection" });
}
});
app.delete("/api/collections/:id", (req, res) => {
try {
// Collections are stored client-side in localStorage
// This endpoint is just a placeholder for future server-side implementation
res.json({ success: true, message: "Collection deleted (client-side)" });
} catch (error) {
console.error("Error deleting collection:", error);
res
.status(500)
.json({ success: false, error: "Failed to delete collection" });
}
});
// Start the server
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});