feat: Implement getVideoInfo and downloadVideo for Bilibili
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { extractBilibiliVideoId, isBilibiliUrl } from "../utils/helpers";
|
||||
import { VideoInfo } from "./downloaders/BaseDownloader";
|
||||
import {
|
||||
BilibiliCollectionCheckResult,
|
||||
BilibiliDownloader,
|
||||
@@ -147,15 +148,12 @@ export async function downloadMissAVVideo(
|
||||
return MissAVDownloader.downloadVideo(url, downloadId, onStart);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Helper function to get video info without downloading
|
||||
export async function getVideoInfo(
|
||||
url: string
|
||||
): Promise<{
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
thumbnailUrl: string;
|
||||
}> {
|
||||
): Promise<VideoInfo> {
|
||||
if (isBilibiliUrl(url)) {
|
||||
const videoId = extractBilibiliVideoId(url);
|
||||
if (videoId) {
|
||||
|
||||
77
backend/src/services/downloaders/BaseDownloader.ts
Normal file
77
backend/src/services/downloaders/BaseDownloader.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import axios from "axios";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { formatVideoFilename } from "../../utils/helpers";
|
||||
import { Video } from "../storageService";
|
||||
|
||||
export interface VideoInfo {
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
thumbnailUrl: string | null;
|
||||
description?: string;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
export interface DownloadOptions {
|
||||
downloadId?: string;
|
||||
onStart?: (cancel: () => void) => void;
|
||||
// Generic key-value store for specific downloader options
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface IDownloader {
|
||||
getVideoInfo(url: string): Promise<VideoInfo>;
|
||||
downloadVideo(url: string, options?: DownloadOptions): Promise<Video>;
|
||||
}
|
||||
|
||||
export abstract class BaseDownloader implements IDownloader {
|
||||
abstract getVideoInfo(url: string): Promise<VideoInfo>;
|
||||
|
||||
abstract downloadVideo(url: string, options?: DownloadOptions): Promise<Video>;
|
||||
|
||||
/**
|
||||
* Common helper to download a thumbnail
|
||||
*/
|
||||
protected async downloadThumbnail(
|
||||
thumbnailUrl: string,
|
||||
savePath: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
console.log("Downloading thumbnail from:", thumbnailUrl);
|
||||
|
||||
// Ensure directory exists
|
||||
fs.ensureDirSync(path.dirname(savePath));
|
||||
|
||||
const response = await axios({
|
||||
method: "GET",
|
||||
url: thumbnailUrl,
|
||||
responseType: "stream",
|
||||
});
|
||||
|
||||
const writer = fs.createWriteStream(savePath);
|
||||
response.data.pipe(writer);
|
||||
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
writer.on("finish", () => {
|
||||
console.log("Thumbnail saved to:", savePath);
|
||||
resolve(true);
|
||||
});
|
||||
writer.on("error", (err) => {
|
||||
console.error("Error writing thumbnail file:", err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error downloading thumbnail:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to format filename using the standard utility
|
||||
*/
|
||||
protected getSafeFilename(title: string, author: string, date: string): string {
|
||||
return formatVideoFilename(title, author, date);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "../../utils/ytDlpUtils";
|
||||
import * as storageService from "../storageService";
|
||||
import { Collection, Video } from "../storageService";
|
||||
import { BaseDownloader, DownloadOptions, VideoInfo } from "./BaseDownloader";
|
||||
|
||||
export interface BilibiliVideoInfo {
|
||||
title: string;
|
||||
@@ -73,7 +74,91 @@ export interface CollectionDownloadResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class BilibiliDownloader {
|
||||
export class BilibiliDownloader extends BaseDownloader {
|
||||
// Implementation of IDownloader.getVideoInfo
|
||||
async getVideoInfo(url: string): Promise<VideoInfo> {
|
||||
const videoId = extractBilibiliVideoId(url);
|
||||
if (!videoId) {
|
||||
throw new Error("Invalid Bilibili URL");
|
||||
}
|
||||
return BilibiliDownloader.getVideoInfo(videoId);
|
||||
}
|
||||
|
||||
// Get video info without downloading (Static wrapper)
|
||||
static async getVideoInfo(videoId: string): Promise<VideoInfo> {
|
||||
try {
|
||||
const videoUrl = `https://www.bilibili.com/video/${videoId}`;
|
||||
|
||||
// Get user config for network options
|
||||
const userConfig = getUserYtDlpConfig(videoUrl);
|
||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||
const info = await executeYtDlpJson(videoUrl, {
|
||||
...networkConfig,
|
||||
noWarnings: true,
|
||||
});
|
||||
|
||||
return {
|
||||
title: info.title || "Bilibili Video",
|
||||
author: info.uploader || info.channel || "Bilibili User",
|
||||
date:
|
||||
info.upload_date ||
|
||||
info.release_date ||
|
||||
new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: info.thumbnail || null,
|
||||
description: info.description // Added description
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching Bilibili video info with yt-dlp:", error);
|
||||
// Fallback to API
|
||||
try {
|
||||
const apiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`;
|
||||
const response = await axios.get(apiUrl);
|
||||
|
||||
if (response.data && response.data.data) {
|
||||
const videoInfo = response.data.data;
|
||||
return {
|
||||
title: videoInfo.title || "Bilibili Video",
|
||||
author: videoInfo.owner?.name || "Bilibili User",
|
||||
date: new Date(videoInfo.pubdate * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
.replace(/-/g, ""),
|
||||
thumbnailUrl: videoInfo.pic || null,
|
||||
description: videoInfo.desc
|
||||
};
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.error("Error fetching Bilibili video info from API:", apiError);
|
||||
}
|
||||
return {
|
||||
title: "Bilibili Video",
|
||||
author: "Bilibili User",
|
||||
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Implementation of IDownloader.downloadVideo
|
||||
// Note: For Bilibili, this defaults to downloading single part/video.
|
||||
async downloadVideo(url: string, options?: DownloadOptions): Promise<Video> {
|
||||
// Assuming single part download for simplicity in the general interface
|
||||
const result = await BilibiliDownloader.downloadSinglePart(
|
||||
url,
|
||||
1,
|
||||
1,
|
||||
"",
|
||||
options?.downloadId,
|
||||
options?.onStart
|
||||
);
|
||||
|
||||
if (result.success && result.videoData) {
|
||||
return result.videoData;
|
||||
}
|
||||
|
||||
throw new Error(result.error || "Failed to download Bilibili video");
|
||||
}
|
||||
|
||||
// Get author info from Bilibili space URL
|
||||
static async getAuthorInfo(mid: string): Promise<{
|
||||
name: string;
|
||||
@@ -205,65 +290,7 @@ export class BilibiliDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
// Get video info without downloading
|
||||
static async getVideoInfo(videoId: string): Promise<{
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
thumbnailUrl: string;
|
||||
}> {
|
||||
try {
|
||||
const videoUrl = `https://www.bilibili.com/video/${videoId}`;
|
||||
|
||||
// Get user config for network options
|
||||
const userConfig = getUserYtDlpConfig(videoUrl);
|
||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||
const info = await executeYtDlpJson(videoUrl, {
|
||||
...networkConfig,
|
||||
noWarnings: true,
|
||||
});
|
||||
|
||||
return {
|
||||
title: info.title || "Bilibili Video",
|
||||
author: info.uploader || info.channel || "Bilibili User",
|
||||
date:
|
||||
info.upload_date ||
|
||||
info.release_date ||
|
||||
new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: info.thumbnail || "",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching Bilibili video info with yt-dlp:", error);
|
||||
// Fallback to API
|
||||
try {
|
||||
const apiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`;
|
||||
const response = await axios.get(apiUrl);
|
||||
|
||||
if (response.data && response.data.data) {
|
||||
const videoInfo = response.data.data;
|
||||
return {
|
||||
title: videoInfo.title || "Bilibili Video",
|
||||
author: videoInfo.owner?.name || "Bilibili User",
|
||||
date: new Date(videoInfo.pubdate * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
.replace(/-/g, ""),
|
||||
thumbnailUrl: videoInfo.pic,
|
||||
};
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.error("Error fetching Bilibili video info from API:", apiError);
|
||||
}
|
||||
return {
|
||||
title: "Bilibili Video",
|
||||
author: "Bilibili User",
|
||||
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to download Bilibili video
|
||||
// Wrapper for internal download logic, matching existing static method
|
||||
static async downloadVideo(
|
||||
url: string,
|
||||
videoPath: string,
|
||||
|
||||
@@ -15,17 +15,18 @@ import {
|
||||
import { formatVideoFilename } from "../../utils/helpers";
|
||||
import * as storageService from "../storageService";
|
||||
import { Video } from "../storageService";
|
||||
import { BaseDownloader, DownloadOptions, VideoInfo } from "./BaseDownloader";
|
||||
|
||||
const YT_DLP_PATH = process.env.YT_DLP_PATH || "yt-dlp";
|
||||
|
||||
export class MissAVDownloader {
|
||||
// Get video info without downloading
|
||||
static async getVideoInfo(url: string): Promise<{
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
thumbnailUrl: string;
|
||||
}> {
|
||||
export class MissAVDownloader extends BaseDownloader {
|
||||
// Implementation of IDownloader.getVideoInfo
|
||||
async getVideoInfo(url: string): Promise<VideoInfo> {
|
||||
return MissAVDownloader.getVideoInfo(url);
|
||||
}
|
||||
|
||||
// Get video info without downloading (Static wrapper)
|
||||
static async getVideoInfo(url: string): Promise<VideoInfo> {
|
||||
try {
|
||||
console.log(`Fetching page content for ${url} with Puppeteer...`);
|
||||
|
||||
@@ -54,7 +55,7 @@ export class MissAVDownloader {
|
||||
title: pageTitle || "MissAV Video",
|
||||
author: author,
|
||||
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: ogImage || "",
|
||||
thumbnailUrl: ogImage || null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching MissAV video info:", error);
|
||||
@@ -65,12 +66,21 @@ export class MissAVDownloader {
|
||||
title: "MissAV Video",
|
||||
author: author,
|
||||
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: "",
|
||||
thumbnailUrl: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to download MissAV video
|
||||
// Implementation of IDownloader.downloadVideo
|
||||
async downloadVideo(url: string, options?: DownloadOptions): Promise<Video> {
|
||||
return MissAVDownloader.downloadVideo(
|
||||
url,
|
||||
options?.downloadId,
|
||||
options?.onStart
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to download MissAV video (Static wrapper/Implementation)
|
||||
static async downloadVideo(
|
||||
url: string,
|
||||
downloadId?: string,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "../../utils/ytDlpUtils";
|
||||
import * as storageService from "../storageService";
|
||||
import { Video } from "../storageService";
|
||||
import { BaseDownloader, DownloadOptions, VideoInfo } from "./BaseDownloader";
|
||||
|
||||
// Note: PO Token provider script path - only used if user has the bgutil plugin installed
|
||||
// Modern yt-dlp (2025.11+) has built-in JS challenge solvers that work without PO tokens
|
||||
@@ -62,7 +63,7 @@ async function extractXiaoHongShuAuthor(url: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export class YtDlpDownloader {
|
||||
export class YtDlpDownloader extends BaseDownloader {
|
||||
// Search for videos (primarily for YouTube, but could be adapted)
|
||||
static async search(
|
||||
query: string,
|
||||
@@ -126,13 +127,13 @@ export class YtDlpDownloader {
|
||||
return formattedResults;
|
||||
}
|
||||
|
||||
// Get video info without downloading
|
||||
static async getVideoInfo(url: string): Promise<{
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
thumbnailUrl: string;
|
||||
}> {
|
||||
// Implementation of IDownloader.getVideoInfo
|
||||
async getVideoInfo(url: string): Promise<VideoInfo> {
|
||||
return YtDlpDownloader.getVideoInfo(url);
|
||||
}
|
||||
|
||||
// Get video info without downloading (Static wrapper)
|
||||
static async getVideoInfo(url: string): Promise<VideoInfo> {
|
||||
try {
|
||||
// Get user config for network options
|
||||
const userConfig = getUserYtDlpConfig(url);
|
||||
@@ -156,6 +157,7 @@ export class YtDlpDownloader {
|
||||
info.upload_date ||
|
||||
new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: info.thumbnail,
|
||||
description: info.description // Added description
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching video info:", error);
|
||||
@@ -237,7 +239,16 @@ export class YtDlpDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
// Download video
|
||||
// Implementation of IDownloader.downloadVideo
|
||||
async downloadVideo(url: string, options?: DownloadOptions): Promise<Video> {
|
||||
return YtDlpDownloader.downloadVideo(
|
||||
url,
|
||||
options?.downloadId,
|
||||
options?.onStart
|
||||
);
|
||||
}
|
||||
|
||||
// Download video (Static wrapper/Implementation)
|
||||
static async downloadVideo(
|
||||
videoUrl: string,
|
||||
downloadId?: string,
|
||||
|
||||
Reference in New Issue
Block a user