refactor: Improve code readability and maintainability
This commit is contained in:
@@ -82,12 +82,15 @@ export const checkVideoDownloadStatus = async (
|
||||
}
|
||||
|
||||
// Check if video was previously downloaded
|
||||
const downloadCheck = storageService.checkVideoDownloadBySourceId(sourceVideoId);
|
||||
const downloadCheck =
|
||||
storageService.checkVideoDownloadBySourceId(sourceVideoId);
|
||||
|
||||
if (downloadCheck.found) {
|
||||
// If status is "exists", verify the video still exists in the database
|
||||
if (downloadCheck.status === "exists" && downloadCheck.videoId) {
|
||||
const existingVideo = storageService.getVideoById(downloadCheck.videoId);
|
||||
const existingVideo = storageService.getVideoById(
|
||||
downloadCheck.videoId
|
||||
);
|
||||
if (!existingVideo) {
|
||||
// Video was deleted but not marked in download history, update it
|
||||
storageService.markVideoDownloadDeleted(downloadCheck.videoId);
|
||||
@@ -180,12 +183,15 @@ export const downloadVideo = async (
|
||||
|
||||
// Check if video was previously downloaded (skip for collections/multi-part)
|
||||
if (sourceVideoId && !downloadAllParts && !downloadCollection) {
|
||||
const downloadCheck = storageService.checkVideoDownloadBySourceId(sourceVideoId);
|
||||
const downloadCheck =
|
||||
storageService.checkVideoDownloadBySourceId(sourceVideoId);
|
||||
|
||||
if (downloadCheck.found) {
|
||||
if (downloadCheck.status === "exists" && downloadCheck.videoId) {
|
||||
// Verify the video still exists
|
||||
const existingVideo = storageService.getVideoById(downloadCheck.videoId);
|
||||
const existingVideo = storageService.getVideoById(
|
||||
downloadCheck.videoId
|
||||
);
|
||||
if (existingVideo) {
|
||||
// Video exists, add to download history as "skipped" and return success
|
||||
storageService.addDownloadHistoryItem({
|
||||
@@ -235,7 +241,8 @@ export const downloadVideo = async (
|
||||
author: downloadCheck.author,
|
||||
downloadedAt: downloadCheck.downloadedAt,
|
||||
deletedAt: downloadCheck.deletedAt,
|
||||
message: "Video was previously downloaded but deleted, skipped download",
|
||||
message:
|
||||
"Video was previously downloaded but deleted, skipped download",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -351,7 +358,8 @@ export const downloadVideo = async (
|
||||
1,
|
||||
videosNumber,
|
||||
title || "Bilibili Video",
|
||||
downloadId
|
||||
downloadId,
|
||||
registerCancel
|
||||
);
|
||||
|
||||
// Add to collection if needed
|
||||
@@ -394,7 +402,8 @@ export const downloadVideo = async (
|
||||
1,
|
||||
1,
|
||||
"", // seriesTitle not used when totalParts is 1
|
||||
downloadId
|
||||
downloadId,
|
||||
registerCancel
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
BilibiliVideoInfo,
|
||||
BilibiliVideosResult,
|
||||
CollectionDownloadResult,
|
||||
DownloadResult
|
||||
DownloadResult,
|
||||
} from "./downloaders/BilibiliDownloader";
|
||||
import { MissAVDownloader } from "./downloaders/MissAVDownloader";
|
||||
import { YtDlpDownloader } from "./downloaders/YtDlpDownloader";
|
||||
@@ -14,7 +14,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
|
||||
@@ -22,28 +27,45 @@ export async function downloadBilibiliVideo(
|
||||
url: string,
|
||||
videoPath: string,
|
||||
thumbnailPath: string,
|
||||
downloadId?: string
|
||||
downloadId?: string,
|
||||
onStart?: (cancel: () => void) => void
|
||||
): Promise<BilibiliVideoInfo> {
|
||||
return BilibiliDownloader.downloadVideo(url, videoPath, thumbnailPath, downloadId);
|
||||
return BilibiliDownloader.downloadVideo(
|
||||
url,
|
||||
videoPath,
|
||||
thumbnailPath,
|
||||
downloadId,
|
||||
onStart
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to check if a Bilibili video has multiple parts
|
||||
export async function checkBilibiliVideoParts(videoId: string): Promise<BilibiliPartsCheckResult> {
|
||||
export async function checkBilibiliVideoParts(
|
||||
videoId: string
|
||||
): Promise<BilibiliPartsCheckResult> {
|
||||
return BilibiliDownloader.checkVideoParts(videoId);
|
||||
}
|
||||
|
||||
// Helper function to check if a Bilibili video belongs to a collection or series
|
||||
export async function checkBilibiliCollectionOrSeries(videoId: string): Promise<BilibiliCollectionCheckResult> {
|
||||
export async function checkBilibiliCollectionOrSeries(
|
||||
videoId: string
|
||||
): Promise<BilibiliCollectionCheckResult> {
|
||||
return BilibiliDownloader.checkCollectionOrSeries(videoId);
|
||||
}
|
||||
|
||||
// Helper function to get all videos from a Bilibili collection
|
||||
export async function getBilibiliCollectionVideos(mid: number, seasonId: number): Promise<BilibiliVideosResult> {
|
||||
export async function getBilibiliCollectionVideos(
|
||||
mid: number,
|
||||
seasonId: number
|
||||
): Promise<BilibiliVideosResult> {
|
||||
return BilibiliDownloader.getCollectionVideos(mid, seasonId);
|
||||
}
|
||||
|
||||
// Helper function to get all videos from a Bilibili series
|
||||
export async function getBilibiliSeriesVideos(mid: number, seriesId: number): Promise<BilibiliVideosResult> {
|
||||
export async function getBilibiliSeriesVideos(
|
||||
mid: number,
|
||||
seriesId: number
|
||||
): Promise<BilibiliVideosResult> {
|
||||
return BilibiliDownloader.getSeriesVideos(mid, seriesId);
|
||||
}
|
||||
|
||||
@@ -53,9 +75,17 @@ export async function downloadSingleBilibiliPart(
|
||||
partNumber: number,
|
||||
totalParts: number,
|
||||
seriesTitle: string,
|
||||
downloadId?: string
|
||||
downloadId?: string,
|
||||
onStart?: (cancel: () => void) => void
|
||||
): Promise<DownloadResult> {
|
||||
return BilibiliDownloader.downloadSinglePart(url, partNumber, totalParts, seriesTitle, downloadId);
|
||||
return BilibiliDownloader.downloadSinglePart(
|
||||
url,
|
||||
partNumber,
|
||||
totalParts,
|
||||
seriesTitle,
|
||||
downloadId,
|
||||
onStart
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to download all videos from a Bilibili collection or series
|
||||
@@ -64,7 +94,11 @@ export async function downloadBilibiliCollection(
|
||||
collectionName: string,
|
||||
downloadId: string
|
||||
): Promise<CollectionDownloadResult> {
|
||||
return BilibiliDownloader.downloadCollection(collectionInfo, collectionName, downloadId);
|
||||
return BilibiliDownloader.downloadCollection(
|
||||
collectionInfo,
|
||||
collectionName,
|
||||
downloadId
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to download remaining Bilibili parts in sequence
|
||||
@@ -76,7 +110,14 @@ export async function downloadRemainingBilibiliParts(
|
||||
collectionId: string,
|
||||
downloadId: string
|
||||
): Promise<void> {
|
||||
return BilibiliDownloader.downloadRemainingParts(baseUrl, startPart, totalParts, seriesTitle, collectionId, downloadId);
|
||||
return BilibiliDownloader.downloadRemainingParts(
|
||||
baseUrl,
|
||||
startPart,
|
||||
totalParts,
|
||||
seriesTitle,
|
||||
collectionId,
|
||||
downloadId
|
||||
);
|
||||
}
|
||||
|
||||
// Search for videos on YouTube (using yt-dlp)
|
||||
@@ -85,17 +126,32 @@ export async function searchYouTube(query: string): Promise<any[]> {
|
||||
}
|
||||
|
||||
// Download generic video (using yt-dlp)
|
||||
export async function downloadYouTubeVideo(videoUrl: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
|
||||
export async function downloadYouTubeVideo(
|
||||
videoUrl: string,
|
||||
downloadId?: string,
|
||||
onStart?: (cancel: () => void) => void
|
||||
): Promise<Video> {
|
||||
return YtDlpDownloader.downloadVideo(videoUrl, downloadId, onStart);
|
||||
}
|
||||
|
||||
// Helper function to download MissAV video
|
||||
export async function downloadMissAVVideo(url: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
|
||||
export async function downloadMissAVVideo(
|
||||
url: string,
|
||||
downloadId?: string,
|
||||
onStart?: (cancel: () => void) => void
|
||||
): Promise<Video> {
|
||||
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 }> {
|
||||
export async function getVideoInfo(
|
||||
url: string
|
||||
): Promise<{
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
thumbnailUrl: string;
|
||||
}> {
|
||||
if (isBilibiliUrl(url)) {
|
||||
const videoId = extractBilibiliVideoId(url);
|
||||
if (videoId) {
|
||||
@@ -104,7 +160,7 @@ export async function getVideoInfo(url: string): Promise<{ title: string; author
|
||||
} else if (url.includes("missav") || url.includes("123av")) {
|
||||
return MissAVDownloader.getVideoInfo(url);
|
||||
}
|
||||
|
||||
|
||||
// Default fallback to yt-dlp for everything else
|
||||
return YtDlpDownloader.getVideoInfo(url);
|
||||
}
|
||||
@@ -116,12 +172,19 @@ export function createDownloadTask(
|
||||
downloadId: string
|
||||
): (registerCancel: (cancel: () => void) => void) => Promise<any> {
|
||||
return async (registerCancel: (cancel: () => void) => void) => {
|
||||
if (type === 'missav') {
|
||||
if (type === "missav") {
|
||||
return MissAVDownloader.downloadVideo(url, downloadId, registerCancel);
|
||||
} else if (type === 'bilibili') {
|
||||
} else if (type === "bilibili") {
|
||||
// For restored tasks, we assume single video download for now
|
||||
// Complex collection handling would require persisting more state
|
||||
return BilibiliDownloader.downloadSinglePart(url, 1, 1, "");
|
||||
return BilibiliDownloader.downloadSinglePart(
|
||||
url,
|
||||
1,
|
||||
1,
|
||||
"",
|
||||
downloadId,
|
||||
registerCancel
|
||||
);
|
||||
} else {
|
||||
// Default to yt-dlp
|
||||
return YtDlpDownloader.downloadVideo(url, downloadId, registerCancel);
|
||||
|
||||
@@ -3,6 +3,11 @@ import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { IMAGES_DIR, SUBTITLES_DIR, VIDEOS_DIR } from "../../config/paths";
|
||||
import { bccToVtt } from "../../utils/bccToVtt";
|
||||
import {
|
||||
calculateDownloadedSize,
|
||||
formatBytes,
|
||||
parseSize,
|
||||
} from "../../utils/downloadUtils";
|
||||
import {
|
||||
extractBilibiliVideoId,
|
||||
formatVideoFilename,
|
||||
@@ -114,21 +119,13 @@ export class BilibiliDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to format bytes
|
||||
private static formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
||||
}
|
||||
|
||||
// Helper function to download Bilibili video
|
||||
static async downloadVideo(
|
||||
url: string,
|
||||
videoPath: string,
|
||||
thumbnailPath: string,
|
||||
downloadId?: string
|
||||
downloadId?: string,
|
||||
onStart?: (cancel: () => void) => void
|
||||
): Promise<BilibiliVideoInfo> {
|
||||
const tempDir = path.join(
|
||||
VIDEOS_DIR,
|
||||
@@ -174,23 +171,81 @@ export class BilibiliDownloader {
|
||||
// Use spawn to capture stdout for progress
|
||||
const subprocess = executeYtDlpSpawn(url, flags);
|
||||
|
||||
// Register cancel function if provided
|
||||
if (onStart) {
|
||||
onStart(() => {
|
||||
console.log("Killing subprocess for download:", downloadId);
|
||||
subprocess.kill();
|
||||
|
||||
// Clean up partial files
|
||||
console.log("Cleaning up partial files...");
|
||||
try {
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.removeSync(tempDir);
|
||||
console.log("Deleted temp directory:", tempDir);
|
||||
}
|
||||
if (fs.existsSync(videoPath)) {
|
||||
fs.unlinkSync(videoPath);
|
||||
console.log("Deleted partial video file:", videoPath);
|
||||
}
|
||||
if (fs.existsSync(thumbnailPath)) {
|
||||
fs.unlinkSync(thumbnailPath);
|
||||
console.log("Deleted partial thumbnail file:", thumbnailPath);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error("Error cleaning up partial files:", cleanupError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track progress from stdout
|
||||
if (downloadId) {
|
||||
subprocess.stdout?.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
// Parse progress: [download] 23.5% of 10.00MiB at 2.00MiB/s ETA 00:05
|
||||
// Also try to match: [download] 55.8MiB of 123.45MiB at 5.67MiB/s ETA 00:12
|
||||
const progressMatch = output.match(
|
||||
/(\d+\.?\d*)%\s+of\s+([~\d\w.]+)\s+at\s+([~\d\w.\/]+)/
|
||||
);
|
||||
|
||||
// Try to match format with downloaded size explicitly shown
|
||||
const progressWithSizeMatch = output.match(
|
||||
/([~\d\w.]+)\s+of\s+([~\d\w.]+)\s+at\s+([~\d\w.\/]+)/
|
||||
);
|
||||
|
||||
if (progressMatch) {
|
||||
const percentage = parseFloat(progressMatch[1]);
|
||||
const totalSize = progressMatch[2];
|
||||
const speed = progressMatch[3];
|
||||
|
||||
// Calculate downloadedSize from percentage and totalSize
|
||||
const downloadedSize = calculateDownloadedSize(
|
||||
percentage,
|
||||
totalSize
|
||||
);
|
||||
|
||||
storageService.updateActiveDownload(downloadId, {
|
||||
progress: percentage,
|
||||
totalSize: totalSize,
|
||||
downloadedSize: downloadedSize,
|
||||
speed: speed,
|
||||
});
|
||||
} else if (progressWithSizeMatch) {
|
||||
// If we have explicit downloaded size in the output
|
||||
const downloadedSize = progressWithSizeMatch[1];
|
||||
const totalSize = progressWithSizeMatch[2];
|
||||
const speed = progressWithSizeMatch[3];
|
||||
|
||||
// Calculate percentage from downloaded and total sizes
|
||||
const downloadedBytes = parseSize(downloadedSize);
|
||||
const totalBytes = parseSize(totalSize);
|
||||
const percentage =
|
||||
totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0;
|
||||
|
||||
storageService.updateActiveDownload(downloadId, {
|
||||
progress: percentage,
|
||||
totalSize: totalSize,
|
||||
downloadedSize: downloadedSize,
|
||||
speed: speed,
|
||||
});
|
||||
}
|
||||
@@ -224,12 +279,42 @@ export class BilibiliDownloader {
|
||||
await subprocess;
|
||||
} catch (error: any) {
|
||||
downloadError = error;
|
||||
// Check if it was cancelled (killed process typically exits with code 143 or throws)
|
||||
if (
|
||||
error.code === 143 ||
|
||||
error.message?.includes("killed") ||
|
||||
error.message?.includes("SIGTERM") ||
|
||||
error.code === "SIGTERM"
|
||||
) {
|
||||
console.log("Download was cancelled");
|
||||
// Clean up temp directory
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.removeSync(tempDir);
|
||||
}
|
||||
throw new Error("Download cancelled by user");
|
||||
}
|
||||
console.error("yt-dlp download failed:", error.message);
|
||||
if (error.stderr) {
|
||||
console.error("yt-dlp stderr:", error.stderr);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if download was cancelled (it might have been removed from active downloads)
|
||||
if (downloadId) {
|
||||
const status = storageService.getDownloadStatus();
|
||||
const isStillActive = status.activeDownloads.some(
|
||||
(d) => d.id === downloadId
|
||||
);
|
||||
if (!isStillActive) {
|
||||
console.log("Download was cancelled (no longer in active downloads)");
|
||||
// Clean up temp directory
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.removeSync(tempDir);
|
||||
}
|
||||
throw new Error("Download cancelled by user");
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Download completed, checking for video file");
|
||||
|
||||
// Find the downloaded file (try multiple extensions)
|
||||
@@ -273,7 +358,7 @@ export class BilibiliDownloader {
|
||||
const tempVideoPath = path.join(tempDir, videoFile);
|
||||
if (downloadId && fs.existsSync(tempVideoPath)) {
|
||||
const stats = fs.statSync(tempVideoPath);
|
||||
const finalSize = this.formatBytes(stats.size);
|
||||
const finalSize = formatBytes(stats.size);
|
||||
storageService.updateActiveDownload(downloadId, {
|
||||
downloadedSize: finalSize,
|
||||
totalSize: finalSize,
|
||||
@@ -567,7 +652,8 @@ export class BilibiliDownloader {
|
||||
partNumber: number,
|
||||
totalParts: number,
|
||||
seriesTitle: string,
|
||||
downloadId?: string
|
||||
downloadId?: string,
|
||||
onStart?: (cancel: () => void) => void
|
||||
): Promise<DownloadResult> {
|
||||
try {
|
||||
console.log(
|
||||
@@ -596,12 +682,28 @@ export class BilibiliDownloader {
|
||||
let finalThumbnailFilename = thumbnailFilename;
|
||||
|
||||
// Download Bilibili video
|
||||
const bilibiliInfo = await BilibiliDownloader.downloadVideo(
|
||||
url,
|
||||
videoPath,
|
||||
thumbnailPath,
|
||||
downloadId
|
||||
);
|
||||
let bilibiliInfo: BilibiliVideoInfo;
|
||||
try {
|
||||
bilibiliInfo = await BilibiliDownloader.downloadVideo(
|
||||
url,
|
||||
videoPath,
|
||||
thumbnailPath,
|
||||
downloadId,
|
||||
onStart
|
||||
);
|
||||
} catch (error: any) {
|
||||
// If download was cancelled, re-throw immediately without downloading subtitles or creating video data
|
||||
if (
|
||||
error.message?.includes("Download cancelled by user") ||
|
||||
error.message?.includes("cancelled")
|
||||
) {
|
||||
console.log(
|
||||
"Download was cancelled, skipping subtitle download and video creation"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!bilibiliInfo) {
|
||||
throw new Error("Failed to get Bilibili video info");
|
||||
@@ -637,12 +739,36 @@ export class BilibiliDownloader {
|
||||
const newVideoPath = path.join(VIDEOS_DIR, newVideoFilename);
|
||||
const newThumbnailPath = path.join(IMAGES_DIR, newThumbnailFilename);
|
||||
|
||||
// Check if download was cancelled before processing files
|
||||
if (downloadId) {
|
||||
const status = storageService.getDownloadStatus();
|
||||
const isStillActive = status.activeDownloads.some(
|
||||
(d) => d.id === downloadId
|
||||
);
|
||||
if (!isStillActive) {
|
||||
console.log("Download was cancelled, skipping file processing");
|
||||
throw new Error("Download cancelled by user");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// Check again if download was cancelled (might have been cancelled during downloadVideo)
|
||||
if (downloadId) {
|
||||
const status = storageService.getDownloadStatus();
|
||||
const isStillActive = status.activeDownloads.some(
|
||||
(d) => d.id === downloadId
|
||||
);
|
||||
if (!isStillActive) {
|
||||
console.log("Download was cancelled, video file not created");
|
||||
throw new Error("Download cancelled by user");
|
||||
}
|
||||
}
|
||||
throw new Error("Video file not found after download");
|
||||
}
|
||||
|
||||
if (thumbnailSaved && fs.existsSync(thumbnailPath)) {
|
||||
@@ -676,6 +802,18 @@ export class BilibiliDownloader {
|
||||
console.error("Failed to get file size:", e);
|
||||
}
|
||||
|
||||
// Check if download was cancelled before downloading subtitles
|
||||
if (downloadId) {
|
||||
const status = storageService.getDownloadStatus();
|
||||
const isStillActive = status.activeDownloads.some(
|
||||
(d) => d.id === downloadId
|
||||
);
|
||||
if (!isStillActive) {
|
||||
console.log("Download was cancelled, skipping subtitle download");
|
||||
throw new Error("Download cancelled by user");
|
||||
}
|
||||
}
|
||||
|
||||
// Download subtitles
|
||||
let subtitles: Array<{
|
||||
language: string;
|
||||
@@ -690,9 +828,41 @@ export class BilibiliDownloader {
|
||||
);
|
||||
console.log(`Downloaded ${subtitles.length} subtitles`);
|
||||
} catch (e) {
|
||||
// If it's a cancellation error, re-throw it
|
||||
if (
|
||||
e instanceof Error &&
|
||||
e.message?.includes("Download cancelled by user")
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
console.error("Error downloading subtitles:", e);
|
||||
}
|
||||
|
||||
// Check if download was cancelled before creating video data
|
||||
if (downloadId) {
|
||||
const status = storageService.getDownloadStatus();
|
||||
const isStillActive = status.activeDownloads.some(
|
||||
(d) => d.id === downloadId
|
||||
);
|
||||
if (!isStillActive) {
|
||||
console.log("Download was cancelled, skipping video data creation");
|
||||
// Clean up any files that were created
|
||||
try {
|
||||
if (fs.existsSync(newVideoPath)) {
|
||||
fs.unlinkSync(newVideoPath);
|
||||
console.log("Deleted video file:", newVideoPath);
|
||||
}
|
||||
if (fs.existsSync(newThumbnailPath)) {
|
||||
fs.unlinkSync(newThumbnailPath);
|
||||
console.log("Deleted thumbnail file:", newThumbnailPath);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error("Error cleaning up files:", cleanupError);
|
||||
}
|
||||
throw new Error("Download cancelled by user");
|
||||
}
|
||||
}
|
||||
|
||||
// Create metadata for the video
|
||||
const videoData: Video = {
|
||||
id: timestamp.toString(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import puppeteer from "puppeteer";
|
||||
import { DATA_DIR, IMAGES_DIR, VIDEOS_DIR } from "../../config/paths";
|
||||
import { calculateDownloadedSize } from "../../utils/downloadUtils";
|
||||
import { formatVideoFilename } from "../../utils/helpers";
|
||||
import * as storageService from "../storageService";
|
||||
import { Video } from "../storageService";
|
||||
@@ -209,7 +210,11 @@ export class MissAVDownloader {
|
||||
}
|
||||
|
||||
// 5. Update the safe base filename with the actual title
|
||||
const newSafeBaseFilename = formatVideoFilename(videoTitle, videoAuthor, videoDate);
|
||||
const newSafeBaseFilename = formatVideoFilename(
|
||||
videoTitle,
|
||||
videoAuthor,
|
||||
videoDate
|
||||
);
|
||||
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
|
||||
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
|
||||
|
||||
@@ -245,7 +250,6 @@ export class MissAVDownloader {
|
||||
addHeader: [`Referer:${referer}`, `User-Agent:${userAgent}`],
|
||||
};
|
||||
|
||||
|
||||
// Parse progress from stdout and stderr
|
||||
const parseProgress = (output: string, source: "stdout" | "stderr") => {
|
||||
if (!downloadId) return;
|
||||
@@ -297,9 +301,16 @@ export class MissAVDownloader {
|
||||
output.includes(`of ~${totalSize}`);
|
||||
const formattedTotalSize = hasTilde ? `~${totalSize}` : totalSize;
|
||||
|
||||
// Calculate downloadedSize from percentage and totalSize
|
||||
const downloadedSize =
|
||||
totalSize !== "?"
|
||||
? calculateDownloadedSize(percentage, formattedTotalSize)
|
||||
: "0 B";
|
||||
|
||||
storageService.updateActiveDownload(downloadId, {
|
||||
progress: percentage,
|
||||
totalSize: formattedTotalSize,
|
||||
downloadedSize: downloadedSize,
|
||||
speed: speed,
|
||||
});
|
||||
}
|
||||
@@ -310,11 +321,16 @@ export class MissAVDownloader {
|
||||
// Convert flags object to array of args
|
||||
const args = [
|
||||
m3u8Url,
|
||||
"--output", newVideoPath,
|
||||
"--format", "best",
|
||||
"--merge-output-format", "mp4",
|
||||
"--add-header", `Referer:${referer}`,
|
||||
"--add-header", `User-Agent:${userAgent}`,
|
||||
"--output",
|
||||
newVideoPath,
|
||||
"--format",
|
||||
"best",
|
||||
"--merge-output-format",
|
||||
"mp4",
|
||||
"--add-header",
|
||||
`Referer:${referer}`,
|
||||
"--add-header",
|
||||
`User-Agent:${userAgent}`,
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -338,25 +354,162 @@ export class MissAVDownloader {
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
reject(err);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
|
||||
if (onStart) {
|
||||
onStart(() => {
|
||||
console.log("Killing subprocess for download:", downloadId);
|
||||
child.kill();
|
||||
// Cleanup logic is handled in the catch block of the main function
|
||||
// via the error thrown by kill (maybe) or we should trigger it manually.
|
||||
// Actually existing cleanup in onStart was good, let's keep it minimal for now
|
||||
// as fine-grained cleanup is complex there.
|
||||
});
|
||||
onStart(() => {
|
||||
console.log("Killing subprocess for download:", downloadId);
|
||||
child.kill();
|
||||
|
||||
// Clean up temporary files created by yt-dlp (*.part, *.ytdl, etc.)
|
||||
console.log("Cleaning up temporary files...");
|
||||
try {
|
||||
const videoDir = path.dirname(newVideoPath);
|
||||
const videoBasename = path.basename(newVideoPath);
|
||||
|
||||
// Find all files in the video directory that match the pattern
|
||||
const files = fs.readdirSync(videoDir);
|
||||
const tempFiles = files.filter((file) => {
|
||||
// Match files like: filename.mp4.part, filename.mp4.ytdl, or any file starting with the video basename
|
||||
// but not the final video file itself
|
||||
return (
|
||||
file.startsWith(videoBasename) &&
|
||||
file !== videoBasename &&
|
||||
(file.endsWith(".part") ||
|
||||
file.endsWith(".ytdl") ||
|
||||
file.endsWith(".mp4.part") ||
|
||||
file.endsWith(".mp4.ytdl"))
|
||||
);
|
||||
});
|
||||
|
||||
for (const tempFile of tempFiles) {
|
||||
const tempFilePath = path.join(videoDir, tempFile);
|
||||
if (fs.existsSync(tempFilePath)) {
|
||||
fs.unlinkSync(tempFilePath);
|
||||
console.log("Deleted temporary file:", tempFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for the main video file if it exists (partial download)
|
||||
if (fs.existsSync(newVideoPath)) {
|
||||
fs.unlinkSync(newVideoPath);
|
||||
console.log("Deleted partial video file:", newVideoPath);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error(
|
||||
"Error cleaning up temporary files:",
|
||||
cleanupError
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Video downloaded successfully");
|
||||
} catch (err: any) {
|
||||
console.error("yt-dlp execution failed:", err);
|
||||
throw err;
|
||||
// Check if it was cancelled (killed process typically exits with code 143 or throws)
|
||||
if (
|
||||
err.code === 143 ||
|
||||
err.message?.includes("killed") ||
|
||||
err.message?.includes("SIGTERM") ||
|
||||
err.code === "SIGTERM"
|
||||
) {
|
||||
console.log("Download was cancelled");
|
||||
|
||||
// Clean up temporary files created by yt-dlp
|
||||
console.log("Cleaning up temporary files after cancellation...");
|
||||
try {
|
||||
const videoDir = path.dirname(newVideoPath);
|
||||
const videoBasename = path.basename(newVideoPath);
|
||||
|
||||
// Find all files in the video directory that match the pattern
|
||||
const files = fs.readdirSync(videoDir);
|
||||
const tempFiles = files.filter((file) => {
|
||||
// Match files like: filename.mp4.part, filename.mp4.ytdl, or any file starting with the video basename
|
||||
// but not the final video file itself
|
||||
return (
|
||||
file.startsWith(videoBasename) &&
|
||||
file !== videoBasename &&
|
||||
(file.endsWith(".part") ||
|
||||
file.endsWith(".ytdl") ||
|
||||
file.endsWith(".mp4.part") ||
|
||||
file.endsWith(".mp4.ytdl"))
|
||||
);
|
||||
});
|
||||
|
||||
for (const tempFile of tempFiles) {
|
||||
const tempFilePath = path.join(videoDir, tempFile);
|
||||
if (fs.existsSync(tempFilePath)) {
|
||||
fs.unlinkSync(tempFilePath);
|
||||
console.log("Deleted temporary file:", tempFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for the main video file if it exists (partial download)
|
||||
if (fs.existsSync(newVideoPath)) {
|
||||
fs.unlinkSync(newVideoPath);
|
||||
console.log("Deleted partial video file:", newVideoPath);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error("Error cleaning up temporary files:", cleanupError);
|
||||
}
|
||||
|
||||
throw new Error("Download cancelled by user");
|
||||
}
|
||||
console.error("yt-dlp execution failed:", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Check if download was cancelled (it might have been removed from active downloads)
|
||||
if (downloadId) {
|
||||
const status = storageService.getDownloadStatus();
|
||||
const isStillActive = status.activeDownloads.some(
|
||||
(d) => d.id === downloadId
|
||||
);
|
||||
if (!isStillActive) {
|
||||
console.log("Download was cancelled (no longer in active downloads)");
|
||||
|
||||
// Clean up temporary files created by yt-dlp
|
||||
console.log("Cleaning up temporary files after cancellation...");
|
||||
try {
|
||||
const videoDir = path.dirname(newVideoPath);
|
||||
const videoBasename = path.basename(newVideoPath);
|
||||
|
||||
// Find all files in the video directory that match the pattern
|
||||
const files = fs.readdirSync(videoDir);
|
||||
const tempFiles = files.filter((file) => {
|
||||
// Match files like: filename.mp4.part, filename.mp4.ytdl, or any file starting with the video basename
|
||||
// but not the final video file itself
|
||||
return (
|
||||
file.startsWith(videoBasename) &&
|
||||
file !== videoBasename &&
|
||||
(file.endsWith(".part") ||
|
||||
file.endsWith(".ytdl") ||
|
||||
file.endsWith(".mp4.part") ||
|
||||
file.endsWith(".mp4.ytdl"))
|
||||
);
|
||||
});
|
||||
|
||||
for (const tempFile of tempFiles) {
|
||||
const tempFilePath = path.join(videoDir, tempFile);
|
||||
if (fs.existsSync(tempFilePath)) {
|
||||
fs.unlinkSync(tempFilePath);
|
||||
console.log("Deleted temporary file:", tempFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for the main video file if it exists (partial download)
|
||||
if (fs.existsSync(newVideoPath)) {
|
||||
fs.unlinkSync(newVideoPath);
|
||||
console.log("Deleted partial video file:", newVideoPath);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error("Error cleaning up temporary files:", cleanupError);
|
||||
}
|
||||
|
||||
throw new Error("Download cancelled by user");
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Download and save the thumbnail
|
||||
@@ -439,7 +592,11 @@ export class MissAVDownloader {
|
||||
} catch (error: any) {
|
||||
console.error("Error in downloadMissAVVideo:", error);
|
||||
// Cleanup
|
||||
const newSafeBaseFilename = formatVideoFilename(videoTitle, videoAuthor, videoDate);
|
||||
const newSafeBaseFilename = formatVideoFilename(
|
||||
videoTitle,
|
||||
videoAuthor,
|
||||
videoDate
|
||||
);
|
||||
const newVideoPath = path.join(VIDEOS_DIR, `${newSafeBaseFilename}.mp4`);
|
||||
const newThumbnailPath = path.join(
|
||||
IMAGES_DIR,
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios from "axios";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { IMAGES_DIR, SUBTITLES_DIR, VIDEOS_DIR } from "../../config/paths";
|
||||
import { calculateDownloadedSize, parseSize } from "../../utils/downloadUtils";
|
||||
import { formatVideoFilename } from "../../utils/helpers";
|
||||
import { executeYtDlpJson, executeYtDlpSpawn } from "../../utils/ytDlpUtils";
|
||||
import * as storageService from "../storageService";
|
||||
@@ -323,6 +324,22 @@ export class YtDlpDownloader {
|
||||
fs.unlinkSync(newThumbnailPath);
|
||||
console.log("Deleted partial thumbnail file:", newThumbnailPath);
|
||||
}
|
||||
|
||||
// Clean up any subtitle files that might have been created by yt-dlp
|
||||
const baseFilename = newSafeBaseFilename;
|
||||
const subtitleFiles = fs
|
||||
.readdirSync(VIDEOS_DIR)
|
||||
.filter(
|
||||
(file: string) =>
|
||||
file.startsWith(baseFilename) && file.endsWith(".vtt")
|
||||
);
|
||||
for (const subtitleFile of subtitleFiles) {
|
||||
const sourceSubPath = path.join(VIDEOS_DIR, subtitleFile);
|
||||
if (fs.existsSync(sourceSubPath)) {
|
||||
fs.unlinkSync(sourceSubPath);
|
||||
console.log("Deleted subtitle file:", sourceSubPath);
|
||||
}
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error("Error cleaning up partial files:", cleanupError);
|
||||
}
|
||||
@@ -332,27 +349,172 @@ export class YtDlpDownloader {
|
||||
subprocess.stdout?.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
// Parse progress: [download] 23.5% of 10.00MiB at 2.00MiB/s ETA 00:05
|
||||
// Also try to match: [download] 55.8MiB of 123.45MiB at 5.67MiB/s ETA 00:12
|
||||
const progressMatch = output.match(
|
||||
/(\d+\.?\d*)%\s+of\s+([~\d\w.]+)\s+at\s+([~\d\w.\/]+)/
|
||||
);
|
||||
|
||||
// Try to match format with downloaded size explicitly shown
|
||||
const progressWithSizeMatch = output.match(
|
||||
/([~\d\w.]+)\s+of\s+([~\d\w.]+)\s+at\s+([~\d\w.\/]+)/
|
||||
);
|
||||
|
||||
if (progressMatch && downloadId) {
|
||||
const percentage = parseFloat(progressMatch[1]);
|
||||
const totalSize = progressMatch[2];
|
||||
const speed = progressMatch[3];
|
||||
|
||||
// Calculate downloadedSize from percentage and totalSize
|
||||
const downloadedSize = calculateDownloadedSize(percentage, totalSize);
|
||||
|
||||
storageService.updateActiveDownload(downloadId, {
|
||||
progress: percentage,
|
||||
totalSize: totalSize,
|
||||
downloadedSize: downloadedSize,
|
||||
speed: speed,
|
||||
});
|
||||
} else if (progressWithSizeMatch && downloadId) {
|
||||
// If we have explicit downloaded size in the output
|
||||
const downloadedSize = progressWithSizeMatch[1];
|
||||
const totalSize = progressWithSizeMatch[2];
|
||||
const speed = progressWithSizeMatch[3];
|
||||
|
||||
// Calculate percentage from downloaded and total sizes
|
||||
const downloadedBytes = parseSize(downloadedSize);
|
||||
const totalBytes = parseSize(totalSize);
|
||||
const percentage =
|
||||
totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0;
|
||||
|
||||
storageService.updateActiveDownload(downloadId, {
|
||||
progress: percentage,
|
||||
totalSize: totalSize,
|
||||
downloadedSize: downloadedSize,
|
||||
speed: speed,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await subprocess;
|
||||
// Wait for download to complete
|
||||
try {
|
||||
await subprocess;
|
||||
} catch (error: any) {
|
||||
// Check if it was cancelled (killed process typically exits with code 143 or throws)
|
||||
if (
|
||||
error.code === 143 ||
|
||||
error.message?.includes("killed") ||
|
||||
error.message?.includes("SIGTERM") ||
|
||||
error.code === "SIGTERM"
|
||||
) {
|
||||
console.log("Download was cancelled");
|
||||
// Clean up partial files
|
||||
try {
|
||||
const partVideoPath = `${newVideoPath}.part`;
|
||||
if (fs.existsSync(partVideoPath)) {
|
||||
fs.unlinkSync(partVideoPath);
|
||||
}
|
||||
if (fs.existsSync(newVideoPath)) {
|
||||
fs.unlinkSync(newVideoPath);
|
||||
}
|
||||
|
||||
// Clean up any subtitle files that might have been created by yt-dlp
|
||||
const baseFilename = newSafeBaseFilename;
|
||||
const subtitleFiles = fs
|
||||
.readdirSync(VIDEOS_DIR)
|
||||
.filter(
|
||||
(file: string) =>
|
||||
file.startsWith(baseFilename) && file.endsWith(".vtt")
|
||||
);
|
||||
for (const subtitleFile of subtitleFiles) {
|
||||
const sourceSubPath = path.join(VIDEOS_DIR, subtitleFile);
|
||||
if (fs.existsSync(sourceSubPath)) {
|
||||
fs.unlinkSync(sourceSubPath);
|
||||
console.log("Deleted subtitle file:", sourceSubPath);
|
||||
}
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error("Error cleaning up partial files:", cleanupError);
|
||||
}
|
||||
throw new Error("Download cancelled by user");
|
||||
}
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check if download was cancelled (it might have been removed from active downloads)
|
||||
if (downloadId) {
|
||||
const status = storageService.getDownloadStatus();
|
||||
const isStillActive = status.activeDownloads.some(
|
||||
(d) => d.id === downloadId
|
||||
);
|
||||
if (!isStillActive) {
|
||||
console.log("Download was cancelled (no longer in active downloads)");
|
||||
// Clean up partial files
|
||||
try {
|
||||
const partVideoPath = `${newVideoPath}.part`;
|
||||
if (fs.existsSync(partVideoPath)) {
|
||||
fs.unlinkSync(partVideoPath);
|
||||
}
|
||||
if (fs.existsSync(newVideoPath)) {
|
||||
fs.unlinkSync(newVideoPath);
|
||||
}
|
||||
|
||||
// Clean up any subtitle files that might have been created by yt-dlp
|
||||
const baseFilename = newSafeBaseFilename;
|
||||
const subtitleFiles = fs
|
||||
.readdirSync(VIDEOS_DIR)
|
||||
.filter(
|
||||
(file: string) =>
|
||||
file.startsWith(baseFilename) && file.endsWith(".vtt")
|
||||
);
|
||||
for (const subtitleFile of subtitleFiles) {
|
||||
const sourceSubPath = path.join(VIDEOS_DIR, subtitleFile);
|
||||
if (fs.existsSync(sourceSubPath)) {
|
||||
fs.unlinkSync(sourceSubPath);
|
||||
console.log("Deleted subtitle file:", sourceSubPath);
|
||||
}
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error("Error cleaning up partial files:", cleanupError);
|
||||
}
|
||||
throw new Error("Download cancelled by user");
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Video downloaded successfully");
|
||||
|
||||
// Check if download was cancelled before processing thumbnails and subtitles
|
||||
if (downloadId) {
|
||||
const status = storageService.getDownloadStatus();
|
||||
const isStillActive = status.activeDownloads.some(
|
||||
(d) => d.id === downloadId
|
||||
);
|
||||
if (!isStillActive) {
|
||||
console.log(
|
||||
"Download was cancelled, skipping thumbnail and subtitle processing"
|
||||
);
|
||||
// Clean up any subtitle files that might have been created
|
||||
try {
|
||||
const baseFilename = newSafeBaseFilename;
|
||||
const subtitleFiles = fs
|
||||
.readdirSync(VIDEOS_DIR)
|
||||
.filter(
|
||||
(file: string) =>
|
||||
file.startsWith(baseFilename) && file.endsWith(".vtt")
|
||||
);
|
||||
for (const subtitleFile of subtitleFiles) {
|
||||
const sourceSubPath = path.join(VIDEOS_DIR, subtitleFile);
|
||||
if (fs.existsSync(sourceSubPath)) {
|
||||
fs.unlinkSync(sourceSubPath);
|
||||
console.log("Deleted subtitle file:", sourceSubPath);
|
||||
}
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error("Error cleaning up subtitle files:", cleanupError);
|
||||
}
|
||||
throw new Error("Download cancelled by user");
|
||||
}
|
||||
}
|
||||
|
||||
// Download and save the thumbnail
|
||||
thumbnailSaved = false;
|
||||
|
||||
@@ -383,6 +545,38 @@ export class YtDlpDownloader {
|
||||
// Continue even if thumbnail download fails
|
||||
}
|
||||
}
|
||||
|
||||
// Check again if download was cancelled before processing subtitles
|
||||
if (downloadId) {
|
||||
const status = storageService.getDownloadStatus();
|
||||
const isStillActive = status.activeDownloads.some(
|
||||
(d) => d.id === downloadId
|
||||
);
|
||||
if (!isStillActive) {
|
||||
console.log("Download was cancelled, skipping subtitle processing");
|
||||
// Clean up any subtitle files that might have been created
|
||||
try {
|
||||
const baseFilename = newSafeBaseFilename;
|
||||
const subtitleFiles = fs
|
||||
.readdirSync(VIDEOS_DIR)
|
||||
.filter(
|
||||
(file: string) =>
|
||||
file.startsWith(baseFilename) && file.endsWith(".vtt")
|
||||
);
|
||||
for (const subtitleFile of subtitleFiles) {
|
||||
const sourceSubPath = path.join(VIDEOS_DIR, subtitleFile);
|
||||
if (fs.existsSync(sourceSubPath)) {
|
||||
fs.unlinkSync(sourceSubPath);
|
||||
console.log("Deleted subtitle file:", sourceSubPath);
|
||||
}
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error("Error cleaning up subtitle files:", cleanupError);
|
||||
}
|
||||
throw new Error("Download cancelled by user");
|
||||
}
|
||||
}
|
||||
|
||||
// Scan for subtitle files
|
||||
try {
|
||||
const baseFilename = newSafeBaseFilename;
|
||||
@@ -396,6 +590,31 @@ export class YtDlpDownloader {
|
||||
console.log(`Found ${subtitleFiles.length} subtitle files`);
|
||||
|
||||
for (const subtitleFile of subtitleFiles) {
|
||||
// Check if download was cancelled during subtitle processing
|
||||
if (downloadId) {
|
||||
const status = storageService.getDownloadStatus();
|
||||
const isStillActive = status.activeDownloads.some(
|
||||
(d) => d.id === downloadId
|
||||
);
|
||||
if (!isStillActive) {
|
||||
console.log("Download was cancelled during subtitle processing");
|
||||
// Clean up remaining subtitle files
|
||||
for (const remainingSubFile of subtitleFiles.slice(
|
||||
subtitleFiles.indexOf(subtitleFile)
|
||||
)) {
|
||||
const remainingSubPath = path.join(
|
||||
VIDEOS_DIR,
|
||||
remainingSubFile
|
||||
);
|
||||
if (fs.existsSync(remainingSubPath)) {
|
||||
fs.unlinkSync(remainingSubPath);
|
||||
console.log("Deleted subtitle file:", remainingSubPath);
|
||||
}
|
||||
}
|
||||
throw new Error("Download cancelled by user");
|
||||
}
|
||||
}
|
||||
|
||||
// Parse language from filename (e.g., video_123.en.vtt -> en)
|
||||
const match = subtitleFile.match(
|
||||
/\.([a-z]{2}(?:-[A-Z]{2})?)(?:\..*?)?\.vtt$/
|
||||
@@ -431,6 +650,13 @@ export class YtDlpDownloader {
|
||||
});
|
||||
}
|
||||
} catch (subtitleError) {
|
||||
// If it's a cancellation error, re-throw it
|
||||
if (
|
||||
subtitleError instanceof Error &&
|
||||
subtitleError.message?.includes("Download cancelled by user")
|
||||
) {
|
||||
throw subtitleError;
|
||||
}
|
||||
console.error("Error processing subtitle files:", subtitleError);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
62
backend/src/utils/downloadUtils.ts
Normal file
62
backend/src/utils/downloadUtils.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Utility functions for download operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse size string (e.g., "10.00MiB", "123.45KiB") to bytes
|
||||
* Handles both decimal (KB, MB, GB, TB) and binary (KiB, MiB, GiB, TiB) units
|
||||
* Also handles ~ prefix for approximate sizes
|
||||
*/
|
||||
export function parseSize(sizeStr: string): number {
|
||||
if (!sizeStr) return 0;
|
||||
|
||||
// Remove ~ prefix if present
|
||||
const cleanSize = sizeStr.replace(/^~/, "").trim();
|
||||
|
||||
// Match number and unit
|
||||
const match = cleanSize.match(/^([\d.]+)\s*([KMGT]?i?B)$/i);
|
||||
if (!match) return 0;
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
|
||||
const multipliers: { [key: string]: number } = {
|
||||
B: 1,
|
||||
KB: 1000,
|
||||
KIB: 1024,
|
||||
MB: 1000 * 1000,
|
||||
MIB: 1024 * 1024,
|
||||
GB: 1000 * 1000 * 1000,
|
||||
GIB: 1024 * 1024 * 1024,
|
||||
TB: 1000 * 1000 * 1000 * 1000,
|
||||
TIB: 1024 * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
return value * (multipliers[unit] || 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable string (e.g., "55.8 MiB")
|
||||
* Uses binary units (KiB, MiB, GiB, TiB) with 1024 base
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate downloaded size from progress percentage and total size
|
||||
* Returns formatted string (e.g., "55.8 MiB")
|
||||
*/
|
||||
export function calculateDownloadedSize(
|
||||
percentage: number,
|
||||
totalSize: string
|
||||
): string {
|
||||
if (!totalSize || totalSize === "?") return "0 B";
|
||||
const totalBytes = parseSize(totalSize);
|
||||
const downloadedBytes = (percentage / 100) * totalBytes;
|
||||
return formatBytes(downloadedBytes);
|
||||
}
|
||||
Reference in New Issue
Block a user