547 lines
20 KiB
TypeScript
547 lines
20 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
import {
|
|
apiDownloaderDownloadRetrieve,
|
|
} from '@/api/generated/public/downloader';
|
|
import { type VideoInfoResponse } from '@/api/generated/public/models';
|
|
import { FaLink, FaVideo, FaVolumeUp, FaFile, FaFont, FaCookie } from 'react-icons/fa';
|
|
import Ad from './Ad';
|
|
import Statistics from './Statistics';
|
|
import { publicApi } from '@/api/publicClient';
|
|
|
|
// Common file extensions supported by ffmpeg
|
|
const FILE_EXTENSIONS = [
|
|
{ value: 'mp4', label: 'MP4 (H.264 + AAC, most compatible)' },
|
|
{ value: 'mkv', label: 'MKV (Flexible, lossless container)' },
|
|
{ value: 'webm', label: 'WebM (VP9/AV1 + Opus)' },
|
|
{ value: 'avi', label: 'AVI (Older format)' },
|
|
{ value: 'mov', label: 'MOV (Apple-friendly)' },
|
|
{ value: 'flv', label: 'FLV (Legacy)' },
|
|
{ value: 'ogg', label: 'OGG (Audio/Video)' },
|
|
];
|
|
|
|
export default function Downloader() {
|
|
const [videoUrl, setVideoUrl] = useState('');
|
|
const [videoInfo, setVideoInfo] = useState<null | VideoInfoResponse>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isDownloading, setIsDownloading] = useState(false);
|
|
const [downloadStatus, setDownloadStatus] = useState<string>('');
|
|
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
|
const [error, setError] = useState<null | { error: string }>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
|
|
// Basic download options
|
|
const [selectedVideoQuality, setSelectedVideoQuality] = useState<string>('');
|
|
const [selectedAudioQuality, setSelectedAudioQuality] = useState<string>('');
|
|
const [selectedExtension, setSelectedExtension] = useState<string>('mp4');
|
|
|
|
// Playlist selection
|
|
const [selectedVideos, setSelectedVideos] = useState<number[]>([]);
|
|
|
|
// Advanced options
|
|
const [subtitles, setSubtitles] = useState<string>('');
|
|
const [embedSubtitles, setEmbedSubtitles] = useState<boolean>(false);
|
|
const [embedThumbnail, setEmbedThumbnail] = useState<boolean>(false);
|
|
const [cookies, setCookies] = useState<string>('');
|
|
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
|
|
|
|
// Helper functions for playlist selection
|
|
const toggleVideoSelection = (videoIndex: number) => {
|
|
setSelectedVideos(prev =>
|
|
prev.includes(videoIndex)
|
|
? prev.filter(i => i !== videoIndex)
|
|
: [...prev, videoIndex]
|
|
);
|
|
};
|
|
|
|
const selectAllVideos = () => {
|
|
if (videoInfo?.videos) {
|
|
setSelectedVideos(videoInfo.videos.map((_, index) => index + 1));
|
|
}
|
|
};
|
|
|
|
const deselectAllVideos = () => {
|
|
setSelectedVideos([]);
|
|
};
|
|
|
|
// Cleanup WebSocket on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (wsRef.current) {
|
|
wsRef.current.close();
|
|
}
|
|
};
|
|
}, []);
|
|
const getAllVideoQualities = () => {
|
|
if (!videoInfo?.videos) return [];
|
|
const allQualities = new Set<string>();
|
|
videoInfo.videos.forEach(video => {
|
|
video.video_resolutions.forEach(quality => allQualities.add(quality));
|
|
});
|
|
return Array.from(allQualities).sort((a, b) => {
|
|
const aNum = parseInt(a.replace('p', ''));
|
|
const bNum = parseInt(b.replace('p', ''));
|
|
return bNum - aNum;
|
|
});
|
|
};
|
|
|
|
const getAllAudioQualities = () => {
|
|
if (!videoInfo?.videos) return [];
|
|
const allQualities = new Set<string>();
|
|
videoInfo.videos.forEach(video => {
|
|
video.audio_resolutions.forEach(quality => allQualities.add(quality));
|
|
});
|
|
return Array.from(allQualities).sort((a, b) => {
|
|
const aNum = parseInt(a.replace('kbps', ''));
|
|
const bNum = parseInt(b.replace('kbps', ''));
|
|
return bNum - aNum;
|
|
});
|
|
};
|
|
|
|
async function retrieveVideoInfo() {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
setVideoInfo(null);
|
|
setSelectedVideos([]);
|
|
try {
|
|
const info = await apiDownloaderDownloadRetrieve({ url: videoUrl });
|
|
setVideoInfo(info);
|
|
// If it's a playlist, select all videos by default
|
|
if (info.is_playlist && info.videos) {
|
|
setSelectedVideos(info.videos.map((_, index) => index + 1));
|
|
}
|
|
|
|
} catch (err: any) {
|
|
const errorMessage = err.response?.data?.error || err.message;
|
|
setError({ error: errorMessage });
|
|
console.error('Retrieve video info error:', err);
|
|
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleDownload() {
|
|
if (!videoUrl) {
|
|
setError({ error: 'Please enter a URL first' });
|
|
return;
|
|
}
|
|
|
|
setIsDownloading(true);
|
|
setDownloadStatus('Connecting…');
|
|
setDownloadProgress(null);
|
|
setError(null);
|
|
|
|
try {
|
|
const videoQuality = selectedVideoQuality
|
|
? parseInt(selectedVideoQuality.replace('p', ''))
|
|
: undefined;
|
|
const audioQuality = selectedAudioQuality
|
|
? parseInt(selectedAudioQuality.replace('kbps', ''))
|
|
: undefined;
|
|
|
|
// Connect to WebSocket
|
|
const wsUrl = `ws://localhost:8000/ws/downloader/`;
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
ws.onopen = () => {
|
|
setDownloadStatus('Starting…');
|
|
// Send download parameters
|
|
ws.send(JSON.stringify({
|
|
url: videoUrl,
|
|
ext: selectedExtension,
|
|
video_quality: videoQuality,
|
|
audio_quality: audioQuality,
|
|
selected_videos: videoInfo?.is_playlist && selectedVideos.length > 0 ? selectedVideos : null,
|
|
subtitles: subtitles || null,
|
|
embed_subtitles: embedSubtitles,
|
|
embed_thumbnail: embedThumbnail,
|
|
cookies: cookies || null,
|
|
}));
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === 'status') {
|
|
setDownloadStatus(data.message);
|
|
} else if (data.type === 'progress') {
|
|
setDownloadStatus(data.message);
|
|
setDownloadProgress(data.percent);
|
|
} else if (data.type === 'done') {
|
|
|
|
setDownloadStatus('Finalizing download...');
|
|
setDownloadProgress(100);
|
|
|
|
// Přidáme responseType: 'blob', aby Axios/Fetch vrátil soubor správně
|
|
publicApi.post('/api/downloader/download/file/',
|
|
{ token: data.token },
|
|
{ responseType: 'blob' }
|
|
)
|
|
.then((response) => {
|
|
// 1. Vytvoření dočasné URL z přijatého Blobu
|
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
|
|
|
// 2. Extrakce názvu souboru z hlaviček (pokud ho backend posílá)
|
|
let filename = `${data.filename || 'video'}.${selectedExtension}`;
|
|
|
|
// 3. Vytvoření neviditelného odkazu pro spuštění stahování
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.setAttribute('download', filename); // Zajišťuje stáhnutí místo otevření
|
|
document.body.appendChild(link);
|
|
|
|
// 4. Simulace kliknutí
|
|
link.click();
|
|
|
|
// 5. Úklid po stažení
|
|
link.remove();
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
setDownloadStatus('Download complete!');
|
|
resolve();
|
|
})
|
|
.catch((err) => {
|
|
console.error('Failed to download blob:', err);
|
|
reject(new Error('Failed to retrieve the file after processing.'));
|
|
});
|
|
|
|
} else if (data.type === 'error') {
|
|
reject(new Error(data.message));
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
reject(new Error('WebSocket connection failed'));
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
if (wsRef.current === ws) {
|
|
wsRef.current = null;
|
|
}
|
|
};
|
|
});
|
|
|
|
} catch (err: any) {
|
|
const errorMessage = err.message || 'Failed to download video';
|
|
setError({ error: errorMessage });
|
|
} finally {
|
|
if (wsRef.current) {
|
|
wsRef.current.close();
|
|
wsRef.current = null;
|
|
}
|
|
setIsDownloading(false);
|
|
setDownloadStatus('');
|
|
setDownloadProgress(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="p-8 w-full max-w-4xl mx-auto">
|
|
<Ad />
|
|
<h1 className="text-2xl font-bold mb-6">Video Downloader</h1>
|
|
|
|
<div className="mb-6">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<FaLink className="text-gray-500" />
|
|
<span className="text-sm font-medium">Video URL</span>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={videoUrl}
|
|
onChange={(e) => setVideoUrl(e.target.value)}
|
|
placeholder="Paste video URL here (YouTube, TikTok, Vimeo, etc.)"
|
|
className="w-full p-3 border rounded"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
onClick={retrieveVideoInfo}
|
|
disabled={isLoading || !videoUrl}
|
|
className="px-6 py-3 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed mx-2 mb-4"
|
|
>
|
|
{isLoading ? 'Loading...' : 'Retrieve Options'}
|
|
</button>
|
|
|
|
{error && (
|
|
<div className="mt-6 mx-4 p-4 bg-red-100 text-red-700 rounded">
|
|
Error: {error.error}
|
|
</div>
|
|
)}
|
|
|
|
{videoInfo && (
|
|
<div className="mt-8 mx-4 p-6 border rounded">
|
|
{videoInfo.is_playlist ? (
|
|
<div>
|
|
<h2 className="text-xl font-semibold mb-4">
|
|
📋 {videoInfo.playlist_title || 'Playlist'}
|
|
</h2>
|
|
<p className="text-gray-600 mb-6">
|
|
{videoInfo.playlist_count} videos found
|
|
</p>
|
|
|
|
{/* Playlist Video Selection */}
|
|
<div className="mb-6">
|
|
<div className="flex items-center gap-4 mb-3">
|
|
<h3 className="text-lg font-medium">Select Videos to Download:</h3>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={selectAllVideos}
|
|
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
|
>
|
|
Select All
|
|
</button>
|
|
<button
|
|
onClick={deselectAllVideos}
|
|
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
|
>
|
|
Clear All
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-h-96 overflow-y-auto border rounded">
|
|
{videoInfo.videos.map((video, index) => {
|
|
const videoNumber = index + 1;
|
|
return (
|
|
<div key={video.id} className="p-3 border-b last:border-b-0 hover:bg-gray-50">
|
|
<label className="flex items-start gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedVideos.includes(videoNumber)}
|
|
onChange={() => toggleVideoSelection(videoNumber)}
|
|
className="mt-1 w-4 h-4"
|
|
/>
|
|
<div className="flex gap-3 flex-1">
|
|
{video.thumbnail && (
|
|
<img
|
|
src={video.thumbnail}
|
|
alt={video.title}
|
|
className="w-20 h-15 object-cover rounded flex-shrink-0"
|
|
/>
|
|
)}
|
|
<div className="flex-1">
|
|
<div className="font-medium">
|
|
{videoNumber}. {video.title}
|
|
</div>
|
|
{video.duration && (
|
|
<div className="text-sm text-gray-600">
|
|
Duration: {Math.floor(video.duration / 60)}:{String(video.duration % 60).padStart(2, '0')}
|
|
</div>
|
|
)}
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
Quality: {video.video_resolutions.join(', ') || 'N/A'} |
|
|
Audio: {video.audio_resolutions.join(', ') || 'N/A'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="mt-3 text-sm text-gray-600">
|
|
{selectedVideos.length} of {videoInfo.videos.length} videos selected
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<h2 className="text-xl font-semibold mb-4">
|
|
🎥 {videoInfo.videos[0]?.title || 'Video'}
|
|
</h2>
|
|
|
|
{videoInfo.videos[0]?.thumbnail && (
|
|
<img
|
|
src={videoInfo.videos[0].thumbnail}
|
|
alt={videoInfo.videos[0].title}
|
|
className="mt-4 w-1/3 max-w-sm rounded shadow mx-auto block"
|
|
/>
|
|
)}
|
|
|
|
{videoInfo.videos[0]?.duration && (
|
|
<p className="mt-4 text-gray-600 text-center">
|
|
Duration: {Math.floor(videoInfo.videos[0].duration / 60)}:{String(videoInfo.videos[0].duration % 60).padStart(2, '0')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Quality and Format Selection */}
|
|
<div className="mt-8 mx-2 grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
{/* Video Quality Dropdown */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
|
|
<FaVideo className="text-gray-500" />
|
|
Video Quality (optional)
|
|
</label>
|
|
<select
|
|
value={selectedVideoQuality}
|
|
onChange={(e) => setSelectedVideoQuality(e.target.value)}
|
|
className="w-full p-2 border rounded"
|
|
>
|
|
<option value="">Best available</option>
|
|
{getAllVideoQualities().map((res) => (
|
|
<option key={res} value={res}>{res}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Audio Quality Dropdown */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
|
|
<FaVolumeUp className="text-gray-500" />
|
|
Audio Quality (optional)
|
|
</label>
|
|
<select
|
|
value={selectedAudioQuality}
|
|
onChange={(e) => setSelectedAudioQuality(e.target.value)}
|
|
className="w-full p-2 border rounded"
|
|
>
|
|
<option value="">Best available</option>
|
|
{getAllAudioQualities().map((res) => (
|
|
<option key={res} value={res}>{res}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* File Extension Dropdown */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
|
|
<FaFile className="text-gray-500" />
|
|
File Format
|
|
</label>
|
|
<select
|
|
value={selectedExtension}
|
|
onChange={(e) => setSelectedExtension(e.target.value)}
|
|
className="w-full p-2 border rounded"
|
|
>
|
|
{FILE_EXTENSIONS.map((ext) => (
|
|
<option key={ext.value} value={ext.value}>
|
|
{ext.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleDownload}
|
|
disabled={isDownloading || (videoInfo.is_playlist && selectedVideos.length === 0)}
|
|
className="mt-8 mx-2 px-6 py-3 bg-green-500 text-white rounded hover:bg-green-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
|
|
>
|
|
{isDownloading ? downloadStatus || 'Downloading…' : (
|
|
videoInfo.is_playlist
|
|
? `Download Selected (${selectedVideos.length}) as ZIP`
|
|
: 'Download Video'
|
|
)}
|
|
</button>
|
|
|
|
{isDownloading && downloadStatus && (
|
|
<div className="mt-3 mx-2">
|
|
<div className="flex items-center gap-2 text-sm text-blue-600 mb-2">
|
|
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
|
</svg>
|
|
{downloadStatus}
|
|
</div>
|
|
{downloadProgress !== null && (
|
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
<div
|
|
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
|
style={{ width: `${downloadProgress}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{videoInfo.is_playlist && selectedVideos.length === 0 && (
|
|
<p className="mt-4 mx-2 text-sm text-red-600">
|
|
Please select at least one video to download
|
|
</p>
|
|
)}
|
|
|
|
{/* Advanced Options Toggle */}
|
|
<div className="mt-10 mx-2 border-t pt-8">
|
|
<button
|
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
className="text-blue-500 hover:text-blue-700 font-medium flex items-center gap-2 mx-2"
|
|
>
|
|
{showAdvanced ? '▼' : '▶'} Advanced Options
|
|
</button>
|
|
|
|
{showAdvanced && (
|
|
<div className="mt-6 space-y-6 p-6 rounded mx-2">
|
|
{/* Subtitles */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
|
|
<FaFont className="text-gray-500" />
|
|
Subtitles (optional)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={subtitles}
|
|
onChange={(e) => setSubtitles(e.target.value)}
|
|
placeholder="e.g., 'en', 'en,cs', or 'all'"
|
|
className="w-full p-2 border rounded"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Language codes (e.g., 'en', 'cs') or 'all' for all available
|
|
</p>
|
|
</div>
|
|
|
|
{/* Checkboxes Row */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={embedSubtitles}
|
|
onChange={(e) => setEmbedSubtitles(e.target.checked)}
|
|
className="w-4 h-4"
|
|
/>
|
|
<span className="text-sm">Embed Subtitles (mkv/mp4 only)</span>
|
|
</label>
|
|
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={embedThumbnail}
|
|
onChange={(e) => setEmbedThumbnail(e.target.checked)}
|
|
className="w-4 h-4"
|
|
/>
|
|
<span className="text-sm">Embed Thumbnail</span>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Cookies for Age-Restricted Content */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
|
|
<FaCookie className="text-gray-500" />
|
|
Cookies (for age-restricted content)
|
|
</label>
|
|
<textarea
|
|
value={cookies}
|
|
onChange={(e) => setCookies(e.target.value)}
|
|
placeholder="Paste cookies in Netscape format here..."
|
|
rows={4}
|
|
className="w-full p-2 border rounded font-mono text-xs"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Export cookies from your browser using extensions like "Get cookies.txt" or "cookies.txt".
|
|
Login to YouTube/Google first, then export cookies in Netscape format.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Statistics />
|
|
</div>
|
|
);
|
|
}
|