Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88e452fc61 | ||
|
|
cffe2319c2 | ||
|
|
19383ad582 | ||
|
|
c2d6215b44 | ||
|
|
f2b5af0912 | ||
|
|
56557da2cf |
@@ -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.5",
|
||||
"version": "1.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "1.2.5",
|
||||
"version": "1.3.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "ts-node src/server.ts",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
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);
|
||||
|
||||
@@ -52,7 +52,6 @@ export class YtDlpDownloader {
|
||||
const searchResults = await youtubedl(`ytsearch5:${query}`, {
|
||||
dumpSingleJson: true,
|
||||
noWarnings: true,
|
||||
noCallHome: true,
|
||||
skipDownload: true,
|
||||
playlistEnd: 5, // Limit to 5 results
|
||||
} as any);
|
||||
@@ -86,7 +85,6 @@ export class YtDlpDownloader {
|
||||
const info = await youtubedl(url, {
|
||||
dumpSingleJson: true,
|
||||
noWarnings: true,
|
||||
callHome: false,
|
||||
preferFreeFormats: true,
|
||||
// youtubeSkipDashManifest: true, // Specific to YT, might want to keep or make conditional
|
||||
} as any);
|
||||
@@ -129,7 +127,6 @@ export class YtDlpDownloader {
|
||||
const info = await youtubedl(videoUrl, {
|
||||
dumpSingleJson: true,
|
||||
noWarnings: true,
|
||||
callHome: false,
|
||||
preferFreeFormats: true,
|
||||
} as any);
|
||||
|
||||
|
||||
@@ -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.5",
|
||||
"version": "1.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.2.5",
|
||||
"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.3.0",
|
||||
"version": "1.3.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.5",
|
||||
"version": "1.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mytube",
|
||||
"version": "1.2.5",
|
||||
"version": "1.3.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mytube",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.2",
|
||||
"description": "YouTube video downloader and player application",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user