Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d45692374 | ||
|
|
fc070da102 | ||
|
|
d1ceef9698 | ||
|
|
bc9564f9bc | ||
|
|
710e85ad5e | ||
|
|
bc3ab6f9ef |
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "1.2.3",
|
"version": "1.2.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "1.2.3",
|
"version": "1.2.5",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.8.1",
|
"axios": "^1.8.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "1.2.4",
|
"version": "1.3.0",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "ts-node src/server.ts",
|
"start": "ts-node src/server.ts",
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import * as downloadService from '../../services/downloadService';
|
import * as downloadService from '../../services/downloadService';
|
||||||
import { BilibiliDownloader } from '../../services/downloaders/BilibiliDownloader';
|
import { BilibiliDownloader } from '../../services/downloaders/BilibiliDownloader';
|
||||||
import { MissAVDownloader } from '../../services/downloaders/MissAVDownloader';
|
import { MissAVDownloader } from '../../services/downloaders/MissAVDownloader';
|
||||||
import { YouTubeDownloader } from '../../services/downloaders/YouTubeDownloader';
|
import { YtDlpDownloader } from '../../services/downloaders/YtDlpDownloader';
|
||||||
|
|
||||||
vi.mock('../../services/downloaders/BilibiliDownloader');
|
vi.mock('../../services/downloaders/BilibiliDownloader');
|
||||||
vi.mock('../../services/downloaders/YouTubeDownloader');
|
vi.mock('../../services/downloaders/YtDlpDownloader');
|
||||||
vi.mock('../../services/downloaders/MissAVDownloader');
|
vi.mock('../../services/downloaders/MissAVDownloader');
|
||||||
|
|
||||||
describe('DownloadService', () => {
|
describe('DownloadService', () => {
|
||||||
@@ -56,22 +56,22 @@ describe('DownloadService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('YouTube', () => {
|
describe('YouTube/Generic', () => {
|
||||||
it('should call YouTubeDownloader.search', async () => {
|
it('should call YtDlpDownloader.search', async () => {
|
||||||
await downloadService.searchYouTube('query');
|
await downloadService.searchYouTube('query');
|
||||||
expect(YouTubeDownloader.search).toHaveBeenCalledWith('query');
|
expect(YtDlpDownloader.search).toHaveBeenCalledWith('query');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call YouTubeDownloader.downloadVideo', async () => {
|
it('should call YtDlpDownloader.downloadVideo', async () => {
|
||||||
await downloadService.downloadYouTubeVideo('url', 'id');
|
await downloadService.downloadYouTubeVideo('url', 'id');
|
||||||
expect(YouTubeDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id');
|
expect(YtDlpDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id', undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('MissAV', () => {
|
describe('MissAV', () => {
|
||||||
it('should call MissAVDownloader.downloadVideo', async () => {
|
it('should call MissAVDownloader.downloadVideo', async () => {
|
||||||
await downloadService.downloadMissAVVideo('url', 'id');
|
await downloadService.downloadMissAVVideo('url', 'id');
|
||||||
expect(MissAVDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id');
|
expect(MissAVDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id', undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import * as downloadService from "../services/downloadService";
|
|||||||
import { getVideoDuration } from "../services/metadataService";
|
import { getVideoDuration } from "../services/metadataService";
|
||||||
import * as storageService from "../services/storageService";
|
import * as storageService from "../services/storageService";
|
||||||
import {
|
import {
|
||||||
extractBilibiliVideoId,
|
extractBilibiliVideoId,
|
||||||
extractUrlFromText,
|
extractUrlFromText,
|
||||||
isBilibiliUrl,
|
isBilibiliUrl,
|
||||||
isValidUrl,
|
isValidUrl,
|
||||||
resolveShortUrl,
|
resolveShortUrl,
|
||||||
trimBilibiliUrl
|
trimBilibiliUrl
|
||||||
} from "../utils/helpers";
|
} from "../utils/helpers";
|
||||||
|
|
||||||
// Configure Multer for file uploads
|
// Configure Multer for file uploads
|
||||||
@@ -86,13 +86,12 @@ export const downloadVideo = async (req: Request, res: Response): Promise<any> =
|
|||||||
console.log("Resolved shortened URL to:", videoUrl);
|
console.log("Resolved shortened URL to:", videoUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be") || isBilibiliUrl(videoUrl) || videoUrl.includes("missav")) {
|
// Try to fetch video info for all URLs
|
||||||
console.log("Fetching video info for title...");
|
console.log("Fetching video info for title...");
|
||||||
const info = await downloadService.getVideoInfo(videoUrl);
|
const info = await downloadService.getVideoInfo(videoUrl);
|
||||||
if (info && info.title) {
|
if (info && info.title) {
|
||||||
initialTitle = info.title;
|
initialTitle = info.title;
|
||||||
console.log("Fetched initial title:", initialTitle);
|
console.log("Fetched initial title:", initialTitle);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Failed to fetch video info for title, using default:", err);
|
console.warn("Failed to fetch video info for title, using default:", err);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
DownloadResult
|
DownloadResult
|
||||||
} from "./downloaders/BilibiliDownloader";
|
} from "./downloaders/BilibiliDownloader";
|
||||||
import { MissAVDownloader } from "./downloaders/MissAVDownloader";
|
import { MissAVDownloader } from "./downloaders/MissAVDownloader";
|
||||||
import { YouTubeDownloader } from "./downloaders/YouTubeDownloader";
|
import { YtDlpDownloader } from "./downloaders/YtDlpDownloader";
|
||||||
import { Video } from "./storageService";
|
import { Video } from "./storageService";
|
||||||
|
|
||||||
// Re-export types for compatibility
|
// Re-export types for compatibility
|
||||||
@@ -77,14 +77,14 @@ export async function downloadRemainingBilibiliParts(
|
|||||||
return BilibiliDownloader.downloadRemainingParts(baseUrl, startPart, totalParts, seriesTitle, collectionId, downloadId);
|
return BilibiliDownloader.downloadRemainingParts(baseUrl, startPart, totalParts, seriesTitle, collectionId, downloadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search for videos on YouTube
|
// Search for videos on YouTube (using yt-dlp)
|
||||||
export async function searchYouTube(query: string): Promise<any[]> {
|
export async function searchYouTube(query: string): Promise<any[]> {
|
||||||
return YouTubeDownloader.search(query);
|
return YtDlpDownloader.search(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download YouTube video
|
// 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 YouTubeDownloader.downloadVideo(videoUrl, downloadId, onStart);
|
return YtDlpDownloader.downloadVideo(videoUrl, downloadId, onStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to download MissAV video
|
// Helper function to download MissAV video
|
||||||
@@ -99,19 +99,12 @@ export async function getVideoInfo(url: string): Promise<{ title: string; author
|
|||||||
if (videoId) {
|
if (videoId) {
|
||||||
return BilibiliDownloader.getVideoInfo(videoId);
|
return BilibiliDownloader.getVideoInfo(videoId);
|
||||||
}
|
}
|
||||||
} else if (url.includes("youtube.com") || url.includes("youtu.be")) {
|
|
||||||
return YouTubeDownloader.getVideoInfo(url);
|
|
||||||
} else if (url.includes("missav")) {
|
} else if (url.includes("missav")) {
|
||||||
return MissAVDownloader.getVideoInfo(url);
|
return MissAVDownloader.getVideoInfo(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fallback
|
// Default fallback to yt-dlp for everything else
|
||||||
return {
|
return YtDlpDownloader.getVideoInfo(url);
|
||||||
title: "Video",
|
|
||||||
author: "Unknown",
|
|
||||||
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
|
||||||
thumbnailUrl: "",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Factory function to create a download task
|
// Factory function to create a download task
|
||||||
@@ -128,8 +121,8 @@ export function createDownloadTask(
|
|||||||
// Complex collection handling would require persisting more state
|
// Complex collection handling would require persisting more state
|
||||||
return BilibiliDownloader.downloadSinglePart(url, 1, 1, "");
|
return BilibiliDownloader.downloadSinglePart(url, 1, 1, "");
|
||||||
} else {
|
} else {
|
||||||
// Default to YouTube
|
// Default to yt-dlp
|
||||||
return YouTubeDownloader.downloadVideo(url, downloadId, registerCancel);
|
return YtDlpDownloader.downloadVideo(url, downloadId, registerCancel);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export class MissAVDownloader {
|
|||||||
// size= 12345kB time=00:01:23.45 bitrate= 1234.5kbits/s speed=1.23x
|
// 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 timeMatch = output.match(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
|
||||||
const sizeMatch = output.match(/size=\s*(\d+)([kMG]?B)/);
|
const sizeMatch = output.match(/size=\s*(\d+)([kMG]?B)/);
|
||||||
const speedMatch = output.match(/speed=\s*(\d+\.?\d*)x/);
|
const bitrateMatch = output.match(/bitrate=\s*(\d+\.?\d*)kbits\/s/);
|
||||||
|
|
||||||
if (timeMatch && downloadId) {
|
if (timeMatch && downloadId) {
|
||||||
const hours = parseInt(timeMatch[1]);
|
const hours = parseInt(timeMatch[1]);
|
||||||
@@ -233,9 +233,17 @@ export class MissAVDownloader {
|
|||||||
totalSizeStr = `${sizeMatch[1]}${sizeMatch[2]}`;
|
totalSizeStr = `${sizeMatch[1]}${sizeMatch[2]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let speedStr = "0x";
|
let speedStr = "0 B/s";
|
||||||
if (speedMatch) {
|
if (bitrateMatch) {
|
||||||
speedStr = `${speedMatch[1]}x`;
|
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, {
|
storageService.updateActiveDownload(downloadId, {
|
||||||
|
|||||||
@@ -7,12 +7,48 @@ import { sanitizeFilename } from "../../utils/helpers";
|
|||||||
import * as storageService from "../storageService";
|
import * as storageService from "../storageService";
|
||||||
import { Video } from "../storageService";
|
import { Video } from "../storageService";
|
||||||
|
|
||||||
export class YouTubeDownloader {
|
// Helper function to extract author from XiaoHongShu page when yt-dlp doesn't provide it
|
||||||
// Search for videos on YouTube
|
async function extractXiaoHongShuAuthor(url: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
console.log("Attempting to extract XiaoHongShu author from webpage...");
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': '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'
|
||||||
|
},
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = response.data;
|
||||||
|
|
||||||
|
// Try to find author name in the JSON data embedded in the page
|
||||||
|
// XiaoHongShu embeds data in window.__INITIAL_STATE__
|
||||||
|
const match = html.match(/"nickname":"([^"]+)"/);
|
||||||
|
if (match && match[1]) {
|
||||||
|
console.log("Found XiaoHongShu author:", match[1]);
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative: try to find in user info
|
||||||
|
const userMatch = html.match(/"user":\{[^}]*"nickname":"([^"]+)"/);
|
||||||
|
if (userMatch && userMatch[1]) {
|
||||||
|
console.log("Found XiaoHongShu author (user):", userMatch[1]);
|
||||||
|
return userMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Could not extract XiaoHongShu author from webpage");
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error extracting XiaoHongShu author:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YtDlpDownloader {
|
||||||
|
// Search for videos (primarily for YouTube, but could be adapted)
|
||||||
static async search(query: string): Promise<any[]> {
|
static async search(query: string): Promise<any[]> {
|
||||||
console.log("Processing search request for query:", query);
|
console.log("Processing search request for query:", query);
|
||||||
|
|
||||||
// Use youtube-dl to search for videos
|
// Use ytsearch for searching
|
||||||
const searchResults = await youtubedl(`ytsearch5:${query}`, {
|
const searchResults = await youtubedl(`ytsearch5:${query}`, {
|
||||||
dumpSingleJson: true,
|
dumpSingleJson: true,
|
||||||
noWarnings: true,
|
noWarnings: true,
|
||||||
@@ -33,7 +69,7 @@ export class YouTubeDownloader {
|
|||||||
thumbnailUrl: entry.thumbnail,
|
thumbnailUrl: entry.thumbnail,
|
||||||
duration: entry.duration,
|
duration: entry.duration,
|
||||||
viewCount: entry.view_count,
|
viewCount: entry.view_count,
|
||||||
sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`,
|
sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`, // Default to YT for search results
|
||||||
source: "youtube",
|
source: "youtube",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -52,29 +88,29 @@ export class YouTubeDownloader {
|
|||||||
noWarnings: true,
|
noWarnings: true,
|
||||||
callHome: false,
|
callHome: false,
|
||||||
preferFreeFormats: true,
|
preferFreeFormats: true,
|
||||||
youtubeSkipDashManifest: true,
|
// youtubeSkipDashManifest: true, // Specific to YT, might want to keep or make conditional
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: info.title || "YouTube Video",
|
title: info.title || "Video",
|
||||||
author: info.uploader || "YouTube User",
|
author: info.uploader || "Unknown",
|
||||||
date: info.upload_date || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
date: info.upload_date || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||||
thumbnailUrl: info.thumbnail,
|
thumbnailUrl: info.thumbnail,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching YouTube video info:", error);
|
console.error("Error fetching video info:", error);
|
||||||
return {
|
return {
|
||||||
title: "YouTube Video",
|
title: "Video",
|
||||||
author: "YouTube User",
|
author: "Unknown",
|
||||||
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||||
thumbnailUrl: "",
|
thumbnailUrl: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download YouTube video
|
// Download video
|
||||||
static async downloadVideo(videoUrl: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
|
static async downloadVideo(videoUrl: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
|
||||||
console.log("Detected YouTube URL");
|
console.log("Detected URL:", videoUrl);
|
||||||
|
|
||||||
// Create a safe base filename (without extension)
|
// Create a safe base filename (without extension)
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
@@ -84,36 +120,41 @@ export class YouTubeDownloader {
|
|||||||
const videoFilename = `${safeBaseFilename}.mp4`;
|
const videoFilename = `${safeBaseFilename}.mp4`;
|
||||||
const thumbnailFilename = `${safeBaseFilename}.jpg`;
|
const thumbnailFilename = `${safeBaseFilename}.jpg`;
|
||||||
|
|
||||||
// Set full paths for video and thumbnail
|
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved, source;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
|
|
||||||
let finalVideoFilename = videoFilename;
|
let finalVideoFilename = videoFilename;
|
||||||
let finalThumbnailFilename = thumbnailFilename;
|
let finalThumbnailFilename = thumbnailFilename;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get YouTube video info first
|
// Get video info first
|
||||||
const info = await youtubedl(videoUrl, {
|
const info = await youtubedl(videoUrl, {
|
||||||
dumpSingleJson: true,
|
dumpSingleJson: true,
|
||||||
noWarnings: true,
|
noWarnings: true,
|
||||||
callHome: false,
|
callHome: false,
|
||||||
preferFreeFormats: true,
|
preferFreeFormats: true,
|
||||||
youtubeSkipDashManifest: true,
|
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
console.log("YouTube video info:", {
|
console.log("Video info:", {
|
||||||
title: info.title,
|
title: info.title,
|
||||||
uploader: info.uploader,
|
uploader: info.uploader,
|
||||||
upload_date: info.upload_date,
|
upload_date: info.upload_date,
|
||||||
|
extractor: info.extractor,
|
||||||
});
|
});
|
||||||
|
|
||||||
videoTitle = info.title || "YouTube Video";
|
videoTitle = info.title || "Video";
|
||||||
videoAuthor = info.uploader || "YouTube User";
|
videoAuthor = info.uploader || "Unknown";
|
||||||
|
|
||||||
|
// If author is unknown and it's a XiaoHongShu video, try custom extraction
|
||||||
|
if ((!info.uploader || info.uploader === "Unknown") && info.extractor === "XiaoHongShu") {
|
||||||
|
const customAuthor = await extractXiaoHongShuAuthor(videoUrl);
|
||||||
|
if (customAuthor) {
|
||||||
|
videoAuthor = customAuthor;
|
||||||
|
}
|
||||||
|
}
|
||||||
videoDate =
|
videoDate =
|
||||||
info.upload_date ||
|
info.upload_date ||
|
||||||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||||
thumbnailUrl = info.thumbnail;
|
thumbnailUrl = info.thumbnail;
|
||||||
|
source = info.extractor || "generic";
|
||||||
|
|
||||||
// Update the safe base filename with the actual title
|
// Update the safe base filename with the actual title
|
||||||
const newSafeBaseFilename = `${sanitizeFilename(
|
const newSafeBaseFilename = `${sanitizeFilename(
|
||||||
@@ -130,8 +171,8 @@ export class YouTubeDownloader {
|
|||||||
const newVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
|
const newVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
|
||||||
const newThumbnailPath = path.join(IMAGES_DIR, finalThumbnailFilename);
|
const newThumbnailPath = path.join(IMAGES_DIR, finalThumbnailFilename);
|
||||||
|
|
||||||
// Download the YouTube video
|
// Download the video
|
||||||
console.log("Downloading YouTube video to:", newVideoPath);
|
console.log("Downloading video to:", newVideoPath);
|
||||||
|
|
||||||
if (downloadId) {
|
if (downloadId) {
|
||||||
storageService.updateActiveDownload(downloadId, {
|
storageService.updateActiveDownload(downloadId, {
|
||||||
@@ -140,20 +181,25 @@ export class YouTubeDownloader {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use exec to capture stdout for progress
|
// Prepare flags
|
||||||
// Format selection prioritizes Safari-compatible codecs (H.264/AAC)
|
const flags: any = {
|
||||||
// avc1 is the H.264 variant that Safari supports best
|
|
||||||
// Use Android client to avoid SABR streaming issues and JS runtime requirements
|
|
||||||
const subprocess = youtubedl.exec(videoUrl, {
|
|
||||||
output: newVideoPath,
|
output: newVideoPath,
|
||||||
format: "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a][acodec=aac]/bestvideo[ext=mp4][vcodec=h264]+bestaudio[ext=m4a]/best[ext=mp4]/best",
|
format: "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
|
||||||
mergeOutputFormat: "mp4",
|
mergeOutputFormat: "mp4",
|
||||||
'extractor-args': "youtube:player_client=android",
|
};
|
||||||
addHeader: [
|
|
||||||
|
// Add YouTube specific flags if it's a YouTube URL
|
||||||
|
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be")) {
|
||||||
|
flags.format = "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a][acodec=aac]/bestvideo[ext=mp4][vcodec=h264]+bestaudio[ext=m4a]/best[ext=mp4]/best";
|
||||||
|
flags['extractor-args'] = "youtube:player_client=android";
|
||||||
|
flags.addHeader = [
|
||||||
'Referer:https://www.youtube.com/',
|
'Referer:https://www.youtube.com/',
|
||||||
'User-Agent:Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
|
'User-Agent:Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
|
||||||
]
|
];
|
||||||
} as any);
|
}
|
||||||
|
|
||||||
|
// Use exec to capture stdout for progress
|
||||||
|
const subprocess = youtubedl.exec(videoUrl, flags);
|
||||||
|
|
||||||
if (onStart) {
|
if (onStart) {
|
||||||
onStart(() => {
|
onStart(() => {
|
||||||
@@ -163,7 +209,6 @@ export class YouTubeDownloader {
|
|||||||
// Clean up partial files
|
// Clean up partial files
|
||||||
console.log("Cleaning up partial files...");
|
console.log("Cleaning up partial files...");
|
||||||
try {
|
try {
|
||||||
// youtube-dl creates .part files during download
|
|
||||||
const partVideoPath = `${newVideoPath}.part`;
|
const partVideoPath = `${newVideoPath}.part`;
|
||||||
const partThumbnailPath = `${newThumbnailPath}.part`;
|
const partThumbnailPath = `${newThumbnailPath}.part`;
|
||||||
|
|
||||||
@@ -209,12 +254,11 @@ export class YouTubeDownloader {
|
|||||||
|
|
||||||
await subprocess;
|
await subprocess;
|
||||||
|
|
||||||
console.log("YouTube video downloaded successfully");
|
console.log("Video downloaded successfully");
|
||||||
|
|
||||||
// Download and save the thumbnail
|
// Download and save the thumbnail
|
||||||
thumbnailSaved = false;
|
thumbnailSaved = false;
|
||||||
|
|
||||||
// Download the thumbnail image
|
|
||||||
if (thumbnailUrl) {
|
if (thumbnailUrl) {
|
||||||
try {
|
try {
|
||||||
console.log("Downloading thumbnail from:", thumbnailUrl);
|
console.log("Downloading thumbnail from:", thumbnailUrl);
|
||||||
@@ -242,9 +286,9 @@ export class YouTubeDownloader {
|
|||||||
// Continue even if thumbnail download fails
|
// Continue even if thumbnail download fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (youtubeError) {
|
} catch (error) {
|
||||||
console.error("Error in YouTube download process:", youtubeError);
|
console.error("Error in download process:", error);
|
||||||
throw youtubeError;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create metadata for the video
|
// Create metadata for the video
|
||||||
@@ -254,7 +298,7 @@ export class YouTubeDownloader {
|
|||||||
author: videoAuthor || "Unknown",
|
author: videoAuthor || "Unknown",
|
||||||
date:
|
date:
|
||||||
videoDate || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
videoDate || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||||
source: "youtube",
|
source: source, // Use extracted source
|
||||||
sourceUrl: videoUrl,
|
sourceUrl: videoUrl,
|
||||||
videoFilename: finalVideoFilename,
|
videoFilename: finalVideoFilename,
|
||||||
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
|
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
|
||||||
@@ -269,12 +313,9 @@ export class YouTubeDownloader {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// If duration is missing from info, try to extract it from file
|
// If duration is missing from info, try to extract it from file
|
||||||
// We need to reconstruct the path because newVideoPath is not in scope here if we are outside the try block
|
|
||||||
// But wait, finalVideoFilename is available.
|
|
||||||
const finalVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
|
const finalVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Dynamic import to avoid circular dependency if any, though here it's fine
|
|
||||||
const { getVideoDuration } = await import("../../services/metadataService");
|
const { getVideoDuration } = await import("../../services/metadataService");
|
||||||
const duration = await getVideoDuration(finalVideoPath);
|
const duration = await getVideoDuration(finalVideoPath);
|
||||||
if (duration) {
|
if (duration) {
|
||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.2.3",
|
"version": "1.2.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.2.3",
|
"version": "1.2.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.4",
|
"version": "1.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const Footer = () => {
|
|||||||
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, justifyContent: 'center', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: { xs: 1, sm: 0 } }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', mt: { xs: 1, sm: 0 } }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mr: 2 }}>
|
||||||
|
v{import.meta.env.VITE_APP_VERSION}
|
||||||
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mr: 2 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mr: 2 }}>
|
||||||
Created by franklioxygen
|
Created by franklioxygen
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -123,10 +123,9 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
|
// Generic URL check
|
||||||
const bilibiliRegex = /^(https?:\/\/)?(www\.)?(bilibili\.com|b23\.tv)\/.+$/;
|
const urlRegex = /^(https?:\/\/[^\s]+)/;
|
||||||
const missavRegex = /^(https?:\/\/)?(www\.)?(missav\.(ai|ws|com))\/.+$/;
|
const isUrl = urlRegex.test(videoUrl);
|
||||||
const isUrl = youtubeRegex.test(videoUrl) || bilibiliRegex.test(videoUrl) || missavRegex.test(videoUrl);
|
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Delete,
|
Delete,
|
||||||
Folder,
|
Folder
|
||||||
Movie,
|
|
||||||
OndemandVideo,
|
|
||||||
YouTube
|
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -140,17 +137,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get source icon
|
|
||||||
const getSourceIcon = () => {
|
|
||||||
if (video.source === 'bilibili') {
|
|
||||||
return <OndemandVideo sx={{ color: '#23ade5' }} />; // Bilibili blue
|
|
||||||
} else if (video.source === 'local') {
|
|
||||||
return <Folder sx={{ color: '#4caf50' }} />; // Local green (using Folder as generic local icon, or maybe VideoFile if available)
|
|
||||||
} else if (video.source === 'missav') {
|
|
||||||
return <Movie sx={{ color: '#ff4081' }} />; // Pink for MissAV
|
|
||||||
}
|
|
||||||
return <YouTube sx={{ color: '#ff0000' }} />; // YouTube red
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -192,9 +179,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
|
|
||||||
{getSourceIcon()}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{video.partNumber && video.totalParts && video.totalParts > 1 && (
|
{video.partNumber && video.totalParts && video.totalParts > 1 && (
|
||||||
<Chip
|
<Chip
|
||||||
@@ -276,7 +261,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
|||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 40, // Positioned to the left of the source icon
|
right: 8,
|
||||||
bgcolor: 'rgba(0,0,0,0.6)',
|
bgcolor: 'rgba(0,0,0,0.6)',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
opacity: 0, // Hidden by default, shown on hover
|
opacity: 0, // Hidden by default, shown on hover
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
|||||||
)}
|
)}
|
||||||
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
|
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<VideoLibrary fontSize="small" sx={{ mr: 0.5 }} />
|
<VideoLibrary fontSize="small" sx={{ mr: 0.5 }} />
|
||||||
<strong>{t('source')}</strong> {video.source === 'bilibili' ? 'Bilibili' : (video.source === 'local' ? 'Local Upload' : 'YouTube')}
|
<strong>{t('source')}</strong> {video.source ? video.source.charAt(0).toUpperCase() + video.source.slice(1) : 'Unknown'}
|
||||||
</Typography>
|
</Typography>
|
||||||
{video.addedAt && (
|
{video.addedAt && (
|
||||||
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
|
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
|||||||
@@ -93,15 +93,40 @@ const DownloadPage: React.FC = () => {
|
|||||||
mutationFn: async (id: string) => {
|
mutationFn: async (id: string) => {
|
||||||
await axios.post(`${API_URL}/downloads/cancel/${id}`);
|
await axios.post(`${API_URL}/downloads/cancel/${id}`);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onMutate: async (id: string) => {
|
||||||
showSnackbar(t('downloadCancelled') || 'Download cancelled');
|
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||||
// DownloadContext handles active/queued updates via its own polling
|
await queryClient.cancelQueries({ queryKey: ['downloadStatus'] });
|
||||||
// But we might want to invalidate to be sure
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const previousStatus = queryClient.getQueryData(['downloadStatus']);
|
||||||
|
|
||||||
|
// Optimistically update to the new value
|
||||||
|
queryClient.setQueryData(['downloadStatus'], (old: any) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
activeDownloads: old.activeDownloads.filter((d: any) => d.id !== id),
|
||||||
|
queuedDownloads: old.queuedDownloads.filter((d: any) => d.id !== id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return a context object with the snapshotted value
|
||||||
|
return { previousStatus };
|
||||||
|
},
|
||||||
|
onError: (_err, _id, context) => {
|
||||||
|
// If the mutation fails, use the context returned from onMutate to roll back
|
||||||
|
if (context?.previousStatus) {
|
||||||
|
queryClient.setQueryData(['downloadStatus'], context.previousStatus);
|
||||||
|
}
|
||||||
|
showSnackbar(t('error') || 'Error');
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
// Always refetch after error or success:
|
||||||
queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
|
queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onSuccess: () => {
|
||||||
showSnackbar(t('error') || 'Error');
|
showSnackbar(t('downloadCancelled') || 'Download cancelled');
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCancelDownload = (id: string) => {
|
const handleCancelDownload = (id: string) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
import packageJson from './package.json';
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -7,4 +8,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5556,
|
port: 5556,
|
||||||
},
|
},
|
||||||
|
define: {
|
||||||
|
'import.meta.env.VITE_APP_VERSION': JSON.stringify(packageJson.version)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mytube",
|
"name": "mytube",
|
||||||
"version": "1.2.3",
|
"version": "1.2.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mytube",
|
"name": "mytube",
|
||||||
"version": "1.2.3",
|
"version": "1.2.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"concurrently": "^8.2.2"
|
"concurrently": "^8.2.2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mytube",
|
"name": "mytube",
|
||||||
"version": "1.2.4",
|
"version": "1.3.0",
|
||||||
"description": "YouTube video downloader and player application",
|
"description": "YouTube video downloader and player application",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user