refactor backend

This commit is contained in:
Peifan Li
2025-11-21 14:29:26 -05:00
parent f454017b2c
commit eaddadceb9
9 changed files with 1345 additions and 1347 deletions

View File

@@ -1,5 +1,5 @@
{
"isDownloading": false,
"title": "",
"timestamp": 1742565862280
"timestamp": 1763753271967
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
const path = require("path");
// Assuming the application is started from the 'backend' directory
const ROOT_DIR = process.cwd();
const UPLOADS_DIR = path.join(ROOT_DIR, "uploads");
const VIDEOS_DIR = path.join(UPLOADS_DIR, "videos");
const IMAGES_DIR = path.join(UPLOADS_DIR, "images");
const DATA_DIR = path.join(ROOT_DIR, "data");
const VIDEOS_DATA_PATH = path.join(DATA_DIR, "videos.json");
const STATUS_DATA_PATH = path.join(DATA_DIR, "status.json");
const COLLECTIONS_DATA_PATH = path.join(DATA_DIR, "collections.json");
module.exports = {
ROOT_DIR,
UPLOADS_DIR,
VIDEOS_DIR,
IMAGES_DIR,
DATA_DIR,
VIDEOS_DATA_PATH,
STATUS_DATA_PATH,
COLLECTIONS_DATA_PATH,
};

View File

@@ -0,0 +1,126 @@
const storageService = require("../services/storageService");
// Get all collections
const getCollections = (req, res) => {
try {
const collections = storageService.getCollections();
res.json(collections);
} catch (error) {
console.error("Error getting collections:", error);
res
.status(500)
.json({ success: false, error: "Failed to get collections" });
}
};
// Create a new collection
const createCollection = (req, res) => {
try {
const { name, videoId } = req.body;
if (!name) {
return res
.status(400)
.json({ success: false, error: "Collection name is required" });
}
// Create a new collection
const newCollection = {
id: Date.now().toString(),
name,
videos: videoId ? [videoId] : [],
createdAt: new Date().toISOString(),
};
// Save the new collection
storageService.saveCollection(newCollection);
res.status(201).json(newCollection);
} catch (error) {
console.error("Error creating collection:", error);
res
.status(500)
.json({ success: false, error: "Failed to create collection" });
}
};
// Update a collection
const updateCollection = (req, res) => {
try {
const { id } = req.params;
const { name, videoId, action } = req.body;
const collection = storageService.getCollectionById(id);
if (!collection) {
return res
.status(404)
.json({ success: false, error: "Collection not found" });
}
// Update the collection
if (name) {
collection.name = name;
}
// Add or remove a video
if (videoId) {
if (action === "add") {
// Add the video if it's not already in the collection
if (!collection.videos.includes(videoId)) {
collection.videos.push(videoId);
}
} else if (action === "remove") {
// Remove the video
collection.videos = collection.videos.filter((v) => v !== videoId);
}
}
collection.updatedAt = new Date().toISOString();
// Save the updated collection
const success = storageService.updateCollection(collection);
if (!success) {
return res
.status(500)
.json({ success: false, error: "Failed to update collection" });
}
res.json(collection);
} catch (error) {
console.error("Error updating collection:", error);
res
.status(500)
.json({ success: false, error: "Failed to update collection" });
}
};
// Delete a collection
const deleteCollection = (req, res) => {
try {
const { id } = req.params;
const success = storageService.deleteCollection(id);
if (!success) {
return res
.status(404)
.json({ success: false, error: "Collection not found" });
}
res.json({ success: true, message: "Collection deleted successfully" });
} catch (error) {
console.error("Error deleting collection:", error);
res
.status(500)
.json({ success: false, error: "Failed to delete collection" });
}
};
module.exports = {
getCollections,
createCollection,
updateCollection,
deleteCollection,
};

View File

@@ -0,0 +1,317 @@
const storageService = require("../services/storageService");
const downloadService = require("../services/downloadService");
const {
isValidUrl,
extractUrlFromText,
resolveShortUrl,
isBilibiliUrl,
trimBilibiliUrl,
extractBilibiliVideoId,
} = require("../utils/helpers");
// Search for videos
const searchVideos = async (req, res) => {
try {
const { query } = req.query;
if (!query) {
return res.status(400).json({ error: "Search query is required" });
}
const results = await downloadService.searchYouTube(query);
res.status(200).json({ results });
} catch (error) {
console.error("Error searching for videos:", error);
res.status(500).json({
error: "Failed to search for videos",
details: error.message,
});
}
};
// Download video
const downloadVideo = async (req, res) => {
try {
const { youtubeUrl, downloadAllParts, collectionName } = req.body;
let videoUrl = youtubeUrl;
if (!videoUrl) {
return res.status(400).json({ error: "Video URL is required" });
}
console.log("Processing download request for input:", videoUrl);
// Extract URL if the input contains text with a URL
videoUrl = extractUrlFromText(videoUrl);
console.log("Extracted 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,
});
}
// 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 (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);
}
// 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) {
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) {
const collection = storageService.getCollectionById(collectionId);
if (collection) {
collection.videos.push(firstPartResult.videoData.id);
storageService.updateCollection(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");
}
}
} else {
// YouTube download
const videoData = await downloadService.downloadYouTubeVideo(videoUrl);
return res.status(200).json({ success: true, video: videoData });
}
} catch (error) {
console.error("Error downloading video:", error);
storageService.updateDownloadStatus(false);
res
.status(500)
.json({ error: "Failed to download video", details: error.message });
}
};
// Get all videos
const getVideos = (req, res) => {
try {
const videos = storageService.getVideos();
res.status(200).json(videos);
} catch (error) {
console.error("Error fetching videos:", error);
res.status(500).json({ error: "Failed to fetch videos" });
}
};
// Get video by ID
const getVideoById = (req, res) => {
try {
const { id } = req.params;
const video = storageService.getVideoById(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" });
}
};
// Delete video
const deleteVideo = (req, res) => {
try {
const { id } = req.params;
const success = storageService.deleteVideo(id);
if (!success) {
return res.status(404).json({ error: "Video not found" });
}
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" });
}
};
// Get download status
const getDownloadStatus = (req, res) => {
try {
const status = storageService.getDownloadStatus();
res.status(200).json(status);
} catch (error) {
console.error("Error fetching download status:", error);
res.status(500).json({ error: "Failed to fetch download status" });
}
};
// Check Bilibili parts
const checkBilibiliParts = async (req, res) => {
try {
const { url } = req.query;
if (!url) {
return res.status(400).json({ error: "URL is required" });
}
if (!isBilibiliUrl(url)) {
return res.status(400).json({ error: "Not a valid Bilibili URL" });
}
// Resolve shortened URLs (like b23.tv)
let videoUrl = url;
if (videoUrl.includes("b23.tv")) {
videoUrl = await resolveShortUrl(videoUrl);
console.log("Resolved shortened URL to:", videoUrl);
}
// Trim Bilibili URL if needed
videoUrl = trimBilibiliUrl(videoUrl);
// Extract video ID
const videoId = extractBilibiliVideoId(videoUrl);
if (!videoId) {
return res
.status(400)
.json({ error: "Could not extract Bilibili video ID" });
}
const result = await downloadService.checkBilibiliVideoParts(videoId);
res.status(200).json(result);
} catch (error) {
console.error("Error checking Bilibili video parts:", error);
res.status(500).json({
error: "Failed to check Bilibili video parts",
details: error.message,
});
}
};
module.exports = {
searchVideos,
downloadVideo,
getVideos,
getVideoById,
deleteVideo,
getDownloadStatus,
checkBilibiliParts,
};

21
backend/src/routes/api.js Normal file
View File

@@ -0,0 +1,21 @@
const express = require("express");
const router = express.Router();
const videoController = require("../controllers/videoController");
const collectionController = require("../controllers/collectionController");
// Video routes
router.get("/search", videoController.searchVideos);
router.post("/download", videoController.downloadVideo);
router.get("/videos", videoController.getVideos);
router.get("/videos/:id", videoController.getVideoById);
router.delete("/videos/:id", videoController.deleteVideo);
router.get("/download-status", videoController.getDownloadStatus);
router.get("/check-bilibili-parts", videoController.checkBilibiliParts);
// Collection routes
router.get("/collections", collectionController.getCollections);
router.post("/collections", collectionController.createCollection);
router.put("/collections/:id", collectionController.updateCollection);
router.delete("/collections/:id", collectionController.deleteCollection);
module.exports = router;

View File

@@ -0,0 +1,537 @@
const fs = require("fs-extra");
const path = require("path");
const youtubedl = require("youtube-dl-exec");
const axios = require("axios");
const { downloadByVedioPath } = require("bilibili-save-nodejs");
const { VIDEOS_DIR, IMAGES_DIR } = require("../config/paths");
const {
sanitizeFilename,
extractBilibiliVideoId,
} = require("../utils/helpers");
const storageService = require("./storageService");
// Helper function to download Bilibili video
async function downloadBilibiliVideo(url, videoPath, thumbnailPath) {
try {
// Create a temporary directory for the download
const tempDir = path.join(VIDEOS_DIR, "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(VIDEOS_DIR, "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,
};
}
}
// Helper function to check if a Bilibili video has multiple parts
async function checkBilibiliVideoParts(videoId) {
try {
// Try to get video info from Bilibili API
const apiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`;
console.log("Fetching video info from API to check parts:", apiUrl);
const response = await axios.get(apiUrl);
if (response.data && response.data.data) {
const videoInfo = response.data.data;
const videosNumber = videoInfo.videos || 1;
console.log(`Bilibili video has ${videosNumber} parts`);
return {
success: true,
videosNumber,
title: videoInfo.title || "Bilibili Video",
};
}
return { success: false, videosNumber: 1 };
} catch (error) {
console.error("Error checking Bilibili video parts:", error);
return { success: false, videosNumber: 1 };
}
}
// Helper function to download a single Bilibili part
async function downloadSingleBilibiliPart(
url,
partNumber,
totalParts,
seriesTitle
) {
try {
console.log(
`Downloading Bilibili part ${partNumber}/${totalParts}: ${url}`
);
// Create a safe base filename (without extension)
const timestamp = Date.now();
const safeBaseFilename = `video_${timestamp}`;
// Add extensions for video and thumbnail
const videoFilename = `${safeBaseFilename}.mp4`;
const thumbnailFilename = `${safeBaseFilename}.jpg`;
// Set full paths for video and thumbnail
const videoPath = path.join(VIDEOS_DIR, videoFilename);
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
let finalVideoFilename = videoFilename;
let finalThumbnailFilename = thumbnailFilename;
// Download Bilibili video
const bilibiliInfo = await downloadBilibiliVideo(
url,
videoPath,
thumbnailPath
);
if (!bilibiliInfo) {
throw new Error("Failed to get Bilibili video info");
}
console.log("Bilibili download info:", bilibiliInfo);
// For multi-part videos, include the part number in the title
videoTitle =
totalParts > 1
? `${seriesTitle} - Part ${partNumber}/${totalParts}`
: bilibiliInfo.title || "Bilibili Video";
videoAuthor = bilibiliInfo.author || "Bilibili User";
videoDate =
bilibiliInfo.date ||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
thumbnailUrl = bilibiliInfo.thumbnailUrl;
thumbnailSaved = bilibiliInfo.thumbnailSaved;
// Update the safe base filename with the actual title
const newSafeBaseFilename = `${sanitizeFilename(videoTitle)}_${timestamp}`;
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
// Rename the files
const newVideoPath = path.join(VIDEOS_DIR, newVideoFilename);
const newThumbnailPath = path.join(IMAGES_DIR, newThumbnailFilename);
if (fs.existsSync(videoPath)) {
fs.renameSync(videoPath, newVideoPath);
console.log("Renamed video file to:", newVideoFilename);
finalVideoFilename = newVideoFilename;
} else {
console.log("Video file not found at:", videoPath);
}
if (thumbnailSaved && fs.existsSync(thumbnailPath)) {
fs.renameSync(thumbnailPath, newThumbnailPath);
console.log("Renamed thumbnail file to:", newThumbnailFilename);
finalThumbnailFilename = newThumbnailFilename;
}
// Create metadata for the video
const videoData = {
id: timestamp.toString(),
title: videoTitle,
author: videoAuthor,
date: videoDate,
source: "bilibili",
sourceUrl: url,
videoFilename: finalVideoFilename,
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : null,
thumbnailUrl: thumbnailUrl,
videoPath: `/videos/${finalVideoFilename}`,
thumbnailPath: thumbnailSaved
? `/images/${finalThumbnailFilename}`
: null,
addedAt: new Date().toISOString(),
partNumber: partNumber,
totalParts: totalParts,
seriesTitle: seriesTitle,
};
// Save the video using storage service
storageService.saveVideo(videoData);
console.log(`Part ${partNumber}/${totalParts} added to database`);
return { success: true, videoData };
} catch (error) {
console.error(
`Error downloading Bilibili part ${partNumber}/${totalParts}:`,
error
);
return { success: false, error: error.message };
}
}
// Helper function to download remaining Bilibili parts in sequence
async function downloadRemainingBilibiliParts(
baseUrl,
startPart,
totalParts,
seriesTitle,
collectionId
) {
try {
for (let part = startPart; part <= totalParts; part++) {
// Update status to show which part is being downloaded
storageService.updateDownloadStatus(
true,
`Downloading part ${part}/${totalParts}: ${seriesTitle}`
);
// Construct URL for this part
const partUrl = `${baseUrl}?p=${part}`;
// Download this part
const result = await downloadSingleBilibiliPart(
partUrl,
part,
totalParts,
seriesTitle
);
// If download was successful and we have a collection ID, add to collection
if (result.success && collectionId && result.videoData) {
try {
const collection = storageService.getCollectionById(collectionId);
if (collection) {
// Add video to collection
collection.videos.push(result.videoData.id);
storageService.updateCollection(collection);
console.log(
`Added part ${part}/${totalParts} to collection ${collectionId}`
);
}
} catch (collectionError) {
console.error(
`Error adding part ${part}/${totalParts} to collection:`,
collectionError
);
}
}
// Small delay between downloads to avoid overwhelming the server
await new Promise((resolve) => setTimeout(resolve, 1000));
}
// All parts downloaded, update status
storageService.updateDownloadStatus(false);
console.log(
`All ${totalParts} parts of "${seriesTitle}" downloaded successfully`
);
} catch (error) {
console.error("Error downloading remaining Bilibili parts:", error);
storageService.updateDownloadStatus(false);
}
}
// Search for videos on YouTube
async function searchYouTube(query) {
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 [];
}
// 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}"`
);
return formattedResults;
}
// Download YouTube video
async function downloadYouTubeVideo(videoUrl) {
console.log("Detected YouTube URL");
storageService.updateDownloadStatus(true, "Downloading YouTube video...");
// Create a safe base filename (without extension)
const timestamp = Date.now();
const safeBaseFilename = `video_${timestamp}`;
// Add extensions for video and thumbnail
const videoFilename = `${safeBaseFilename}.mp4`;
const thumbnailFilename = `${safeBaseFilename}.jpg`;
// Set full paths for video and thumbnail
const videoPath = path.join(VIDEOS_DIR, videoFilename);
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
let finalVideoFilename = videoFilename;
let finalThumbnailFilename = thumbnailFilename;
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 download status with actual title
storageService.updateDownloadStatus(true, `Downloading: ${videoTitle}`);
// Update the safe base filename with the actual title
const newSafeBaseFilename = `${sanitizeFilename(
videoTitle
)}_${timestamp}`;
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
// Update the filenames
finalVideoFilename = newVideoFilename;
finalThumbnailFilename = newThumbnailFilename;
// Update paths
const newVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
const newThumbnailPath = path.join(IMAGES_DIR, 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);
// Set download status to false on error
storageService.updateDownloadStatus(false);
throw youtubeError;
}
// Create metadata for the video
const videoData = {
id: timestamp.toString(),
title: videoTitle || "Video",
author: videoAuthor || "Unknown",
date:
videoDate || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
source: "youtube",
sourceUrl: videoUrl,
videoFilename: finalVideoFilename,
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : null,
thumbnailUrl: thumbnailUrl,
videoPath: `/videos/${finalVideoFilename}`,
thumbnailPath: thumbnailSaved
? `/images/${finalThumbnailFilename}`
: null,
addedAt: new Date().toISOString(),
};
// Save the video
storageService.saveVideo(videoData);
console.log("Video added to database");
// Set download status to false when complete
storageService.updateDownloadStatus(false);
return videoData;
}
module.exports = {
downloadBilibiliVideo,
checkBilibiliVideoParts,
downloadSingleBilibiliPart,
downloadRemainingBilibiliParts,
searchYouTube,
downloadYouTubeVideo,
};

