feat: Implement getVideoInfo and downloadVideo for Bilibili

This commit is contained in:
Peifan Li
2025-12-14 20:57:10 -05:00
parent 3023883e9c
commit f864b90988
5 changed files with 247 additions and 124 deletions

View File

@@ -1,12 +1,13 @@
import { extractBilibiliVideoId, isBilibiliUrl } from "../utils/helpers";
import { VideoInfo } from "./downloaders/BaseDownloader";
import {
BilibiliCollectionCheckResult,
BilibiliDownloader,
BilibiliPartsCheckResult,
BilibiliVideoInfo,
BilibiliVideosResult,
CollectionDownloadResult,
DownloadResult,
BilibiliCollectionCheckResult,
BilibiliDownloader,
BilibiliPartsCheckResult,
BilibiliVideoInfo,
BilibiliVideosResult,
CollectionDownloadResult,
DownloadResult,
} from "./downloaders/BilibiliDownloader";
import { MissAVDownloader } from "./downloaders/MissAVDownloader";
import { YtDlpDownloader } from "./downloaders/YtDlpDownloader";
@@ -14,12 +15,12 @@ import { Video } from "./storageService";
// Re-export types for compatibility
export type {
BilibiliCollectionCheckResult,
BilibiliPartsCheckResult,
BilibiliVideoInfo,
BilibiliVideosResult,
CollectionDownloadResult,
DownloadResult
BilibiliCollectionCheckResult,
BilibiliPartsCheckResult,
BilibiliVideoInfo,
BilibiliVideosResult,
CollectionDownloadResult,
DownloadResult
};
// Helper function to download Bilibili video
@@ -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) {

View 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);
}
}

View File

@@ -5,24 +5,25 @@ import { IMAGES_DIR, SUBTITLES_DIR, VIDEOS_DIR } from "../../config/paths";
import { DownloadCancelledError } from "../../errors/DownloadErrors";
import { bccToVtt } from "../../utils/bccToVtt";
import {
calculateDownloadedSize,
formatBytes,
isCancellationError,
isDownloadActive,
parseSize,
calculateDownloadedSize,
formatBytes,
isCancellationError,
isDownloadActive,
parseSize,
} from "../../utils/downloadUtils";
import {
extractBilibiliVideoId,
formatVideoFilename,
extractBilibiliVideoId,
formatVideoFilename,
} from "../../utils/helpers";
import {
executeYtDlpJson,
executeYtDlpSpawn,
getNetworkConfigFromUserConfig,
getUserYtDlpConfig,
executeYtDlpJson,
executeYtDlpSpawn,
getNetworkConfigFromUserConfig,
getUserYtDlpConfig,
} 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,

View File

@@ -7,25 +7,26 @@ import puppeteer from "puppeteer";
import { DATA_DIR, IMAGES_DIR, VIDEOS_DIR } from "../../config/paths";
import { DownloadCancelledError } from "../../errors/DownloadErrors";
import {
calculateDownloadedSize,
cleanupTemporaryFiles,
isCancellationError,
isDownloadActive,
calculateDownloadedSize,
cleanupTemporaryFiles,
isCancellationError,
isDownloadActive,
} from "../../utils/downloadUtils";
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,

View File

@@ -4,22 +4,23 @@ import path from "path";
import { IMAGES_DIR, SUBTITLES_DIR, VIDEOS_DIR } from "../../config/paths";
import { DownloadCancelledError } from "../../errors/DownloadErrors";
import {
calculateDownloadedSize,
cleanupPartialVideoFiles,
cleanupSubtitleFiles,
isCancellationError,
isDownloadActive,
parseSize,
calculateDownloadedSize,
cleanupPartialVideoFiles,
cleanupSubtitleFiles,
isCancellationError,
isDownloadActive,
parseSize,
} from "../../utils/downloadUtils";
import { formatVideoFilename } from "../../utils/helpers";
import {
executeYtDlpJson,
executeYtDlpSpawn,
getNetworkConfigFromUserConfig,
getUserYtDlpConfig,
executeYtDlpJson,
executeYtDlpSpawn,
getNetworkConfigFromUserConfig,
getUserYtDlpConfig,
} 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,