增加离线下载功能
This commit is contained in:
@@ -6,6 +6,7 @@ export function getAuthInfoFromCookie(request: NextRequest): {
|
||||
username?: string;
|
||||
signature?: string;
|
||||
timestamp?: number;
|
||||
role?: 'owner' | 'admin' | 'user';
|
||||
} | null {
|
||||
const authCookie = request.cookies.get('auth');
|
||||
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
/**
|
||||
* 服务器端离线下载器
|
||||
* 用于在服务器端下载 M3U8 视频到本地文件系统
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as https from 'https';
|
||||
import * as http from 'http';
|
||||
import * as zlib from 'zlib';
|
||||
import { URL } from 'url';
|
||||
|
||||
export interface OfflineDownloadTask {
|
||||
id: string;
|
||||
source: string;
|
||||
videoId: string;
|
||||
episodeIndex: number;
|
||||
title: string;
|
||||
m3u8Url: string;
|
||||
status: 'pending' | 'downloading' | 'completed' | 'error' | 'paused';
|
||||
progress: number;
|
||||
totalSegments: number;
|
||||
downloadedSegments: number;
|
||||
errorMessage?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
downloadDir: string;
|
||||
// 视频元数据
|
||||
metadata?: {
|
||||
videoTitle?: string; // 视频总标题(如:某某动漫)
|
||||
cover?: string; // 封面图片URL
|
||||
description?: string; // 视频描述
|
||||
year?: string; // 年份
|
||||
rating?: number; // 评分
|
||||
totalEpisodes?: number; // 总集数
|
||||
};
|
||||
}
|
||||
|
||||
interface SegmentInfo {
|
||||
url: string;
|
||||
filename: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface KeyInfo {
|
||||
method: string;
|
||||
uri: string;
|
||||
iv?: string;
|
||||
}
|
||||
|
||||
export class OfflineDownloader {
|
||||
private baseDir: string;
|
||||
private maxRetries = 3;
|
||||
private retryDelay = 1000; // ms
|
||||
private concurrency = 6;
|
||||
|
||||
constructor(baseDir: string) {
|
||||
this.baseDir = baseDir;
|
||||
this.ensureDir(this.baseDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建下载任务
|
||||
*/
|
||||
async createTask(
|
||||
source: string,
|
||||
videoId: string,
|
||||
episodeIndex: number,
|
||||
title: string,
|
||||
m3u8Url: string,
|
||||
metadata?: {
|
||||
videoTitle?: string;
|
||||
cover?: string;
|
||||
description?: string;
|
||||
year?: string;
|
||||
rating?: number;
|
||||
totalEpisodes?: number;
|
||||
}
|
||||
): Promise<OfflineDownloadTask> {
|
||||
const taskId = `${source}_${videoId}_${episodeIndex}_${Date.now()}`;
|
||||
const downloadDir = path.join(this.baseDir, source, videoId, `ep${episodeIndex + 1}`);
|
||||
|
||||
const task: OfflineDownloadTask = {
|
||||
id: taskId,
|
||||
source,
|
||||
videoId,
|
||||
episodeIndex,
|
||||
title,
|
||||
m3u8Url,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
totalSegments: 0,
|
||||
downloadedSegments: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
downloadDir,
|
||||
metadata,
|
||||
};
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始下载任务
|
||||
*/
|
||||
async startDownload(
|
||||
task: OfflineDownloadTask,
|
||||
onProgress?: (task: OfflineDownloadTask) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
task.status = 'downloading';
|
||||
task.updatedAt = new Date();
|
||||
onProgress?.(task);
|
||||
|
||||
// 确保下载目录存在
|
||||
this.ensureDir(task.downloadDir);
|
||||
|
||||
// 检查是否已经下载过(避免重复下载)
|
||||
const playlistPath = path.join(task.downloadDir, 'playlist.m3u8');
|
||||
if (fs.existsSync(playlistPath)) {
|
||||
// 检查所有文件是否完整
|
||||
const isComplete = await this.verifyDownload(task.downloadDir);
|
||||
if (isComplete) {
|
||||
task.status = 'completed';
|
||||
task.progress = 100;
|
||||
task.updatedAt = new Date();
|
||||
onProgress?.(task);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 下载 M3U8 文件
|
||||
let m3u8Content = await this.fetchContent(task.m3u8Url);
|
||||
let finalM3u8Url = task.m3u8Url;
|
||||
|
||||
// 检查是否为主播放列表(包含多个分辨率)
|
||||
if (this.isMasterPlaylist(m3u8Content)) {
|
||||
console.log('检测到主播放列表,正在选择最高分辨率...');
|
||||
|
||||
// 解析主播放列表,获取最高分辨率的子播放列表URL
|
||||
const bestVariantUrl = this.selectBestVariant(m3u8Content, task.m3u8Url);
|
||||
|
||||
if (bestVariantUrl) {
|
||||
console.log('已选择最高分辨率流:', bestVariantUrl);
|
||||
finalM3u8Url = bestVariantUrl;
|
||||
|
||||
// 下载子播放列表
|
||||
m3u8Content = await this.fetchContent(bestVariantUrl);
|
||||
} else {
|
||||
console.warn('无法找到子播放列表,使用原始URL');
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 M3U8 文件
|
||||
const { segments, keyInfo } = this.parseM3U8(m3u8Content, finalM3u8Url);
|
||||
|
||||
task.totalSegments = segments.length;
|
||||
onProgress?.(task);
|
||||
|
||||
// 下载解密 Key(如果有)
|
||||
if (keyInfo && keyInfo.uri) {
|
||||
await this.downloadKey(keyInfo, task.downloadDir);
|
||||
}
|
||||
|
||||
// 下载所有片段
|
||||
await this.downloadSegments(segments, task, onProgress);
|
||||
|
||||
// 生成本地播放列表,保持原始格式
|
||||
this.generateLocalPlaylist(m3u8Content, segments, keyInfo, task.downloadDir);
|
||||
|
||||
task.status = 'completed';
|
||||
task.progress = 100;
|
||||
task.updatedAt = new Date();
|
||||
onProgress?.(task);
|
||||
} catch (error) {
|
||||
task.status = 'error';
|
||||
task.errorMessage = error instanceof Error ? error.message : String(error);
|
||||
task.updatedAt = new Date();
|
||||
onProgress?.(task);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为主播放列表(Master Playlist)
|
||||
*/
|
||||
private isMasterPlaylist(content: string): boolean {
|
||||
return content.includes('#EXT-X-STREAM-INF:');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从主播放列表中选择最高分辨率的变体
|
||||
*/
|
||||
private selectBestVariant(content: string, baseUrl: string): string | null {
|
||||
const lines = content.split('\n').map((line) => line.trim());
|
||||
|
||||
interface Variant {
|
||||
url: string;
|
||||
bandwidth: number;
|
||||
resolution?: { width: number; height: number };
|
||||
}
|
||||
|
||||
const variants: Variant[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('#EXT-X-STREAM-INF:')) {
|
||||
// 提取带宽信息
|
||||
const bandwidthMatch = line.match(/BANDWIDTH=(\d+)/);
|
||||
const bandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0;
|
||||
|
||||
// 提取分辨率信息
|
||||
const resolutionMatch = line.match(/RESOLUTION=(\d+)x(\d+)/);
|
||||
const resolution = resolutionMatch
|
||||
? { width: parseInt(resolutionMatch[1], 10), height: parseInt(resolutionMatch[2], 10) }
|
||||
: undefined;
|
||||
|
||||
// 下一行应该是子播放列表的URL
|
||||
if (i + 1 < lines.length) {
|
||||
const nextLine = lines[i + 1];
|
||||
if (nextLine && !nextLine.startsWith('#')) {
|
||||
const variantUrl = this.resolveUrl(nextLine, baseUrl);
|
||||
variants.push({ url: variantUrl, bandwidth, resolution });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (variants.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 优先按分辨率排序(宽度 * 高度),如果没有分辨率信息则按带宽排序
|
||||
variants.sort((a, b) => {
|
||||
// 如果两者都有分辨率信息,按分辨率排序
|
||||
if (a.resolution && b.resolution) {
|
||||
const aPixels = a.resolution.width * a.resolution.height;
|
||||
const bPixels = b.resolution.width * b.resolution.height;
|
||||
if (aPixels !== bPixels) {
|
||||
return bPixels - aPixels; // 降序
|
||||
}
|
||||
}
|
||||
|
||||
// 如果只有一个有分辨率信息,优先选择有分辨率的
|
||||
if (a.resolution && !b.resolution) return -1;
|
||||
if (!a.resolution && b.resolution) return 1;
|
||||
|
||||
// 都没有分辨率信息,或分辨率相同,则按带宽排序
|
||||
return b.bandwidth - a.bandwidth; // 降序
|
||||
});
|
||||
|
||||
console.log('可用的流变体:', variants.map(v => ({
|
||||
url: v.url,
|
||||
bandwidth: v.bandwidth,
|
||||
resolution: v.resolution ? `${v.resolution.width}x${v.resolution.height}` : '未知',
|
||||
})));
|
||||
|
||||
return variants[0].url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 M3U8 文件
|
||||
*/
|
||||
private parseM3U8(
|
||||
content: string,
|
||||
baseUrl: string
|
||||
): { segments: SegmentInfo[]; keyInfo: KeyInfo | null } {
|
||||
const lines = content.split('\n').map((line) => line.trim());
|
||||
const segments: SegmentInfo[] = [];
|
||||
let keyInfo: KeyInfo | null = null;
|
||||
let currentDuration = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// 解析时长
|
||||
if (line.startsWith('#EXTINF:')) {
|
||||
const match = line.match(/#EXTINF:([\d.]+)/);
|
||||
if (match) {
|
||||
currentDuration = parseFloat(match[1]);
|
||||
}
|
||||
}
|
||||
// 解析加密信息
|
||||
else if (line.startsWith('#EXT-X-KEY:')) {
|
||||
const methodMatch = line.match(/METHOD=([^,]+)/);
|
||||
const uriMatch = line.match(/URI="([^"]+)"/);
|
||||
const ivMatch = line.match(/IV=([^,\s]+)/);
|
||||
|
||||
if (methodMatch && methodMatch[1] !== 'NONE') {
|
||||
keyInfo = {
|
||||
method: methodMatch[1],
|
||||
uri: uriMatch ? this.resolveUrl(uriMatch[1], baseUrl) : '',
|
||||
iv: ivMatch ? ivMatch[1] : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
// 解析片段 URL
|
||||
else if (line && !line.startsWith('#')) {
|
||||
const segmentUrl = this.resolveUrl(line, baseUrl);
|
||||
const filename = `segment_${segments.length.toString().padStart(5, '0')}.ts`;
|
||||
|
||||
segments.push({
|
||||
url: segmentUrl,
|
||||
filename,
|
||||
duration: currentDuration,
|
||||
});
|
||||
|
||||
currentDuration = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { segments, keyInfo };
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载所有片段(带重试和并发控制)
|
||||
*/
|
||||
private async downloadSegments(
|
||||
segments: SegmentInfo[],
|
||||
task: OfflineDownloadTask,
|
||||
onProgress?: (task: OfflineDownloadTask) => void
|
||||
): Promise<void> {
|
||||
const queue = [...segments];
|
||||
const downloading: Promise<void>[] = [];
|
||||
let downloadedCount = 0;
|
||||
|
||||
const downloadNext = async (): Promise<void> => {
|
||||
if (queue.length === 0) return;
|
||||
|
||||
const segment = queue.shift()!;
|
||||
const segmentPath = path.join(task.downloadDir, segment.filename);
|
||||
|
||||
// 如果文件已存在且大小 > 0,跳过下载
|
||||
if (fs.existsSync(segmentPath) && fs.statSync(segmentPath).size > 0) {
|
||||
downloadedCount++;
|
||||
task.downloadedSegments = downloadedCount;
|
||||
task.progress = Math.round((downloadedCount / task.totalSegments) * 100);
|
||||
task.updatedAt = new Date();
|
||||
onProgress?.(task);
|
||||
return downloadNext();
|
||||
}
|
||||
|
||||
// 下载片段(带重试)
|
||||
await this.downloadWithRetry(segment.url, segmentPath);
|
||||
|
||||
downloadedCount++;
|
||||
task.downloadedSegments = downloadedCount;
|
||||
task.progress = Math.round((downloadedCount / task.totalSegments) * 100);
|
||||
task.updatedAt = new Date();
|
||||
onProgress?.(task);
|
||||
|
||||
return downloadNext();
|
||||
};
|
||||
|
||||
// 并发下载
|
||||
for (let i = 0; i < Math.min(this.concurrency, segments.length); i++) {
|
||||
downloading.push(downloadNext());
|
||||
}
|
||||
|
||||
await Promise.all(downloading);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载解密 Key
|
||||
*/
|
||||
private async downloadKey(keyInfo: KeyInfo, downloadDir: string): Promise<void> {
|
||||
if (!keyInfo.uri) return;
|
||||
|
||||
const keyPath = path.join(downloadDir, 'key.key');
|
||||
|
||||
// 如果 key 已存在,跳过下载
|
||||
if (fs.existsSync(keyPath) && fs.statSync(keyPath).size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.downloadWithRetry(keyInfo.uri, keyPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件(带重试)
|
||||
*/
|
||||
private async downloadWithRetry(url: string, savePath: string): Promise<void> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
||||
try {
|
||||
await this.downloadFile(url, savePath);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
console.error(`下载失败 (尝试 ${attempt + 1}/${this.maxRetries}): ${url}`, error);
|
||||
|
||||
if (attempt < this.maxRetries - 1) {
|
||||
await this.sleep(this.retryDelay * (attempt + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`下载失败(已重试 ${this.maxRetries} 次): ${url}\n${lastError?.message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标准浏览器请求头
|
||||
*/
|
||||
private getHeaders(url: string): Record<string, string> {
|
||||
const urlObj = new URL(url);
|
||||
return {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Origin': `${urlObj.protocol}//${urlObj.host}`,
|
||||
'Referer': `${urlObj.protocol}//${urlObj.host}/`,
|
||||
'Connection': 'keep-alive',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载单个文件
|
||||
*/
|
||||
private async downloadFile(url: string, savePath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const client = urlObj.protocol === 'https:' ? https : http;
|
||||
|
||||
const options = {
|
||||
headers: this.getHeaders(url),
|
||||
};
|
||||
|
||||
const request = client.get(url, options, (response) => {
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
// 处理重定向
|
||||
const redirectUrl = response.headers.location;
|
||||
if (redirectUrl) {
|
||||
this.downloadFile(redirectUrl, savePath).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查内容编码,处理压缩
|
||||
const encoding = response.headers['content-encoding'];
|
||||
let stream: NodeJS.ReadableStream = response;
|
||||
|
||||
if (encoding === 'gzip') {
|
||||
stream = response.pipe(zlib.createGunzip());
|
||||
} else if (encoding === 'deflate') {
|
||||
stream = response.pipe(zlib.createInflate());
|
||||
} else if (encoding === 'br') {
|
||||
stream = response.pipe(zlib.createBrotliDecompress());
|
||||
}
|
||||
|
||||
const fileStream = fs.createWriteStream(savePath);
|
||||
stream.pipe(fileStream);
|
||||
|
||||
fileStream.on('finish', () => {
|
||||
fileStream.close();
|
||||
resolve();
|
||||
});
|
||||
|
||||
fileStream.on('error', (err) => {
|
||||
fs.unlink(savePath, () => {});
|
||||
reject(err);
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
fs.unlink(savePath, () => {});
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
request.setTimeout(30000, () => {
|
||||
request.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内容(用于 M3U8 文件)
|
||||
*/
|
||||
private async fetchContent(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const client = urlObj.protocol === 'https:' ? https : http;
|
||||
|
||||
const options = {
|
||||
headers: this.getHeaders(url),
|
||||
};
|
||||
|
||||
const request = client.get(url, options, (response) => {
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
const redirectUrl = response.headers.location;
|
||||
if (redirectUrl) {
|
||||
this.fetchContent(redirectUrl).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查内容编码,处理压缩
|
||||
const encoding = response.headers['content-encoding'];
|
||||
let stream: NodeJS.ReadableStream = response;
|
||||
|
||||
if (encoding === 'gzip') {
|
||||
stream = response.pipe(zlib.createGunzip());
|
||||
} else if (encoding === 'deflate') {
|
||||
stream = response.pipe(zlib.createInflate());
|
||||
} else if (encoding === 'br') {
|
||||
stream = response.pipe(zlib.createBrotliDecompress());
|
||||
}
|
||||
|
||||
let data = '';
|
||||
stream.on('data', (chunk) => {
|
||||
data += chunk.toString('utf-8');
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
resolve(data);
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
request.setTimeout(10000, () => {
|
||||
request.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成本地播放列表(保持原始格式,只替换片段URL)
|
||||
*/
|
||||
private generateLocalPlaylist(
|
||||
originalM3u8Content: string,
|
||||
segments: SegmentInfo[],
|
||||
keyInfo: KeyInfo | null,
|
||||
downloadDir: string
|
||||
): void {
|
||||
const lines = originalM3u8Content.split('\n');
|
||||
const modifiedLines: string[] = [];
|
||||
let segmentIndex = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// 替换 Key URI
|
||||
if (trimmedLine.startsWith('#EXT-X-KEY:')) {
|
||||
if (keyInfo && keyInfo.method !== 'NONE') {
|
||||
// 替换 URI 为本地 key.key
|
||||
const modifiedLine = line.replace(/URI="[^"]+"/g, 'URI="key.key"');
|
||||
modifiedLines.push(modifiedLine);
|
||||
} else {
|
||||
modifiedLines.push(line);
|
||||
}
|
||||
}
|
||||
// 替换视频片段 URL
|
||||
else if (trimmedLine && !trimmedLine.startsWith('#')) {
|
||||
// 这是一个视频片段,替换为本地文件名
|
||||
if (segmentIndex < segments.length) {
|
||||
const indent = line.match(/^\s*/)?.[0] || '';
|
||||
modifiedLines.push(indent + segments[segmentIndex].filename);
|
||||
segmentIndex++;
|
||||
} else {
|
||||
// 如果超出了片段数量,保留原始行(不应该发生)
|
||||
modifiedLines.push(line);
|
||||
}
|
||||
}
|
||||
// 保持其他所有行不变
|
||||
else {
|
||||
modifiedLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
const playlistPath = path.join(downloadDir, 'playlist.m3u8');
|
||||
fs.writeFileSync(playlistPath, modifiedLines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证下载是否完整
|
||||
*/
|
||||
private async verifyDownload(downloadDir: string): Promise<boolean> {
|
||||
try {
|
||||
const playlistPath = path.join(downloadDir, 'playlist.m3u8');
|
||||
if (!fs.existsSync(playlistPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(playlistPath, 'utf-8');
|
||||
const lines = content.split('\n').map((line) => line.trim());
|
||||
|
||||
// 检查所有 ts 文件是否存在
|
||||
for (const line of lines) {
|
||||
if (line && !line.startsWith('#')) {
|
||||
const segmentPath = path.join(downloadDir, line);
|
||||
if (!fs.existsSync(segmentPath) || fs.statSync(segmentPath).size === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析相对 URL
|
||||
*/
|
||||
private resolveUrl(targetUrl: string, baseUrl: string): string {
|
||||
if (targetUrl.startsWith('http://') || targetUrl.startsWith('https://')) {
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const base = new URL(baseUrl);
|
||||
if (targetUrl.startsWith('/')) {
|
||||
return `${base.protocol}//${base.host}${targetUrl}`;
|
||||
} else {
|
||||
const basePath = base.pathname.substring(0, base.pathname.lastIndexOf('/') + 1);
|
||||
return `${base.protocol}//${base.host}${basePath}${targetUrl}`;
|
||||
}
|
||||
} catch {
|
||||
return targetUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
private ensureDir(dir: string): void {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟函数
|
||||
*/
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除下载任务的所有文件
|
||||
*/
|
||||
async deleteTask(task: OfflineDownloadTask): Promise<void> {
|
||||
if (fs.existsSync(task.downloadDir)) {
|
||||
fs.rmSync(task.downloadDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查视频是否已下载
|
||||
*/
|
||||
checkDownloaded(source: string, videoId: string, episodeIndex: number): boolean {
|
||||
const downloadDir = path.join(this.baseDir, source, videoId, `ep${episodeIndex + 1}`);
|
||||
const playlistPath = path.join(downloadDir, 'playlist.m3u8');
|
||||
return fs.existsSync(playlistPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地播放列表路径
|
||||
*/
|
||||
getLocalPlaylistPath(source: string, videoId: string, episodeIndex: number): string | null {
|
||||
const downloadDir = path.join(this.baseDir, source, videoId, `ep${episodeIndex + 1}`);
|
||||
const playlistPath = path.join(downloadDir, 'playlist.m3u8');
|
||||
return fs.existsSync(playlistPath) ? playlistPath : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user