refactor backend
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"isDownloading": false,
|
||||
"title": "",
|
||||
"timestamp": 1742565862280
|
||||
"timestamp": 1763753271967
|
||||
}
|
||||
1356
backend/server.js
1356
backend/server.js
File diff suppressed because it is too large
Load Diff
24
backend/src/config/paths.js
Normal file
24
backend/src/config/paths.js
Normal 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,
|
||||
};
|
||||
126
backend/src/controllers/collectionController.js
Normal file
126
backend/src/controllers/collectionController.js
Normal 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,
|
||||
};
|
||||
317
backend/src/controllers/videoController.js
Normal file
317
backend/src/controllers/videoController.js
Normal 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
21
backend/src/routes/api.js
Normal 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;
|
||||
537
backend/src/services/downloadService.js
Normal file
537
backend/src/services/downloadService.js
Normal 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,
|
||||
};
|
||||
188
backend/src/services/storageService.js
Normal file
188
backend/src/services/storageService.js
Normal 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,
|
||||
};
|
||||
121
backend/src/utils/helpers.js
Normal file
121
backend/src/utils/helpers.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user