Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88e452fc61 | ||
|
|
cffe2319c2 | ||
|
|
19383ad582 | ||
|
|
c2d6215b44 | ||
|
|
f2b5af0912 | ||
|
|
56557da2cf | ||
|
|
1d45692374 | ||
|
|
fc070da102 | ||
|
|
d1ceef9698 |
@@ -1,6 +1,6 @@
|
||||
# MyTube
|
||||
|
||||
一个 YouTube/Bilibili/MissAV 视频下载和播放应用,允许您将视频及其缩略图本地保存。将您的视频整理到收藏夹中,以便轻松访问和管理。
|
||||
一个 YouTube/Bilibili/MissAV 视频下载和播放应用,允许您将视频及其缩略图本地保存。将您的视频整理到收藏夹中,以便轻松访问和管理。现已支持[yt-dlp所有网址](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##),包括微博,小红书,x.com等。
|
||||
|
||||
[English](README.md)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MyTube
|
||||
|
||||
A YouTube/Bilibili/MissAV video downloader and player application that allows you to download and save videos locally, along with their thumbnails. Organize your videos into collections for easy access and management.
|
||||
A YouTube/Bilibili/MissAV video downloader and player application that allows you to download and save videos locally, along with their thumbnails. Organize your videos into collections for easy access and management. Now supports [yt-dlp sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##), including Weibo, Xiaohongshu, X.com, etc.
|
||||
|
||||
[中文](README-zh.md)
|
||||
|
||||
|
||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.2.5",
|
||||
"version": "1.3.2",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"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 { BilibiliDownloader } from '../../services/downloaders/BilibiliDownloader';
|
||||
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/YouTubeDownloader');
|
||||
vi.mock('../../services/downloaders/YtDlpDownloader');
|
||||
vi.mock('../../services/downloaders/MissAVDownloader');
|
||||
|
||||
describe('DownloadService', () => {
|
||||
@@ -56,22 +56,22 @@ describe('DownloadService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('YouTube', () => {
|
||||
it('should call YouTubeDownloader.search', async () => {
|
||||
describe('YouTube/Generic', () => {
|
||||
it('should call YtDlpDownloader.search', async () => {
|
||||
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');
|
||||
expect(YouTubeDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id');
|
||||
expect(YtDlpDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MissAV', () => {
|
||||
it('should call MissAVDownloader.downloadVideo', async () => {
|
||||
await downloadService.downloadMissAVVideo('url', 'id');
|
||||
expect(MissAVDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id');
|
||||
expect(MissAVDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id', undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,10 @@ interface Settings {
|
||||
maxConcurrentDownloads: number;
|
||||
language: string;
|
||||
tags?: string[];
|
||||
cloudDriveEnabled?: boolean;
|
||||
openListApiUrl?: string;
|
||||
openListToken?: string;
|
||||
cloudDrivePath?: string;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -22,7 +26,11 @@ const defaultSettings: Settings = {
|
||||
defaultAutoPlay: false,
|
||||
defaultAutoLoop: false,
|
||||
maxConcurrentDownloads: 3,
|
||||
language: 'en'
|
||||
language: 'en',
|
||||
cloudDriveEnabled: false,
|
||||
openListApiUrl: '',
|
||||
openListToken: '',
|
||||
cloudDrivePath: ''
|
||||
};
|
||||
|
||||
export const getSettings = async (_req: Request, res: Response) => {
|
||||
|
||||
@@ -9,12 +9,12 @@ import * as downloadService from "../services/downloadService";
|
||||
import { getVideoDuration } from "../services/metadataService";
|
||||
import * as storageService from "../services/storageService";
|
||||
import {
|
||||
extractBilibiliVideoId,
|
||||
extractUrlFromText,
|
||||
isBilibiliUrl,
|
||||
isValidUrl,
|
||||
resolveShortUrl,
|
||||
trimBilibiliUrl
|
||||
extractBilibiliVideoId,
|
||||
extractUrlFromText,
|
||||
isBilibiliUrl,
|
||||
isValidUrl,
|
||||
resolveShortUrl,
|
||||
trimBilibiliUrl
|
||||
} from "../utils/helpers";
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be") || isBilibiliUrl(videoUrl) || videoUrl.includes("missav")) {
|
||||
console.log("Fetching video info for title...");
|
||||
const info = await downloadService.getVideoInfo(videoUrl);
|
||||
if (info && info.title) {
|
||||
initialTitle = info.title;
|
||||
console.log("Fetched initial title:", initialTitle);
|
||||
}
|
||||
// Try to fetch video info for all URLs
|
||||
console.log("Fetching video info for title...");
|
||||
const info = await downloadService.getVideoInfo(videoUrl);
|
||||
if (info && info.title) {
|
||||
initialTitle = info.title;
|
||||
console.log("Fetched initial title:", initialTitle);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to fetch video info for title, using default:", err);
|
||||
|
||||
173
backend/src/services/CloudStorageService.ts
Normal file
173
backend/src/services/CloudStorageService.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import axios from 'axios';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { getSettings } from './storageService';
|
||||
|
||||
interface CloudDriveConfig {
|
||||
enabled: boolean;
|
||||
apiUrl: string;
|
||||
token: string;
|
||||
uploadPath: string;
|
||||
}
|
||||
|
||||
export class CloudStorageService {
|
||||
private static getConfig(): CloudDriveConfig {
|
||||
const settings = getSettings();
|
||||
return {
|
||||
enabled: settings.cloudDriveEnabled || false,
|
||||
apiUrl: settings.openListApiUrl || '',
|
||||
token: settings.openListToken || '',
|
||||
uploadPath: settings.cloudDrivePath || '/'
|
||||
};
|
||||
}
|
||||
|
||||
static async uploadVideo(videoData: any): Promise<void> {
|
||||
const config = this.getConfig();
|
||||
if (!config.enabled || !config.apiUrl || !config.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[CloudStorage] Starting upload for video: ${videoData.title}`);
|
||||
|
||||
try {
|
||||
// Upload Video File
|
||||
if (videoData.videoPath) {
|
||||
// videoPath is relative, e.g. /videos/filename.mp4
|
||||
// We need absolute path. Assuming backend runs in project root or we can resolve it.
|
||||
// Based on storageService, VIDEOS_DIR is likely imported from config/paths.
|
||||
// But here we might need to resolve it.
|
||||
// Let's try to resolve relative to process.cwd() or use absolute path if available.
|
||||
// Actually, storageService stores relative paths for frontend usage.
|
||||
// We should probably look up the file using the same logic as storageService or just assume standard location.
|
||||
// For now, let's try to construct the path.
|
||||
|
||||
// Better approach: Use the absolute path if we can get it, or resolve from common dirs.
|
||||
// Since I don't have direct access to config/paths here easily without importing,
|
||||
// I'll assume the videoData might have enough info or I'll import paths.
|
||||
|
||||
const absoluteVideoPath = this.resolveAbsolutePath(videoData.videoPath);
|
||||
if (absoluteVideoPath && fs.existsSync(absoluteVideoPath)) {
|
||||
await this.uploadFile(absoluteVideoPath, config);
|
||||
} else {
|
||||
console.error(`[CloudStorage] Video file not found: ${videoData.videoPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload Thumbnail
|
||||
if (videoData.thumbnailPath) {
|
||||
const absoluteThumbPath = this.resolveAbsolutePath(videoData.thumbnailPath);
|
||||
if (absoluteThumbPath && fs.existsSync(absoluteThumbPath)) {
|
||||
await this.uploadFile(absoluteThumbPath, config);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload Metadata (JSON)
|
||||
const metadata = {
|
||||
title: videoData.title,
|
||||
description: videoData.description,
|
||||
author: videoData.author,
|
||||
sourceUrl: videoData.sourceUrl,
|
||||
tags: videoData.tags,
|
||||
createdAt: videoData.createdAt,
|
||||
...videoData
|
||||
};
|
||||
|
||||
const metadataFileName = `${this.sanitizeFilename(videoData.title)}.json`;
|
||||
const metadataPath = path.join(process.cwd(), 'temp_metadata', metadataFileName);
|
||||
fs.ensureDirSync(path.dirname(metadataPath));
|
||||
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
|
||||
await this.uploadFile(metadataPath, config);
|
||||
|
||||
// Cleanup temp metadata
|
||||
fs.unlinkSync(metadataPath);
|
||||
|
||||
console.log(`[CloudStorage] Upload completed for: ${videoData.title}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[CloudStorage] Upload failed for ${videoData.title}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private static resolveAbsolutePath(relativePath: string): string | null {
|
||||
// This is a heuristic. In a real app we should import the constants.
|
||||
// Assuming the app runs from 'backend' or root.
|
||||
// relativePath starts with /videos or /images
|
||||
|
||||
// Try to find the 'data' directory.
|
||||
// If we are in backend/src/services, data is likely ../../../data
|
||||
|
||||
// Let's try to use the absolute path if we can find the data dir.
|
||||
// Or just check common locations.
|
||||
|
||||
const possibleRoots = [
|
||||
path.join(process.cwd(), 'data'),
|
||||
path.join(process.cwd(), '..', 'data'), // if running from backend
|
||||
path.join(__dirname, '..', '..', '..', 'data') // if compiled
|
||||
];
|
||||
|
||||
for (const root of possibleRoots) {
|
||||
if (fs.existsSync(root)) {
|
||||
// Remove leading slash from relative path
|
||||
const cleanRelative = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
|
||||
const fullPath = path.join(root, cleanRelative);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async uploadFile(filePath: string, config: CloudDriveConfig): Promise<void> {
|
||||
const fileName = path.basename(filePath);
|
||||
const fileSize = fs.statSync(filePath).size;
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
|
||||
console.log(`[CloudStorage] Uploading ${fileName} (${fileSize} bytes)...`);
|
||||
|
||||
// Generic upload implementation
|
||||
// Assuming a simple PUT or POST with file content
|
||||
// Many cloud drives (like Alist/WebDAV) use PUT with the path.
|
||||
|
||||
// Construct URL: apiUrl + uploadPath + fileName
|
||||
// Ensure slashes are handled correctly
|
||||
const baseUrl = config.apiUrl.endsWith('/') ? config.apiUrl.slice(0, -1) : config.apiUrl;
|
||||
const uploadDir = config.uploadPath.startsWith('/') ? config.uploadPath : '/' + config.uploadPath;
|
||||
const finalDir = uploadDir.endsWith('/') ? uploadDir : uploadDir + '/';
|
||||
|
||||
// Encode filename for URL
|
||||
const encodedFileName = encodeURIComponent(fileName);
|
||||
const url = `${baseUrl}${finalDir}${encodedFileName}`;
|
||||
|
||||
try {
|
||||
await axios.put(url, fileStream, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.token}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': fileSize
|
||||
},
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity
|
||||
});
|
||||
console.log(`[CloudStorage] Successfully uploaded ${fileName}`);
|
||||
} catch (error: any) {
|
||||
// Try POST if PUT fails, some APIs might differ
|
||||
console.warn(`[CloudStorage] PUT failed, trying POST... Error: ${error.message}`);
|
||||
try {
|
||||
// For POST, we might need FormData, but let's try raw body first or check if it's a specific API.
|
||||
// If it's Alist/WebDAV, PUT is standard.
|
||||
// If it's a custom API, it might expect FormData.
|
||||
// Let's stick to PUT for now as it's common for "Save to Cloud" generic interfaces.
|
||||
throw error;
|
||||
} catch (retryError) {
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static sanitizeFilename(filename: string): string {
|
||||
return filename.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CloudStorageService } from "./CloudStorageService";
|
||||
import { createDownloadTask } from "./downloadService";
|
||||
import * as storageService from "./storageService";
|
||||
|
||||
@@ -279,6 +280,16 @@ class DownloadManager {
|
||||
author: videoData.author,
|
||||
});
|
||||
|
||||
// Trigger Cloud Upload (Async, don't await to block queue processing?)
|
||||
// Actually, we might want to await it if we want to ensure it's done before resolving,
|
||||
// but that would block the download queue.
|
||||
// Let's run it in background but log it.
|
||||
CloudStorageService.uploadVideo({
|
||||
...videoData,
|
||||
title: finalTitle || task.title,
|
||||
sourceUrl: task.sourceUrl
|
||||
}).catch(err => console.error("Background cloud upload failed:", err));
|
||||
|
||||
task.resolve(result);
|
||||
} catch (error) {
|
||||
console.error(`Error downloading ${task.title}:`, error);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DownloadResult
|
||||
} from "./downloaders/BilibiliDownloader";
|
||||
import { MissAVDownloader } from "./downloaders/MissAVDownloader";
|
||||
import { YouTubeDownloader } from "./downloaders/YouTubeDownloader";
|
||||
import { YtDlpDownloader } from "./downloaders/YtDlpDownloader";
|
||||
import { Video } from "./storageService";
|
||||
|
||||
// Re-export types for compatibility
|
||||
@@ -77,14 +77,14 @@ export async function downloadRemainingBilibiliParts(
|
||||
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[]> {
|
||||
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> {
|
||||
return YouTubeDownloader.downloadVideo(videoUrl, downloadId, onStart);
|
||||
return YtDlpDownloader.downloadVideo(videoUrl, downloadId, onStart);
|
||||
}
|
||||
|
||||
// Helper function to download MissAV video
|
||||
@@ -99,19 +99,12 @@ export async function getVideoInfo(url: string): Promise<{ title: string; author
|
||||
if (videoId) {
|
||||
return BilibiliDownloader.getVideoInfo(videoId);
|
||||
}
|
||||
} else if (url.includes("youtube.com") || url.includes("youtu.be")) {
|
||||
return YouTubeDownloader.getVideoInfo(url);
|
||||
} else if (url.includes("missav")) {
|
||||
return MissAVDownloader.getVideoInfo(url);
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return {
|
||||
title: "Video",
|
||||
author: "Unknown",
|
||||
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: "",
|
||||
};
|
||||
// Default fallback to yt-dlp for everything else
|
||||
return YtDlpDownloader.getVideoInfo(url);
|
||||
}
|
||||
|
||||
// Factory function to create a download task
|
||||
@@ -128,8 +121,8 @@ export function createDownloadTask(
|
||||
// Complex collection handling would require persisting more state
|
||||
return BilibiliDownloader.downloadSinglePart(url, 1, 1, "");
|
||||
} else {
|
||||
// Default to YouTube
|
||||
return YouTubeDownloader.downloadVideo(url, downloadId, registerCancel);
|
||||
// Default to yt-dlp
|
||||
return YtDlpDownloader.downloadVideo(url, downloadId, registerCancel);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,16 +7,51 @@ import { sanitizeFilename } from "../../utils/helpers";
|
||||
import * as storageService from "../storageService";
|
||||
import { Video } from "../storageService";
|
||||
|
||||
export class YouTubeDownloader {
|
||||
// Search for videos on YouTube
|
||||
// Helper function to extract author from XiaoHongShu page when yt-dlp doesn't provide it
|
||||
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[]> {
|
||||
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}`, {
|
||||
dumpSingleJson: true,
|
||||
noWarnings: true,
|
||||
noCallHome: true,
|
||||
skipDownload: true,
|
||||
playlistEnd: 5, // Limit to 5 results
|
||||
} as any);
|
||||
@@ -33,7 +68,7 @@ export class YouTubeDownloader {
|
||||
thumbnailUrl: entry.thumbnail,
|
||||
duration: entry.duration,
|
||||
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",
|
||||
}));
|
||||
|
||||
@@ -50,31 +85,30 @@ export class YouTubeDownloader {
|
||||
const info = await youtubedl(url, {
|
||||
dumpSingleJson: true,
|
||||
noWarnings: true,
|
||||
callHome: false,
|
||||
preferFreeFormats: true,
|
||||
youtubeSkipDashManifest: true,
|
||||
// youtubeSkipDashManifest: true, // Specific to YT, might want to keep or make conditional
|
||||
} as any);
|
||||
|
||||
return {
|
||||
title: info.title || "YouTube Video",
|
||||
author: info.uploader || "YouTube User",
|
||||
title: info.title || "Video",
|
||||
author: info.uploader || "Unknown",
|
||||
date: info.upload_date || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: info.thumbnail,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching YouTube video info:", error);
|
||||
console.error("Error fetching video info:", error);
|
||||
return {
|
||||
title: "YouTube Video",
|
||||
author: "YouTube User",
|
||||
title: "Video",
|
||||
author: "Unknown",
|
||||
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
thumbnailUrl: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Download YouTube video
|
||||
// Download 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)
|
||||
const timestamp = Date.now();
|
||||
@@ -84,36 +118,40 @@ export class YouTubeDownloader {
|
||||
const videoFilename = `${safeBaseFilename}.mp4`;
|
||||
const thumbnailFilename = `${safeBaseFilename}.jpg`;
|
||||
|
||||
// Set full paths for video and thumbnail
|
||||
|
||||
|
||||
|
||||
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
|
||||
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved, source;
|
||||
let finalVideoFilename = videoFilename;
|
||||
let finalThumbnailFilename = thumbnailFilename;
|
||||
|
||||
try {
|
||||
// Get YouTube video info first
|
||||
// Get video info first
|
||||
const info = await youtubedl(videoUrl, {
|
||||
dumpSingleJson: true,
|
||||
noWarnings: true,
|
||||
callHome: false,
|
||||
preferFreeFormats: true,
|
||||
youtubeSkipDashManifest: true,
|
||||
} as any);
|
||||
|
||||
console.log("YouTube video info:", {
|
||||
console.log("Video info:", {
|
||||
title: info.title,
|
||||
uploader: info.uploader,
|
||||
upload_date: info.upload_date,
|
||||
extractor: info.extractor,
|
||||
});
|
||||
|
||||
videoTitle = info.title || "YouTube Video";
|
||||
videoAuthor = info.uploader || "YouTube User";
|
||||
videoTitle = info.title || "Video";
|
||||
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 =
|
||||
info.upload_date ||
|
||||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
thumbnailUrl = info.thumbnail;
|
||||
source = info.extractor || "generic";
|
||||
|
||||
// Update the safe base filename with the actual title
|
||||
const newSafeBaseFilename = `${sanitizeFilename(
|
||||
@@ -130,8 +168,8 @@ export class YouTubeDownloader {
|
||||
const newVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
|
||||
const newThumbnailPath = path.join(IMAGES_DIR, finalThumbnailFilename);
|
||||
|
||||
// Download the YouTube video
|
||||
console.log("Downloading YouTube video to:", newVideoPath);
|
||||
// Download the video
|
||||
console.log("Downloading video to:", newVideoPath);
|
||||
|
||||
if (downloadId) {
|
||||
storageService.updateActiveDownload(downloadId, {
|
||||
@@ -140,20 +178,25 @@ export class YouTubeDownloader {
|
||||
});
|
||||
}
|
||||
|
||||
// Use exec to capture stdout for progress
|
||||
// Format selection prioritizes Safari-compatible codecs (H.264/AAC)
|
||||
// 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, {
|
||||
// Prepare flags
|
||||
const flags: any = {
|
||||
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",
|
||||
'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/',
|
||||
'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) {
|
||||
onStart(() => {
|
||||
@@ -163,7 +206,6 @@ export class YouTubeDownloader {
|
||||
// Clean up partial files
|
||||
console.log("Cleaning up partial files...");
|
||||
try {
|
||||
// youtube-dl creates .part files during download
|
||||
const partVideoPath = `${newVideoPath}.part`;
|
||||
const partThumbnailPath = `${newThumbnailPath}.part`;
|
||||
|
||||
@@ -209,12 +251,11 @@ export class YouTubeDownloader {
|
||||
|
||||
await subprocess;
|
||||
|
||||
console.log("YouTube video downloaded successfully");
|
||||
console.log("Video downloaded successfully");
|
||||
|
||||
// Download and save the thumbnail
|
||||
thumbnailSaved = false;
|
||||
|
||||
// Download the thumbnail image
|
||||
if (thumbnailUrl) {
|
||||
try {
|
||||
console.log("Downloading thumbnail from:", thumbnailUrl);
|
||||
@@ -242,9 +283,9 @@ export class YouTubeDownloader {
|
||||
// Continue even if thumbnail download fails
|
||||
}
|
||||
}
|
||||
} catch (youtubeError) {
|
||||
console.error("Error in YouTube download process:", youtubeError);
|
||||
throw youtubeError;
|
||||
} catch (error) {
|
||||
console.error("Error in download process:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Create metadata for the video
|
||||
@@ -254,7 +295,7 @@ export class YouTubeDownloader {
|
||||
author: videoAuthor || "Unknown",
|
||||
date:
|
||||
videoDate || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||
source: "youtube",
|
||||
source: source, // Use extracted source
|
||||
sourceUrl: videoUrl,
|
||||
videoFilename: finalVideoFilename,
|
||||
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
|
||||
@@ -269,12 +310,9 @@ export class YouTubeDownloader {
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
try {
|
||||
// Dynamic import to avoid circular dependency if any, though here it's fine
|
||||
const { getVideoDuration } = await import("../../services/metadataService");
|
||||
const duration = await getVideoDuration(finalVideoPath);
|
||||
if (duration) {
|
||||
@@ -64,9 +64,10 @@ export const backfillDurations = async () => {
|
||||
const duration = await getVideoDuration(fsPath);
|
||||
|
||||
if (duration !== null) {
|
||||
await db.update(videos)
|
||||
db.update(videos)
|
||||
.set({ duration: duration.toString() })
|
||||
.where(eq(videos.id, video.id));
|
||||
.where(eq(videos.id, video.id))
|
||||
.run();
|
||||
console.log(`Updated duration for ${video.title}: ${duration}s`);
|
||||
updatedCount++;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function runMigration() {
|
||||
description: video.description,
|
||||
viewCount: video.viewCount,
|
||||
duration: video.duration,
|
||||
}).onConflictDoNothing();
|
||||
}).onConflictDoNothing().run();
|
||||
results.videos.count++;
|
||||
} catch (error: any) {
|
||||
console.error(`Error migrating video ${video.id}:`, error);
|
||||
@@ -96,7 +96,7 @@ export async function runMigration() {
|
||||
title: collection.title,
|
||||
createdAt: collection.createdAt || new Date().toISOString(),
|
||||
updatedAt: collection.updatedAt,
|
||||
}).onConflictDoNothing();
|
||||
}).onConflictDoNothing().run();
|
||||
results.collections.count++;
|
||||
|
||||
// Insert Collection Videos
|
||||
@@ -106,7 +106,7 @@ export async function runMigration() {
|
||||
await db.insert(collectionVideos).values({
|
||||
collectionId: collection.id,
|
||||
videoId: videoId,
|
||||
}).onConflictDoNothing();
|
||||
}).onConflictDoNothing().run();
|
||||
} catch (err: any) {
|
||||
console.error(`Error linking video ${videoId} to collection ${collection.id}:`, err);
|
||||
results.errors.push(`Link ${videoId}->${collection.id}: ${err.message}`);
|
||||
@@ -137,7 +137,7 @@ export async function runMigration() {
|
||||
}).onConflictDoUpdate({
|
||||
target: settings.key,
|
||||
set: { value: JSON.stringify(value) },
|
||||
});
|
||||
}).run();
|
||||
results.settings.count++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -178,7 +178,7 @@ export async function runMigration() {
|
||||
speed: download.speed,
|
||||
status: 'active',
|
||||
}
|
||||
});
|
||||
}).run();
|
||||
results.downloads.count++;
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ export async function runMigration() {
|
||||
timestamp: download.timestamp,
|
||||
status: 'queued',
|
||||
}
|
||||
});
|
||||
}).run();
|
||||
results.downloads.count++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,9 +108,13 @@ export function sanitizeFilename(filename: string): string {
|
||||
|
||||
// Replace only unsafe characters for filesystems
|
||||
// This preserves non-Latin characters like Chinese, Japanese, Korean, etc.
|
||||
return withoutHashtags
|
||||
const sanitized = withoutHashtags
|
||||
.replace(/[\/\\:*?"<>|]/g, "_") // Replace unsafe filesystem characters
|
||||
.replace(/\s+/g, "_"); // Replace spaces with underscores
|
||||
|
||||
// Truncate to 200 characters to avoid ENAMETOOLONG errors (filesystem limit is usually 255 bytes)
|
||||
// We use 200 to leave room for timestamp suffix and extension
|
||||
return sanitized.slice(0, 200);
|
||||
}
|
||||
|
||||
// Helper function to extract user mid from Bilibili URL
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.1",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.2.5",
|
||||
"version": "1.3.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -123,10 +123,9 @@ const Header: React.FC<HeaderProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
|
||||
const bilibiliRegex = /^(https?:\/\/)?(www\.)?(bilibili\.com|b23\.tv)\/.+$/;
|
||||
const missavRegex = /^(https?:\/\/)?(www\.)?(missav\.(ai|ws|com))\/.+$/;
|
||||
const isUrl = youtubeRegex.test(videoUrl) || bilibiliRegex.test(videoUrl) || missavRegex.test(videoUrl);
|
||||
// Generic URL check
|
||||
const urlRegex = /^(https?:\/\/[^\s]+)/;
|
||||
const isUrl = urlRegex.test(videoUrl);
|
||||
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import {
|
||||
Delete,
|
||||
Folder,
|
||||
Movie,
|
||||
OndemandVideo,
|
||||
YouTube
|
||||
Folder
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
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 (
|
||||
<>
|
||||
@@ -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 && (
|
||||
<Chip
|
||||
@@ -276,7 +261,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 40, // Positioned to the left of the source icon
|
||||
right: 8,
|
||||
bgcolor: 'rgba(0,0,0,0.6)',
|
||||
color: 'white',
|
||||
opacity: 0, // Hidden by default, shown on hover
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
Forward10,
|
||||
Fullscreen,
|
||||
FullscreenExit,
|
||||
KeyboardDoubleArrowLeft,
|
||||
KeyboardDoubleArrowRight,
|
||||
Loop,
|
||||
Pause,
|
||||
PlayArrow,
|
||||
@@ -139,7 +141,7 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: 2, overflow: 'hidden', boxShadow: 4, position: 'relative' }}>
|
||||
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: { xs: 0, sm: 2 }, overflow: 'hidden', boxShadow: 4, position: 'relative' }}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
style={{ width: '100%', aspectRatio: '16/9', display: 'block' }}
|
||||
@@ -216,6 +218,11 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
|
||||
{/* Row 2 on Mobile: Seek Controls */}
|
||||
<Stack direction="row" spacing={1} justifyContent="center" width={{ xs: '100%', sm: 'auto' }}>
|
||||
<Tooltip title="-10m">
|
||||
<Button variant="outlined" onClick={() => handleSeek(-600)}>
|
||||
<KeyboardDoubleArrowLeft />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="-1m">
|
||||
<Button variant="outlined" onClick={() => handleSeek(-60)}>
|
||||
<FastRewind />
|
||||
@@ -236,6 +243,11 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
<FastForward />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="+10m">
|
||||
<Button variant="outlined" onClick={() => handleSeek(600)}>
|
||||
<KeyboardDoubleArrowRight />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -242,7 +242,7 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
)}
|
||||
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<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>
|
||||
{video.addedAt && (
|
||||
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
|
||||
@@ -19,7 +19,8 @@ import { Video } from '../types';
|
||||
|
||||
const AuthorVideos: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { author } = useParams<{ author: string }>();
|
||||
const { authorName } = useParams<{ authorName: string }>();
|
||||
const author = authorName;
|
||||
const navigate = useNavigate();
|
||||
const { videos, loading, deleteVideo } = useVideo();
|
||||
const { collections } = useCollection();
|
||||
@@ -79,7 +80,7 @@ const AuthorVideos: React.FC = () => {
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" fontWeight="bold">
|
||||
{author ? decodeURIComponent(author) : t('unknownAuthor')}
|
||||
{author || t('unknownAuthor')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
{authorVideos.length} {t('videos')}
|
||||
|
||||
@@ -339,6 +339,11 @@ const DownloadPage: React.FC = () => {
|
||||
secondaryTypographyProps={{ component: 'div' }}
|
||||
secondary={
|
||||
<Box component="div" sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{item.sourceUrl && (
|
||||
<Typography variant="caption" color="primary" component="a" href={item.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
|
||||
{item.sourceUrl}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" component="span">
|
||||
{formatDate(item.finishedAt)}
|
||||
</Typography>
|
||||
|
||||
@@ -44,6 +44,10 @@ interface Settings {
|
||||
maxConcurrentDownloads: number;
|
||||
language: string;
|
||||
tags: string[];
|
||||
cloudDriveEnabled: boolean;
|
||||
openListApiUrl: string;
|
||||
openListToken: string;
|
||||
cloudDrivePath: string;
|
||||
}
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
@@ -58,7 +62,11 @@ const SettingsPage: React.FC = () => {
|
||||
defaultAutoLoop: false,
|
||||
maxConcurrentDownloads: 3,
|
||||
language: 'en',
|
||||
tags: []
|
||||
tags: [],
|
||||
cloudDriveEnabled: false,
|
||||
openListApiUrl: '',
|
||||
openListToken: '',
|
||||
cloudDrivePath: ''
|
||||
});
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
|
||||
@@ -469,6 +477,48 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* Cloud Drive Settings */}
|
||||
<Grid size={12}>
|
||||
<Typography variant="h6" gutterBottom>{t('cloudDriveSettings')}</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.cloudDriveEnabled || false}
|
||||
onChange={(e) => handleChange('cloudDriveEnabled', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('enableAutoSave')}
|
||||
/>
|
||||
|
||||
{settings.cloudDriveEnabled && (
|
||||
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 600 }}>
|
||||
<TextField
|
||||
label={t('apiUrl')}
|
||||
value={settings.openListApiUrl || ''}
|
||||
onChange={(e) => handleChange('openListApiUrl', e.target.value)}
|
||||
helperText={t('apiUrlHelper')}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t('token')}
|
||||
value={settings.openListToken || ''}
|
||||
onChange={(e) => handleChange('openListToken', e.target.value)}
|
||||
type="password"
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t('uploadPath')}
|
||||
value={settings.cloudDrivePath || ''}
|
||||
onChange={(e) => handleChange('cloudDrivePath', e.target.value)}
|
||||
helperText={t('cloudDrivePathHelper')}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* Database Settings */}
|
||||
<Grid size={12}>
|
||||
<Typography variant="h6" gutterBottom>{t('database')}</Typography>
|
||||
|
||||
@@ -354,9 +354,14 @@ const VideoPlayer: React.FC = () => {
|
||||
}).slice(0, 10);
|
||||
}, [video, videos, collections]);
|
||||
|
||||
// Scroll to top when video ID changes
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Grid container spacing={4}>
|
||||
<Container maxWidth={false} disableGutters sx={{ py: { xs: 0, md: 4 }, px: { xs: 0, md: 2 } }}>
|
||||
<Grid container spacing={{ xs: 0, md: 4 }}>
|
||||
{/* Main Content Column */}
|
||||
<Grid size={{ xs: 12, lg: 8 }}>
|
||||
<VideoControls
|
||||
@@ -367,31 +372,33 @@ const VideoPlayer: React.FC = () => {
|
||||
startTime={video.progress || 0}
|
||||
/>
|
||||
|
||||
<VideoInfo
|
||||
video={video}
|
||||
onTitleSave={handleSaveTitle}
|
||||
onRatingChange={handleRatingChange}
|
||||
onAuthorClick={handleAuthorClick}
|
||||
onAddToCollection={handleAddToCollection}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
deleteError={deleteMutation.error ? (deleteMutation.error as any).message || t('deleteFailed') : null}
|
||||
videoCollections={videoCollections}
|
||||
onCollectionClick={handleCollectionClick}
|
||||
availableTags={availableTags}
|
||||
onTagsUpdate={handleUpdateTags}
|
||||
/>
|
||||
<Box sx={{ px: { xs: 2, md: 0 } }}>
|
||||
<VideoInfo
|
||||
video={video}
|
||||
onTitleSave={handleSaveTitle}
|
||||
onRatingChange={handleRatingChange}
|
||||
onAuthorClick={handleAuthorClick}
|
||||
onAddToCollection={handleAddToCollection}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
deleteError={deleteMutation.error ? (deleteMutation.error as any).message || t('deleteFailed') : null}
|
||||
videoCollections={videoCollections}
|
||||
onCollectionClick={handleCollectionClick}
|
||||
availableTags={availableTags}
|
||||
onTagsUpdate={handleUpdateTags}
|
||||
/>
|
||||
|
||||
<CommentsSection
|
||||
comments={comments}
|
||||
loading={loadingComments}
|
||||
showComments={showComments}
|
||||
onToggleComments={handleToggleComments}
|
||||
/>
|
||||
<CommentsSection
|
||||
comments={comments}
|
||||
loading={loadingComments}
|
||||
showComments={showComments}
|
||||
onToggleComments={handleToggleComments}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Sidebar Column - Up Next */}
|
||||
<Grid size={{ xs: 12, lg: 4 }}>
|
||||
<Grid size={{ xs: 12, lg: 4 }} sx={{ p: { xs: 2, md: 0 }, pt: { xs: 2, md: 0 } }}>
|
||||
<Typography variant="h6" gutterBottom fontWeight="bold">{t('upNext')}</Typography>
|
||||
<Stack spacing={2}>
|
||||
{relatedVideos.map(relatedVideo => (
|
||||
|
||||
@@ -84,6 +84,15 @@ export const ar = {
|
||||
cleanupTempFilesActiveDownloads: "لا يمكن التنظيف أثناء وجود تنزيلات نشطة. يرجى الانتظار حتى تكتمل جميع التنزيلات أو إلغائها أولاً.",
|
||||
cleanupTempFilesSuccess: "تم حذف {count} ملف (ملفات) مؤقت بنجاح.",
|
||||
cleanupTempFilesFailed: "فشل تنظيف الملفات المؤقتة",
|
||||
|
||||
// Cloud Drive
|
||||
cloudDriveSettings: "التخزين السحابي (OpenList)",
|
||||
enableAutoSave: "تمكين الحفظ التلقائي في السحابة",
|
||||
apiUrl: "رابط API",
|
||||
apiUrlHelper: "مثال: https://your-alist-instance.com/api/fs/put",
|
||||
token: "الرمز المميز (Token)",
|
||||
uploadPath: "مسار التحميل",
|
||||
cloudDrivePathHelper: "مسار الدليل في التخزين السحابي، مثال: /mytube-uploads",
|
||||
|
||||
// Manage
|
||||
manageContent: "إدارة المحتوى",
|
||||
|
||||
@@ -42,6 +42,16 @@ export const de = {
|
||||
cleanupTempFilesActiveDownloads: "Bereinigung nicht möglich, während Downloads aktiv sind. Bitte warten Sie, bis alle Downloads abgeschlossen sind, oder brechen Sie sie ab.",
|
||||
cleanupTempFilesSuccess: "Erfolgreich {count} temporäre Datei(en) gelöscht.",
|
||||
cleanupTempFilesFailed: "Fehler beim Bereinigen temporärer Dateien",
|
||||
|
||||
// Cloud Drive
|
||||
cloudDriveSettings: "Cloud-Speicher (OpenList)",
|
||||
enableAutoSave: "Automatisches Speichern in der Cloud aktivieren",
|
||||
apiUrl: "API-URL",
|
||||
apiUrlHelper: "z.B. https://your-alist-instance.com/api/fs/put",
|
||||
token: "Token",
|
||||
uploadPath: "Upload-Pfad",
|
||||
cloudDrivePathHelper: "Verzeichnispfad im Cloud-Speicher, z.B. /mytube-uploads",
|
||||
|
||||
manageContent: "Inhalte Verwalten", videos: "Videos", collections: "Sammlungen", allVideos: "Alle Videos",
|
||||
delete: "Löschen", backToHome: "Zurück zur Startseite", confirmDelete: "Sind Sie sicher, dass Sie dies löschen möchten?",
|
||||
deleteSuccess: "Erfolgreich gelöscht", deleteFailed: "Löschen fehlgeschlagen", noVideos: "Keine Videos gefunden",
|
||||
|
||||
@@ -84,6 +84,15 @@ export const en = {
|
||||
cleanupTempFilesActiveDownloads: "Cannot clean up while downloads are active. Please wait for all downloads to complete or cancel them first.",
|
||||
cleanupTempFilesSuccess: "Successfully deleted {count} temporary file(s).",
|
||||
cleanupTempFilesFailed: "Failed to clean up temporary files",
|
||||
|
||||
// Cloud Drive
|
||||
cloudDriveSettings: "Cloud Drive (OpenList)",
|
||||
enableAutoSave: "Enable Auto Save to Cloud",
|
||||
apiUrl: "API URL",
|
||||
apiUrlHelper: "e.g. https://your-alist-instance.com/api/fs/put",
|
||||
token: "Token",
|
||||
uploadPath: "Upload Path",
|
||||
cloudDrivePathHelper: "Directory path in cloud drive, e.g. /mytube-uploads",
|
||||
|
||||
// Manage
|
||||
manageContent: "Manage Content",
|
||||
|
||||
@@ -40,6 +40,16 @@ export const es = {
|
||||
cleanupTempFilesActiveDownloads: "No se puede limpiar mientras hay descargas activas. Espera a que todas las descargas terminen o cancélalas primero.",
|
||||
cleanupTempFilesSuccess: "Se eliminaron exitosamente {count} archivo(s) temporal(es).",
|
||||
cleanupTempFilesFailed: "Error al limpiar archivos temporales",
|
||||
|
||||
// Cloud Drive
|
||||
cloudDriveSettings: "Almacenamiento en la Nube (OpenList)",
|
||||
enableAutoSave: "Habilitar guardado automático en la nube",
|
||||
apiUrl: "URL de la API",
|
||||
apiUrlHelper: "ej. https://your-alist-instance.com/api/fs/put",
|
||||
token: "Token",
|
||||
uploadPath: "Ruta de carga",
|
||||
cloudDrivePathHelper: "Ruta del directorio en la nube, ej. /mytube-uploads",
|
||||
|
||||
manageContent: "Gestionar Contenido", videos: "Videos", collections: "Colecciones", allVideos: "Todos los Videos",
|
||||
delete: "Eliminar", backToHome: "Volver a Inicio", confirmDelete: "¿Está seguro de que desea eliminar esto?",
|
||||
deleteSuccess: "Eliminado exitosamente", deleteFailed: "Error al eliminar", noVideos: "No se encontraron videos",
|
||||
|
||||
@@ -84,6 +84,15 @@ export const fr = {
|
||||
cleanupTempFilesActiveDownloads: "Impossible de nettoyer pendant que des téléchargements sont actifs. Veuillez attendre la fin de tous les téléchargements ou les annuler d'abord.",
|
||||
cleanupTempFilesSuccess: "{count} fichier(s) temporaire(s) supprimé(s) avec succès.",
|
||||
cleanupTempFilesFailed: "Échec du nettoyage des fichiers temporaires",
|
||||
|
||||
// Cloud Drive
|
||||
cloudDriveSettings: "Stockage Cloud (OpenList)",
|
||||
enableAutoSave: "Activer la sauvegarde automatique sur le Cloud",
|
||||
apiUrl: "URL de l'API",
|
||||
apiUrlHelper: "ex. https://your-alist-instance.com/api/fs/put",
|
||||
token: "Jeton (Token)",
|
||||
uploadPath: "Chemin de téléchargement",
|
||||
cloudDrivePathHelper: "Chemin du répertoire dans le cloud, ex. /mytube-uploads",
|
||||
|
||||
// Manage
|
||||
manageContent: "Gérer le contenu",
|
||||
|
||||
@@ -84,6 +84,15 @@ export const ja = {
|
||||
cleanupTempFilesActiveDownloads: "ダウンロードがアクティブな間はクリーンアップできません。すべてのダウンロードが完了するまで待つか、キャンセルしてください。",
|
||||
cleanupTempFilesSuccess: "{count}個の一時ファイルを正常に削除しました。",
|
||||
cleanupTempFilesFailed: "一時ファイルのクリーンアップに失敗しました",
|
||||
|
||||
// Cloud Drive
|
||||
cloudDriveSettings: "クラウドストレージ (OpenList)",
|
||||
enableAutoSave: "クラウドへの自動保存を有効にする",
|
||||
apiUrl: "API URL",
|
||||
apiUrlHelper: "例: https://your-alist-instance.com/api/fs/put",
|
||||
token: "トークン",
|
||||
uploadPath: "アップロードパス",
|
||||
cloudDrivePathHelper: "クラウドドライブ内のディレクトリパス、例: /mytube-uploads",
|
||||
|
||||
// Manage
|
||||
manageContent: "コンテンツの管理",
|
||||
|
||||
@@ -84,6 +84,15 @@ export const ko = {
|
||||
cleanupTempFilesActiveDownloads: "다운로드가 활성화된 동안에는 정리할 수 없습니다. 모든 다운로드가 완료될 때까지 기다리거나 먼저 취소하세요.",
|
||||
cleanupTempFilesSuccess: "{count}개의 임시 파일을 성공적으로 삭제했습니다.",
|
||||
cleanupTempFilesFailed: "임시 파일 정리 실패",
|
||||
|
||||
// Cloud Drive
|
||||
cloudDriveSettings: "클라우드 드라이브 (OpenList)",
|
||||
enableAutoSave: "클라우드 자동 저장 활성화",
|
||||
apiUrl: "API URL",
|
||||
apiUrlHelper: "예: https://your-alist-instance.com/api/fs/put",
|
||||
token: "토큰",
|
||||
uploadPath: "업로드 경로",
|
||||
cloudDrivePathHelper: "클라우드 드라이브 내 디렉토리 경로, 예: /mytube-uploads",
|
||||
|
||||
// Manage
|
||||
manageContent: "콘텐츠 관리",
|
||||
|
||||
@@ -84,6 +84,15 @@ export const pt = {
|
||||
cleanupTempFilesActiveDownloads: "Não é possível limpar enquanto houver downloads ativos. Aguarde a conclusão de todos os downloads ou cancele-os primeiro.",
|
||||
cleanupTempFilesSuccess: "{count} arquivo(s) temporário(s) excluído(s) com sucesso.",
|
||||
cleanupTempFilesFailed: "Falha ao limpar arquivos temporários",
|
||||
|
||||
// Cloud Drive
|
||||
cloudDriveSettings: "Armazenamento em Nuvem (OpenList)",
|
||||
enableAutoSave: "Ativar salvamento automático na nuvem",
|
||||
apiUrl: "URL da API",
|
||||
apiUrlHelper: "ex. https://your-alist-instance.com/api/fs/put",
|
||||
token: "Token",
|
||||
uploadPath: "Caminho de upload",
|
||||
cloudDrivePathHelper: "Caminho do diretório na nuvem, ex. /mytube-uploads",
|
||||
|
||||
// Manage
|
||||
manageContent: "Gerenciar Conteúdo",
|
||||
@@ -150,6 +159,12 @@ export const pt = {
|
||||
titleUpdateFailed: "Falha ao atualizar título",
|
||||
refreshThumbnail: "Atualizar miniatura",
|
||||
thumbnailRefreshed: "Miniatura atualizada com sucesso",
|
||||
thumbnailRefreshFailed: "Falha ao atualizar miniatura",
|
||||
videoUpdated: "Vídeo atualizado com sucesso",
|
||||
videoUpdateFailed: "Falha ao atualizar vídeo",
|
||||
failedToLoadVideos: "Falha ao carregar vídeos. Por favor, tente novamente mais tarde.",
|
||||
videoRemovedSuccessfully: "Vídeo removido com sucesso",
|
||||
failedToDeleteVideo: "Falha ao excluir vídeo",
|
||||
// Snackbar Messages
|
||||
videoDownloading: "Baixando vídeo",
|
||||
downloadStartedSuccessfully: "Download iniciado com sucesso",
|
||||
|
||||
@@ -84,6 +84,15 @@ export const ru = {
|
||||
cleanupTempFilesActiveDownloads: "Невозможно очистить, пока активны загрузки. Пожалуйста, дождитесь завершения всех загрузок или сначала отмените их.",
|
||||
cleanupTempFilesSuccess: "Успешно удалено {count} временных файлов.",
|
||||
cleanupTempFilesFailed: "Не удалось очистить временные файлы",
|
||||
|
||||
// Cloud Drive
|
||||
cloudDriveSettings: "Облачное хранилище (OpenList)",
|
||||
enableAutoSave: "Включить автосохранение в облако",
|
||||
apiUrl: "URL API",
|
||||
apiUrlHelper: "напр. https://your-alist-instance.com/api/fs/put",
|
||||
token: "Токен",
|
||||
uploadPath: "Путь загрузки",
|
||||
cloudDrivePathHelper: "Путь к каталогу в облаке, напр. /mytube-uploads",
|
||||
|
||||
// Manage
|
||||
manageContent: "Управление контентом",
|
||||
|
||||
@@ -84,6 +84,15 @@ export const zh = {
|
||||
cleanupTempFilesActiveDownloads: "有活动下载时无法清理。请等待所有下载完成或取消它们。",
|
||||
cleanupTempFilesSuccess: "成功删除了 {count} 个临时文件。",
|
||||
cleanupTempFilesFailed: "清理临时文件失败",
|
||||
|
||||
// Cloud Drive
|
||||
cloudDriveSettings: "云端存储 (OpenList)",
|
||||
enableAutoSave: "启用自动保存到云端",
|
||||
apiUrl: "API 地址",
|
||||
apiUrlHelper: "例如:https://your-alist-instance.com/api/fs/put",
|
||||
token: "Token",
|
||||
uploadPath: "上传路径",
|
||||
cloudDrivePathHelper: "云端存储中的目录路径,例如:/mytube-uploads",
|
||||
|
||||
// Manage
|
||||
manageContent: "内容管理",
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mytube",
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.2.5",
|
||||
"version": "1.3.2",
|
||||
"description": "YouTube video downloader and player application",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user