View File

@@ -0,0 +1,188 @@
const fs = require("fs-extra");
const path = require("path");
const {
UPLOADS_DIR,
VIDEOS_DIR,
IMAGES_DIR,
DATA_DIR,
VIDEOS_DATA_PATH,
STATUS_DATA_PATH,
COLLECTIONS_DATA_PATH,
} = require("../config/paths");
// Initialize storage directories and files
function initializeStorage() {
fs.ensureDirSync(UPLOADS_DIR);
fs.ensureDirSync(VIDEOS_DIR);
fs.ensureDirSync(IMAGES_DIR);
fs.ensureDirSync(DATA_DIR);
// 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)
);
}
}
// Update download status
function updateDownloadStatus(isDownloading, 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}`
);
} catch (error) {
console.error("Error updating download status:", error);
}
}
// Get download status
function getDownloadStatus() {
if (!fs.existsSync(STATUS_DATA_PATH)) {
updateDownloadStatus(false);
return { isDownloading: false, title: "" };
}
const status = JSON.parse(fs.readFileSync(STATUS_DATA_PATH, "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 { isDownloading: false, title: "" };
}
return status;
}
// Get all videos
function getVideos() {
if (!fs.existsSync(VIDEOS_DATA_PATH)) {
return [];
}
return JSON.parse(fs.readFileSync(VIDEOS_DATA_PATH, "utf8"));
}
// Get video by ID
function getVideoById(id) {
const videos = getVideos();
return videos.find((v) => v.id === id);
}
// Save a new video
function saveVideo(videoData) {
let videos = getVideos();
videos.unshift(videoData);
fs.writeFileSync(VIDEOS_DATA_PATH, JSON.stringify(videos, null, 2));
return videoData;
}
// Delete a video
function deleteVideo(id) {
let videos = getVideos();
const videoToDelete = videos.find((v) => v.id === id);
if (!videoToDelete) {
return false;
}
// Remove the video file from the videos directory
if (videoToDelete.videoFilename) {
const videoFilePath = path.join(VIDEOS_DIR, videoToDelete.videoFilename);
if (fs.existsSync(videoFilePath)) {
fs.unlinkSync(videoFilePath);
}
}
// Remove the thumbnail file from the images directory
if (videoToDelete.thumbnailFilename) {
const thumbnailFilePath = path.join(
IMAGES_DIR,
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(VIDEOS_DATA_PATH, JSON.stringify(videos, null, 2));
return true;
}
// Get all collections
function getCollections() {
if (!fs.existsSync(COLLECTIONS_DATA_PATH)) {
return [];
}
return JSON.parse(fs.readFileSync(COLLECTIONS_DATA_PATH, "utf8"));
}
// Get collection by ID
function getCollectionById(id) {
const collections = getCollections();
return collections.find((c) => c.id === id);
}
// Save a new collection
function saveCollection(collection) {
let collections = getCollections();
collections.push(collection);
fs.writeFileSync(COLLECTIONS_DATA_PATH, JSON.stringify(collections, null, 2));
return collection;
}
// Update a collection
function updateCollection(updatedCollection) {
let collections = getCollections();
const index = collections.findIndex((c) => c.id === updatedCollection.id);
if (index === -1) {
return false;
}
collections[index] = updatedCollection;
fs.writeFileSync(COLLECTIONS_DATA_PATH, JSON.stringify(collections, null, 2));
return true;
}
// Delete a collection
function deleteCollection(id) {
let collections = getCollections();
const updatedCollections = collections.filter((c) => c.id !== id);
if (updatedCollections.length === collections.length) {
return false;
}
fs.writeFileSync(
COLLECTIONS_DATA_PATH,
JSON.stringify(updatedCollections, null, 2)
);
return true;
}
module.exports = {
initializeStorage,
updateDownloadStatus,
getDownloadStatus,
getVideos,
getVideoById,
saveVideo,
deleteVideo,
getCollections,
getCollectionById,
saveCollection,
updateCollection,
deleteCollection,
};

View File

@@ -0,0 +1,121 @@
const axios = require("axios");
// 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") || url.includes("b23.tv");
}
// Helper function to extract URL from text that might contain a title and URL
function extractUrlFromText(text) {
// Regular expression to find URLs in text
const urlRegex = /(https?:\/\/[^\s]+)/g;
const matches = text.match(urlRegex);
if (matches && matches.length > 0) {
return matches[0];
}
return text; // Return original text if no URL found
}
// Helper function to resolve shortened URLs (like b23.tv)
async function resolveShortUrl(url) {
try {
console.log(`Resolving shortened URL: ${url}`);
// Make a HEAD request to follow redirects
const response = await axios.head(url, {
maxRedirects: 5,
validateStatus: null,
});
// Get the final URL after redirects
const resolvedUrl = response.request.res.responseUrl || url;
console.log(`Resolved to: ${resolvedUrl}`);
return resolvedUrl;
} catch (error) {
console.error(`Error resolving shortened URL: ${error.message}`);
return url; // Return original URL if resolution fails
}
}
// Helper function to trim Bilibili URL by removing query parameters
function trimBilibiliUrl(url) {
try {
// First, extract the video ID (BV or av format)
const videoIdMatch = url.match(/\/video\/(BV[\w]+|av\d+)/i);
if (videoIdMatch && videoIdMatch[1]) {
const videoId = videoIdMatch[1];
// Construct a clean URL with just the video ID
const cleanUrl = videoId.startsWith("BV")
? `https://www.bilibili.com/video/${videoId}`
: `https://www.bilibili.com/video/${videoId}`;
console.log(`Trimmed Bilibili URL from "${url}" to "${cleanUrl}"`);
return cleanUrl;
}
// If we couldn't extract the video ID using the regex above,
// try to clean the URL by removing query parameters
try {
const urlObj = new URL(url);
const cleanUrl = `${urlObj.origin}${urlObj.pathname}`;
console.log(`Trimmed Bilibili URL from "${url}" to "${cleanUrl}"`);
return cleanUrl;
} catch (urlError) {
console.error("Error parsing URL:", urlError);
return url;
}
} 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 - works for both desktop and mobile URLs
const bvMatch = url.match(/\/video\/(BV[\w]+)/i);
if (bvMatch && bvMatch[1]) {
return bvMatch[1];
}
// Extract av ID from URL
const avMatch = url.match(/\/video\/(av\d+)/i);
if (avMatch && avMatch[1]) {
return 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
}
module.exports = {
isValidUrl,
isBilibiliUrl,
extractUrlFromText,
resolveShortUrl,
trimBilibiliUrl,
extractBilibiliVideoId,
sanitizeFilename,
};