refactor: Improve code readability and maintainability

This commit is contained in:
Peifan Li
2025-12-10 00:32:08 -05:00
parent 429403806e
commit 4ab371f123
6 changed files with 752 additions and 65 deletions

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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) {

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