feat: Improve MissAVDownloader methods and error handling

This commit is contained in:
Peifan Li
2025-12-07 23:54:48 -05:00
parent 83140dc7fb
commit 4a26709203

View File

@@ -10,375 +10,543 @@ import * as storageService from "../storageService";
import { Video } from "../storageService";
export class MissAVDownloader {
// Get video info without downloading
static async getVideoInfo(url: string): Promise<{ title: string; author: string; date: string; thumbnailUrl: string }> {
try {
console.log("Fetching MissAV page content with Puppeteer...");
// Get video info without downloading
static async getVideoInfo(
url: string
): Promise<{
title: string;
author: string;
date: string;
thumbnailUrl: string;
}> {
try {
console.log("Fetching MissAV page content with Puppeteer...");
const browser = await puppeteer.launch({
headless: true,
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
const browser = await puppeteer.launch({
headless: true,
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
await page.setUserAgent(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
);
await page.goto(url, { waitUntil: "networkidle2", timeout: 60000 });
const html = await page.content();
await browser.close();
const html = await page.content();
await browser.close();
const $ = cheerio.load(html);
const pageTitle = $('meta[property="og:title"]').attr('content');
const ogImage = $('meta[property="og:image"]').attr('content');
const $ = cheerio.load(html);
const pageTitle = $('meta[property="og:title"]').attr("content");
const ogImage = $('meta[property="og:image"]').attr("content");
return {
title: pageTitle || "MissAV Video",
author: "MissAV",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: ogImage || "",
};
} catch (error) {
console.error("Error fetching MissAV video info:", error);
return {
title: "MissAV Video",
author: "MissAV",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: "",
};
}
return {
title: pageTitle || "MissAV Video",
author: "MissAV",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: ogImage || "",
};
} catch (error) {
console.error("Error fetching MissAV video info:", error);
return {
title: "MissAV Video",
author: "MissAV",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: "",
};
}
}
// Helper function to download MissAV video
static async downloadVideo(url: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
console.log("Detected MissAV URL:", url);
// Helper function to download MissAV video
static async downloadVideo(
url: string,
downloadId?: string,
onStart?: (cancel: () => void) => void
): Promise<Video> {
console.log("Detected MissAV URL:", url);
const timestamp = Date.now();
const safeBaseFilename = `video_${timestamp}`;
const videoFilename = `${safeBaseFilename}.mp4`;
const thumbnailFilename = `${safeBaseFilename}.jpg`;
const timestamp = Date.now();
const safeBaseFilename = `video_${timestamp}`;
const videoFilename = `${safeBaseFilename}.mp4`;
const thumbnailFilename = `${safeBaseFilename}.jpg`;
// Ensure directories exist
fs.ensureDirSync(VIDEOS_DIR);
fs.ensureDirSync(IMAGES_DIR);
// Ensure directories exist
fs.ensureDirSync(VIDEOS_DIR);
fs.ensureDirSync(IMAGES_DIR);
const videoPath = path.join(VIDEOS_DIR, videoFilename);
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
const videoPath = path.join(VIDEOS_DIR, videoFilename);
const thumbnailPath = path.join(IMAGES_DIR, thumbnailFilename);
let videoTitle = "MissAV Video";
let videoAuthor = "MissAV";
let videoDate = new Date().toISOString().slice(0, 10).replace(/-/g, "");
let thumbnailUrl: string | null = null;
let thumbnailSaved = false;
let m3u8Url: string | null = null;
let videoTitle = "MissAV Video";
let videoAuthor = "MissAV";
let videoDate = new Date().toISOString().slice(0, 10).replace(/-/g, "");
let thumbnailUrl: string | null = null;
let thumbnailSaved = false;
let m3u8Url: string | null = null;
try {
// 1. Fetch the page content using Puppeteer to bypass Cloudflare and capture m3u8 URL
console.log("Launching Puppeteer to capture m3u8 URL...");
const browser = await puppeteer.launch({
headless: true,
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
// Set a real user agent
const userAgent =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
await page.setUserAgent(userAgent);
// Setup request listener to find m3u8 URLs
// Collect all m3u8 URLs and prefer specific quality playlists over master playlists
const m3u8Urls: string[] = [];
page.on("request", (request) => {
const reqUrl = request.url();
if (reqUrl.includes(".m3u8") && !reqUrl.includes("preview")) {
console.log("Found m3u8 URL via network interception:", reqUrl);
if (!m3u8Urls.includes(reqUrl)) {
m3u8Urls.push(reqUrl);
}
}
});
console.log("Navigating to:", url);
await page.goto(url, { waitUntil: "networkidle2", timeout: 60000 });
const html = await page.content();
await browser.close();
// 2. Extract metadata using cheerio
const $ = cheerio.load(html);
const pageTitle = $('meta[property="og:title"]').attr("content");
if (pageTitle) {
videoTitle = pageTitle;
}
const ogImage = $('meta[property="og:image"]').attr("content");
if (ogImage) {
thumbnailUrl = ogImage;
}
console.log("Extracted metadata:", {
title: videoTitle,
thumbnail: thumbnailUrl,
});
// 3. Select the best m3u8 URL from collected URLs
// Prefer specific quality playlists (e.g., /480p/video.m3u8) over master playlists (e.g., /playlist.m3u8)
if (m3u8Urls.length > 0) {
// Sort URLs: prefer specific quality playlists, avoid master playlists
const sortedUrls = m3u8Urls.sort((a, b) => {
const aIsMaster =
a.includes("/playlist.m3u8") || a.includes("/master/");
const bIsMaster =
b.includes("/playlist.m3u8") || b.includes("/master/");
// Prefer non-master playlists
if (aIsMaster && !bIsMaster) return 1;
if (!aIsMaster && bIsMaster) return -1;
// Among non-master playlists, prefer higher quality (480p > 240p)
const aQuality = a.match(/(\d+p)/)?.[1] || "0p";
const bQuality = b.match(/(\d+p)/)?.[1] || "0p";
const aQualityNum = parseInt(aQuality) || 0;
const bQualityNum = parseInt(bQuality) || 0;
return bQualityNum - aQualityNum; // Higher quality first
});
m3u8Url = sortedUrls[0];
console.log(
`Selected m3u8 URL from ${m3u8Urls.length} candidates:`,
m3u8Url
);
if (sortedUrls.length > 1) {
console.log("Alternative URLs:", sortedUrls.slice(1));
}
}
// 4. If m3u8 URL was not found via network, try regex extraction as fallback
let regexExtractedUrl: string | null = null;
if (!m3u8Url) {
console.log(
"m3u8 URL not found via network, trying regex extraction..."
);
// Logic ported from: https://github.com/smalltownjj/yt-dlp-plugin-missav/blob/main/yt_dlp_plugins/extractor/missav.py
const m3u8Match = html.match(/m3u8\|[^"]+\|playlist\|source/);
if (m3u8Match) {
const matchString = m3u8Match[0];
const cleanString = matchString
.replace("m3u8|", "")
.replace("|playlist|source", "");
const urlWords = cleanString.split("|");
const videoIndex = urlWords.indexOf("video");
if (videoIndex !== -1) {
const protocol = urlWords[videoIndex - 1];
const videoFormat = urlWords[videoIndex + 1];
const m3u8UrlPath = urlWords.slice(0, 5).reverse().join("-");
const baseUrlPath = urlWords
.slice(5, videoIndex - 1)
.reverse()
.join(".");
regexExtractedUrl = `${protocol}://${baseUrlPath}/${m3u8UrlPath}/${videoFormat}/${urlWords[videoIndex]}.m3u8`;
console.log("Reconstructed m3u8 URL via regex:", regexExtractedUrl);
// Add to m3u8Urls if not already present
if (!m3u8Urls.includes(regexExtractedUrl)) {
m3u8Urls.push(regexExtractedUrl);
}
m3u8Url = regexExtractedUrl;
}
}
}
if (!m3u8Url) {
const debugFile = path.join(DATA_DIR, `missav_debug_${timestamp}.html`);
fs.writeFileSync(debugFile, html);
console.error(`Could not find m3u8 URL. HTML dumped to ${debugFile}`);
throw new Error(
"Could not find m3u8 URL in page source or network requests"
);
}
// 5. Download the video using ffmpeg directly
// Try multiple URLs if the first one fails
// Use sorted URLs if we have them, otherwise use the single URL
let urlsToTry: string[];
if (m3u8Urls.length > 0) {
// Re-sort to ensure best URL is first
const sortedUrls = m3u8Urls.sort((a, b) => {
const aIsMaster =
a.includes("/playlist.m3u8") || a.includes("/master/");
const bIsMaster =
b.includes("/playlist.m3u8") || b.includes("/master/");
if (aIsMaster && !bIsMaster) return 1;
if (!aIsMaster && bIsMaster) return -1;
const aQuality = a.match(/(\d+p)/)?.[1] || "0p";
const bQuality = b.match(/(\d+p)/)?.[1] || "0p";
const aQualityNum = parseInt(aQuality) || 0;
const bQualityNum = parseInt(bQuality) || 0;
return bQualityNum - aQualityNum;
});
urlsToTry = sortedUrls;
} else {
urlsToTry = [m3u8Url];
}
let downloadSuccess = false;
let lastError: Error | null = null;
for (const urlToTry of urlsToTry) {
if (downloadSuccess) break;
console.log(`Attempting to download video stream from: ${urlToTry}`);
console.log("Downloading video stream to:", videoPath);
if (downloadId) {
storageService.updateActiveDownload(downloadId, {
filename: videoTitle,
progress: 0,
});
}
try {
// 1. Fetch the page content using Puppeteer to bypass Cloudflare and capture m3u8 URL
console.log("Launching Puppeteer to capture m3u8 URL...");
await new Promise<void>((resolve, reject) => {
const ffmpegArgs = [
"-user_agent",
userAgent,
"-headers",
"Referer: https://missav.ai/",
"-i",
urlToTry,
"-c",
"copy",
"-bsf:a",
"aac_adtstoasc",
"-y", // Overwrite output file
videoPath,
];
const browser = await puppeteer.launch({
headless: true,
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
console.log("Spawning ffmpeg with args:", ffmpegArgs.join(" "));
// Set a real user agent
const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
await page.setUserAgent(userAgent);
const ffmpeg = spawn("ffmpeg", ffmpegArgs);
let totalDurationSec = 0;
let ffmpegStderr = "";
// Setup request listener to find m3u8
page.on('request', (request) => {
const reqUrl = request.url();
if (reqUrl.includes('.m3u8') && !reqUrl.includes('preview')) {
console.log("Found m3u8 URL via network interception:", reqUrl);
if (!m3u8Url) {
m3u8Url = reqUrl;
}
}
});
if (onStart) {
onStart(() => {
console.log("Killing ffmpeg process for download:", downloadId);
ffmpeg.kill("SIGKILL");
console.log("Navigating to:", url);
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
const html = await page.content();
await browser.close();
// 2. Extract metadata using cheerio
const $ = cheerio.load(html);
const pageTitle = $('meta[property="og:title"]').attr('content');
if (pageTitle) {
videoTitle = pageTitle;
}
const ogImage = $('meta[property="og:image"]').attr('content');
if (ogImage) {
thumbnailUrl = ogImage;
}
console.log("Extracted metadata:", { title: videoTitle, thumbnail: thumbnailUrl });
// 3. If m3u8 URL was not found via network, try regex extraction as fallback
if (!m3u8Url) {
console.log("m3u8 URL not found via network, trying regex extraction...");
// Logic ported from: https://github.com/smalltownjj/yt-dlp-plugin-missav/blob/main/yt_dlp_plugins/extractor/missav.py
const m3u8Match = html.match(/m3u8\|[^"]+\|playlist\|source/);
if (m3u8Match) {
const matchString = m3u8Match[0];
const cleanString = matchString.replace("m3u8|", "").replace("|playlist|source", "");
const urlWords = cleanString.split("|");
const videoIndex = urlWords.indexOf("video");
if (videoIndex !== -1) {
const protocol = urlWords[videoIndex - 1];
const videoFormat = urlWords[videoIndex + 1];
const m3u8UrlPath = urlWords.slice(0, 5).reverse().join("-");
const baseUrlPath = urlWords.slice(5, videoIndex - 1).reverse().join(".");
m3u8Url = `${protocol}://${baseUrlPath}/${m3u8UrlPath}/${videoFormat}/${urlWords[videoIndex]}.m3u8`;
console.log("Reconstructed m3u8 URL via regex:", m3u8Url);
}
}
}
if (!m3u8Url) {
const debugFile = path.join(DATA_DIR, `missav_debug_${timestamp}.html`);
fs.writeFileSync(debugFile, html);
console.error(`Could not find m3u8 URL. HTML dumped to ${debugFile}`);
throw new Error("Could not find m3u8 URL in page source or network requests");
}
// 4. Download the video using ffmpeg directly
console.log("Downloading video stream to:", videoPath);
if (downloadId) {
storageService.updateActiveDownload(downloadId, {
filename: videoTitle,
progress: 0
});
}
await new Promise<void>((resolve, reject) => {
const ffmpegArgs = [
'-user_agent', userAgent,
'-headers', 'Referer: https://missav.ai/',
'-i', m3u8Url!,
'-c', 'copy',
'-bsf:a', 'aac_adtstoasc',
'-y', // Overwrite output file
videoPath
];
console.log("Spawning ffmpeg with args:", ffmpegArgs.join(" "));
const ffmpeg = spawn('ffmpeg', ffmpegArgs);
let totalDurationSec = 0;
if (onStart) {
onStart(() => {
console.log("Killing ffmpeg process for download:", downloadId);
ffmpeg.kill('SIGKILL');
// Cleanup
try {
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 (e) {
console.error("Error cleaning up partial files:", e);
}
});
}
ffmpeg.stderr.on('data', (data) => {
const output = data.toString();
// console.log("ffmpeg stderr:", output); // Uncomment for verbose debug
// Try to parse duration if not set
if (totalDurationSec === 0) {
const durationMatch = output.match(/Duration: (\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
if (durationMatch) {
const hours = parseInt(durationMatch[1]);
const minutes = parseInt(durationMatch[2]);
const seconds = parseInt(durationMatch[3]);
totalDurationSec = hours * 3600 + minutes * 60 + seconds;
console.log("Detected total duration:", totalDurationSec);
}
}
// Parse progress
// size= 12345kB time=00:01:23.45 bitrate= 1234.5kbits/s speed=1.23x
const timeMatch = output.match(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
const sizeMatch = output.match(/size=\s*(\d+)([kMG]?B)/);
const bitrateMatch = output.match(/bitrate=\s*(\d+\.?\d*)kbits\/s/);
if (timeMatch && downloadId) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const seconds = parseInt(timeMatch[3]);
const currentTimeSec = hours * 3600 + minutes * 60 + seconds;
let percentage = 0;
if (totalDurationSec > 0) {
percentage = Math.min(100, (currentTimeSec / totalDurationSec) * 100);
}
let totalSizeStr = "0B";
if (sizeMatch) {
totalSizeStr = `${sizeMatch[1]}${sizeMatch[2]}`;
}
let speedStr = "0 B/s";
if (bitrateMatch) {
const bitrateKbps = parseFloat(bitrateMatch[1]);
// Convert kbits/s to KB/s (approximate, usually bitrate is bits, so /8)
// But ffmpeg reports kbits/s. 1 byte = 8 bits.
const speedKBps = bitrateKbps / 8;
if (speedKBps > 1024) {
speedStr = `${(speedKBps / 1024).toFixed(2)} MB/s`;
} else {
speedStr = `${speedKBps.toFixed(2)} KB/s`;
}
}
storageService.updateActiveDownload(downloadId, {
progress: parseFloat(percentage.toFixed(1)),
totalSize: totalSizeStr,
speed: speedStr
});
}
});
ffmpeg.on('close', (code) => {
if (code === 0) {
console.log("ffmpeg process finished successfully");
resolve();
} else {
console.error(`ffmpeg process exited with code ${code}`);
// If killed (null code) or error
if (code === null) {
// Likely killed by user, reject? Or resolve if handled?
// If killed by onStart callback, we might want to reject to stop flow
reject(new Error("Download cancelled"));
} else {
reject(new Error(`ffmpeg exited with code ${code}`));
}
}
});
ffmpeg.on('error', (err) => {
console.error("Failed to start ffmpeg:", err);
reject(err);
});
});
console.log("Video download complete");
// 5. Download thumbnail
if (thumbnailUrl) {
// Cleanup
try {
console.log("Downloading thumbnail from:", thumbnailUrl);
const thumbnailResponse = await axios({
method: "GET",
url: thumbnailUrl,
responseType: "stream",
});
const thumbnailWriter = fs.createWriteStream(thumbnailPath);
thumbnailResponse.data.pipe(thumbnailWriter);
await new Promise<void>((resolve, reject) => {
thumbnailWriter.on("finish", () => {
thumbnailSaved = true;
resolve();
});
thumbnailWriter.on("error", reject);
});
console.log("Thumbnail saved");
} catch (err) {
console.error("Error downloading thumbnail:", err);
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 (e) {
console.error("Error cleaning up partial files:", e);
}
});
}
// 6. Rename files with title
let finalVideoFilename = videoFilename;
let finalThumbnailFilename = thumbnailFilename;
ffmpeg.stderr.on("data", (data) => {
const output = data.toString();
ffmpegStderr += output;
// console.log("ffmpeg stderr:", output); // Uncomment for verbose debug
const newSafeBaseFilename = `${sanitizeFilename(videoTitle)}_${timestamp}`;
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
const newVideoPath = path.join(VIDEOS_DIR, newVideoFilename);
const newThumbnailPath = path.join(IMAGES_DIR, newThumbnailFilename);
if (fs.existsSync(videoPath)) {
fs.renameSync(videoPath, newVideoPath);
finalVideoFilename = newVideoFilename;
}
if (thumbnailSaved && fs.existsSync(thumbnailPath)) {
fs.renameSync(thumbnailPath, newThumbnailPath);
finalThumbnailFilename = newThumbnailFilename;
}
// Get video duration
let duration: string | undefined;
try {
const { getVideoDuration } = await import("../../services/metadataService");
const durationSec = await getVideoDuration(newVideoPath);
if (durationSec) {
duration = durationSec.toString();
// Try to parse duration if not set
if (totalDurationSec === 0) {
const durationMatch = output.match(
/Duration: (\d{2}):(\d{2}):(\d{2})\.(\d{2})/
);
if (durationMatch) {
const hours = parseInt(durationMatch[1]);
const minutes = parseInt(durationMatch[2]);
const seconds = parseInt(durationMatch[3]);
totalDurationSec = hours * 3600 + minutes * 60 + seconds;
console.log("Detected total duration:", totalDurationSec);
}
} catch (e) {
console.error("Failed to extract duration from MissAV video:", e);
}
}
// Get file size
let fileSize: string | undefined;
try {
if (fs.existsSync(newVideoPath)) {
const stats = fs.statSync(newVideoPath);
fileSize = stats.size.toString();
// Parse progress
// size= 12345kB time=00:01:23.45 bitrate= 1234.5kbits/s speed=1.23x
const timeMatch = output.match(
/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/
);
const sizeMatch = output.match(/size=\s*(\d+)([kMG]?B)/);
const bitrateMatch = output.match(
/bitrate=\s*(\d+\.?\d*)kbits\/s/
);
if (timeMatch && downloadId) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const seconds = parseInt(timeMatch[3]);
const currentTimeSec = hours * 3600 + minutes * 60 + seconds;
let percentage = 0;
if (totalDurationSec > 0) {
percentage = Math.min(
100,
(currentTimeSec / totalDurationSec) * 100
);
}
} catch (e) {
console.error("Failed to get file size:", e);
}
// 7. Save metadata
const videoData: Video = {
id: timestamp.toString(),
title: videoTitle,
author: videoAuthor,
date: videoDate,
source: "missav",
sourceUrl: url,
videoFilename: finalVideoFilename,
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
thumbnailUrl: thumbnailUrl || undefined,
videoPath: `/videos/${finalVideoFilename}`,
thumbnailPath: thumbnailSaved ? `/images/${finalThumbnailFilename}` : null,
duration: duration,
fileSize: fileSize,
addedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
};
let totalSizeStr = "0B";
if (sizeMatch) {
totalSizeStr = `${sizeMatch[1]}${sizeMatch[2]}`;
}
storageService.saveVideo(videoData);
console.log("MissAV video saved to database");
let speedStr = "0 B/s";
if (bitrateMatch) {
const bitrateKbps = parseFloat(bitrateMatch[1]);
// Convert kbits/s to KB/s (approximate, usually bitrate is bits, so /8)
// But ffmpeg reports kbits/s. 1 byte = 8 bits.
const speedKBps = bitrateKbps / 8;
if (speedKBps > 1024) {
speedStr = `${(speedKBps / 1024).toFixed(2)} MB/s`;
} else {
speedStr = `${speedKBps.toFixed(2)} KB/s`;
}
}
return videoData;
storageService.updateActiveDownload(downloadId, {
progress: parseFloat(percentage.toFixed(1)),
totalSize: totalSizeStr,
speed: speedStr,
});
}
});
ffmpeg.on("close", (code) => {
if (code === 0) {
console.log("ffmpeg process finished successfully");
resolve();
} else {
console.error(`ffmpeg process exited with code ${code}`);
console.error(
"ffmpeg stderr output:",
ffmpegStderr.slice(-1000)
); // Last 1000 chars
// If killed (null code) or error
if (code === null) {
// Likely killed by user, reject? Or resolve if handled?
// If killed by onStart callback, we might want to reject to stop flow
reject(new Error("Download cancelled"));
} else {
reject(
new Error(
`ffmpeg exited with code ${code} when trying URL: ${urlToTry}`
)
);
}
}
});
ffmpeg.on("error", (err) => {
console.error("Failed to start ffmpeg:", err);
reject(err);
});
});
downloadSuccess = true;
console.log("Video download complete");
} catch (error: any) {
console.error("Error in downloadMissAVVideo:", error);
// Cleanup
if (fs.existsSync(videoPath)) fs.removeSync(videoPath);
if (fs.existsSync(thumbnailPath)) fs.removeSync(thumbnailPath);
throw error;
lastError = error;
console.error(`Failed to download from ${urlToTry}:`, error.message);
// Clean up partial file before trying next URL
if (fs.existsSync(videoPath)) {
try {
fs.unlinkSync(videoPath);
console.log(
"Cleaned up partial video file before trying next URL"
);
} catch (e) {
console.error("Error cleaning up partial file:", e);
}
}
// If this is not the last URL, continue to next
if (urlsToTry.indexOf(urlToTry) < urlsToTry.length - 1) {
console.log(
`Trying next URL... (${urlsToTry.indexOf(urlToTry) + 2}/${
urlsToTry.length
})`
);
continue;
}
}
}
if (!downloadSuccess) {
throw (
lastError ||
new Error("Failed to download video from all available URLs")
);
}
// 6. Download thumbnail
if (thumbnailUrl) {
try {
console.log("Downloading thumbnail from:", thumbnailUrl);
const thumbnailResponse = await axios({
method: "GET",
url: thumbnailUrl,
responseType: "stream",
});
const thumbnailWriter = fs.createWriteStream(thumbnailPath);
thumbnailResponse.data.pipe(thumbnailWriter);
await new Promise<void>((resolve, reject) => {
thumbnailWriter.on("finish", () => {
thumbnailSaved = true;
resolve();
});
thumbnailWriter.on("error", reject);
});
console.log("Thumbnail saved");
} catch (err) {
console.error("Error downloading thumbnail:", err);
}
}
// 7. Rename files with title
let finalVideoFilename = videoFilename;
let finalThumbnailFilename = thumbnailFilename;
const newSafeBaseFilename = `${sanitizeFilename(
videoTitle
)}_${timestamp}`;
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
const newVideoPath = path.join(VIDEOS_DIR, newVideoFilename);
const newThumbnailPath = path.join(IMAGES_DIR, newThumbnailFilename);
if (fs.existsSync(videoPath)) {
fs.renameSync(videoPath, newVideoPath);
finalVideoFilename = newVideoFilename;
}
if (thumbnailSaved && fs.existsSync(thumbnailPath)) {
fs.renameSync(thumbnailPath, newThumbnailPath);
finalThumbnailFilename = newThumbnailFilename;
}
// Get video duration
let duration: string | undefined;
try {
const { getVideoDuration } = await import(
"../../services/metadataService"
);
const durationSec = await getVideoDuration(newVideoPath);
if (durationSec) {
duration = durationSec.toString();
}
} catch (e) {
console.error("Failed to extract duration from MissAV video:", e);
}
// Get file size
let fileSize: string | undefined;
try {
if (fs.existsSync(newVideoPath)) {
const stats = fs.statSync(newVideoPath);
fileSize = stats.size.toString();
}
} catch (e) {
console.error("Failed to get file size:", e);
}
// 8. Save metadata
const videoData: Video = {
id: timestamp.toString(),
title: videoTitle,
author: videoAuthor,
date: videoDate,
source: "missav",
sourceUrl: url,
videoFilename: finalVideoFilename,
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
thumbnailUrl: thumbnailUrl || undefined,
videoPath: `/videos/${finalVideoFilename}`,
thumbnailPath: thumbnailSaved
? `/images/${finalThumbnailFilename}`
: null,
duration: duration,
fileSize: fileSize,
addedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
};
storageService.saveVideo(videoData);
console.log("MissAV video saved to database");
return videoData;
} catch (error: any) {
console.error("Error in downloadMissAVVideo:", error);
// Cleanup
if (fs.existsSync(videoPath)) fs.removeSync(videoPath);
if (fs.existsSync(thumbnailPath)) fs.removeSync(thumbnailPath);
throw error;
}
}
}