Add playlist support to downloader API and frontend

Enhanced the downloader backend and frontend to support playlist URLs for video info and downloads. The API now returns structured playlist information, allows selecting specific videos for download, and returns a ZIP file for playlist downloads. Updated OpenAPI types, removed deprecated parameters (start_time, end_time, playlist_items), and improved Content Security Policy handling in nginx. Refactored frontend to handle playlist selection and updated generated API models accordingly.
This commit is contained in:
2025-12-25 04:54:27 +01:00
parent cf615c5279
commit 264f0116ae
20 changed files with 606 additions and 424 deletions

View File

@@ -1,17 +1,12 @@
# Step 1: Build React (Vite) app
FROM node:22-alpine AS build
FROM node:22 AS build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Clean install with force flag to bypass cache issues
#RUN rm -rf node_modules package-lock.json && \
# npm cache clean --force && \
# npm install --legacy-peer-deps
# install
RUN npm install --legacy-peer-deps
# Clean install
RUN npm ci --legacy-peer-deps
# Copy source files
COPY . .

View File

@@ -14,6 +14,11 @@ http {
sendfile on;
keepalive_timeout 65;
# Content Security Policy - organized for better readability
map $request_uri $csp_policy {
default "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src * data: blob:; connect-src 'self' http://127.0.0.1:8000 http://localhost:8000 ws: wss: https://api.paylibo.com; font-src 'self' data: https://fonts.gstatic.com";
}
server {
listen 80;
server_name _;
@@ -27,7 +32,7 @@ http {
location / {
try_files $uri /index.html;
# Ensure CSP is present on SPA document responses too
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.paylibo.com; connect-src 'self' http://127.0.0.1:8000 http://localhost:8000 ws: wss: https://api.paylibo.com; font-src 'self' data:" always;
add_header Content-Security-Policy $csp_policy always;
}
# -------------------------
@@ -59,7 +64,7 @@ http {
client_max_body_size 50m;
# Ensure CSP is also present on proxied responses
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.paylibo.com; connect-src 'self' http://127.0.0.1:8000 http://localhost:8000 ws: wss: https://api.paylibo.com; font-src 'self' data:" always;
add_header Content-Security-Policy $csp_policy always;
}
# -------------------------
@@ -69,7 +74,10 @@ http {
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Minimal, valid CSP for development (apply on all responses)
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.paylibo.com; connect-src 'self' http://127.0.0.1:8000 http://localhost:8000 ws: wss: https://api.paylibo.com; font-src 'self' data:" always;
# CSP Policy - Centrally defined above for better maintainability
# To add new domains, update the $csp_policy map above
# Development: More permissive for external resources
# Production: Should be more restrictive and use nonces/hashes where possible
add_header Content-Security-Policy $csp_policy always;
}
}

View File

@@ -5,7 +5,7 @@
*/
export interface DownloadRequest {
/** Video URL to download from supported platforms */
/** Video/Playlist URL to download from supported platforms */
url: string;
/** Container format for the output file. Common formats: mp4 (H.264 + AAC, most compatible), mkv (flexible, lossless container), webm (VP9/AV1 + Opus), flv (legacy), mov (Apple-friendly), avi (older), ogg, m4a (audio only), mp3 (audio only). The extension will be validated by ffmpeg during conversion. */
ext?: string;
@@ -19,6 +19,11 @@ export interface DownloadRequest {
* @nullable
*/
audio_quality?: number | null;
/**
* For playlists: specify which videos to download as array of numbers (e.g., [1,3,5]). If omitted, all videos are downloaded.
* @nullable
*/
selected_videos?: number[] | null;
/**
* Language codes (e.g., 'en', 'cs', 'en,cs') or 'all' for all available subtitles
* @nullable
@@ -30,21 +35,6 @@ export interface DownloadRequest {
embed_thumbnail?: boolean;
/** Extract audio only, ignoring video quality settings */
extract_audio?: boolean;
/**
* Start time for trimming (format: HH:MM:SS or seconds as integer)
* @nullable
*/
start_time?: string | null;
/**
* End time for trimming (format: HH:MM:SS or seconds as integer)
* @nullable
*/
end_time?: string | null;
/**
* Playlist items to download (e.g., '1-5,8,10' or '1,2,3')
* @nullable
*/
playlist_items?: string | null;
/**
* Browser cookies in Netscape format for age-restricted content. Export from browser extensions like 'Get cookies.txt'
* @nullable

View File

@@ -85,6 +85,7 @@ export * from "./stateFdaEnum";
export * from "./statusEnum";
export * from "./trackingURL";
export * from "./userRegistration";
export * from "./videoInfo";
export * from "./videoInfoResponse";
export * from "./zasilkovnaPacket";
export * from "./zasilkovnaPacketRead";

View File

@@ -1,27 +0,0 @@
/**
* Generated by orval v7.17.0 🍺
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
/**
* * `WAITING_FOR_ORDERING_SHIPMENT` - cz#Čeká na objednání zásilkovny
* `PENDING` - cz#Podáno
* `SENDED` - cz#Odesláno
* `ARRIVED` - cz#Doručeno
* `CANCELED` - cz#Zrušeno
* `RETURNING` - cz#Posláno zpátky
* `RETURNED` - cz#Vráceno
*/
export type StateE15Enum = (typeof StateE15Enum)[keyof typeof StateE15Enum];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const StateE15Enum = {
WAITING_FOR_ORDERING_SHIPMENT: "WAITING_FOR_ORDERING_SHIPMENT",
PENDING: "PENDING",
SENDED: "SENDED",
ARRIVED: "ARRIVED",
CANCELED: "CANCELED",
RETURNING: "RETURNING",
RETURNED: "RETURNED",
} as const;

View File

@@ -1,21 +0,0 @@
/**
* Generated by orval v7.17.0 🍺
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
/**
* * `ordered` - cz#Objednávka se připravuje
* `shipped` - cz#Odesláno
* `delivered` - cz#Doručeno
* `ready_to_pickup` - cz#Připraveno k vyzvednutí
*/
export type StateFdaEnum = (typeof StateFdaEnum)[keyof typeof StateFdaEnum];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const StateFdaEnum = {
ordered: "ordered",
shipped: "shipped",
delivered: "delivered",
ready_to_pickup: "ready_to_pickup",
} as const;

View File

@@ -0,0 +1,26 @@
/**
* Generated by orval v7.17.0 🍺
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
export interface VideoInfo {
/** Video ID */
id: string;
/** Video title */
title: string;
/**
* Video duration in seconds (null if unavailable)
* @nullable
*/
duration: number | null;
/**
* Base64 encoded thumbnail image as data URL (e.g., data:image/jpeg;base64,...)
* @nullable
*/
thumbnail: string | null;
/** List of available video quality options (e.g., '1080p', '720p', '480p') */
video_resolutions: string[];
/** List of available audio format options */
audio_resolutions: string[];
}

View File

@@ -3,22 +3,21 @@
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
import type { VideoInfo } from "./videoInfo";
export interface VideoInfoResponse {
/** Video title */
title: string;
/** Whether the URL is a playlist */
is_playlist: boolean;
/**
* Video duration in seconds (null if unavailable)
* Playlist title (if applicable)
* @nullable
*/
duration: number | null;
playlist_title: string | null;
/**
* URL to video thumbnail image
* Number of videos in playlist (if applicable)
* @nullable
*/
thumbnail: string | null;
/** List of available video quality options (e.g., '1080p', '720p', '480p') */
video_resolutions: string[];
/** List of available audio format options */
audio_resolutions: string[];
playlist_count: number | null;
/** Array of video information (single video for individual URLs, multiple for playlists) */
videos: VideoInfo[];
}

View File

@@ -32,18 +32,24 @@ import { publicMutator } from "../../publicClient";
/**
*
Fetch detailed information about a video from supported platforms.
Fetch detailed information about a video or playlist from supported platforms.
**Supported platforms:** YouTube, TikTok, Vimeo, Twitter, Instagram, Facebook, Reddit, and many more.
**Returns:**
For single videos:
- Video title, duration, and thumbnail
- Available video qualities/resolutions
- Available audio formats
For playlists:
- Array of videos with the same info structure as single videos
- Each video includes title, duration, thumbnail, and available qualities
**Usage:**
```
GET /api/downloader/download/?url=https://youtube.com/watch?v=VIDEO_ID
GET /api/downloader/download/?url=https://youtube.com/playlist?list=PLAYLIST_ID
```
* @summary Get video info from URL
@@ -211,7 +217,12 @@ export function useApiDownloaderDownloadRetrieve<
/**
*
Download video with optional quality constraints and container format conversion.
Download video/playlist with optional quality constraints and container format conversion.
**For Playlists:**
- Returns a ZIP file containing all selected videos
- Use `selected_videos` to specify which videos to download (e.g., [1,3,5] or [1,2,3,4,5])
- If `selected_videos` is not provided, all videos in the playlist will be downloaded
**Quality Parameters (optional):**
- If not specified, yt-dlp will automatically select the best available quality.
@@ -228,12 +239,9 @@ export function useApiDownloaderDownloadRetrieve<
- `embed_subtitles`: Embed subtitles into video file
- `embed_thumbnail`: Embed thumbnail as cover art
- `extract_audio`: Extract audio only (ignores video quality)
- `start_time`: Trim start (format: HH:MM:SS or seconds)
- `end_time`: Trim end (format: HH:MM:SS or seconds)
- `playlist_items`: Download specific playlist items (e.g., '1-5,8,10')
- `cookies`: Browser cookies for age-restricted content (Netscape format)
* @summary Download video from URL
* @summary Download video or playlist from URL
*/
export const apiDownloaderDownloadCreate = (
downloadRequest: DownloadRequest,
@@ -293,7 +301,7 @@ export type ApiDownloaderDownloadCreateMutationBody = DownloadRequest;
export type ApiDownloaderDownloadCreateMutationError = DownloadErrorResponse;
/**
* @summary Download video from URL
* @summary Download video or playlist from URL
*/
export const useApiDownloaderDownloadCreate = <
TError = DownloadErrorResponse,

View File

@@ -6,7 +6,7 @@
export type ApiDownloaderDownloadRetrieveParams = {
/**
* Video URL from YouTube, TikTok, Vimeo, etc. Must be a valid URL from a supported platform.
* Video/Playlist URL from YouTube, TikTok, Vimeo, etc. Must be a valid URL from a supported platform.
* @minLength 1
*/
url: string;

View File

@@ -5,7 +5,7 @@
*/
export interface DownloadRequest {
/** Video URL to download from supported platforms */
/** Video/Playlist URL to download from supported platforms */
url: string;
/** Container format for the output file. Common formats: mp4 (H.264 + AAC, most compatible), mkv (flexible, lossless container), webm (VP9/AV1 + Opus), flv (legacy), mov (Apple-friendly), avi (older), ogg, m4a (audio only), mp3 (audio only). The extension will be validated by ffmpeg during conversion. */
ext?: string;
@@ -19,6 +19,11 @@ export interface DownloadRequest {
* @nullable
*/
audio_quality?: number | null;
/**
* For playlists: specify which videos to download as array of numbers (e.g., [1,3,5]). If omitted, all videos are downloaded.
* @nullable
*/
selected_videos?: number[] | null;
/**
* Language codes (e.g., 'en', 'cs', 'en,cs') or 'all' for all available subtitles
* @nullable
@@ -30,21 +35,6 @@ export interface DownloadRequest {
embed_thumbnail?: boolean;
/** Extract audio only, ignoring video quality settings */
extract_audio?: boolean;
/**
* Start time for trimming (format: HH:MM:SS or seconds as integer)
* @nullable
*/
start_time?: string | null;
/**
* End time for trimming (format: HH:MM:SS or seconds as integer)
* @nullable
*/
end_time?: string | null;
/**
* Playlist items to download (e.g., '1-5,8,10' or '1,2,3')
* @nullable
*/
playlist_items?: string | null;
/**
* Browser cookies in Netscape format for age-restricted content. Export from browser extensions like 'Get cookies.txt'
* @nullable

View File

@@ -80,6 +80,7 @@ export * from "./stateFdaEnum";
export * from "./statusEnum";
export * from "./trackingURL";
export * from "./userRegistration";
export * from "./videoInfo";
export * from "./videoInfoResponse";
export * from "./zasilkovnaPacket";
export * from "./zasilkovnaPacketRead";

View File

@@ -1,27 +0,0 @@
/**
* Generated by orval v7.17.0 🍺
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
/**
* * `WAITING_FOR_ORDERING_SHIPMENT` - cz#Čeká na objednání zásilkovny
* `PENDING` - cz#Podáno
* `SENDED` - cz#Odesláno
* `ARRIVED` - cz#Doručeno
* `CANCELED` - cz#Zrušeno
* `RETURNING` - cz#Posláno zpátky
* `RETURNED` - cz#Vráceno
*/
export type StateE15Enum = (typeof StateE15Enum)[keyof typeof StateE15Enum];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const StateE15Enum = {
WAITING_FOR_ORDERING_SHIPMENT: "WAITING_FOR_ORDERING_SHIPMENT",
PENDING: "PENDING",
SENDED: "SENDED",
ARRIVED: "ARRIVED",
CANCELED: "CANCELED",
RETURNING: "RETURNING",
RETURNED: "RETURNED",
} as const;

View File

@@ -1,21 +0,0 @@
/**
* Generated by orval v7.17.0 🍺
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
/**
* * `ordered` - cz#Objednávka se připravuje
* `shipped` - cz#Odesláno
* `delivered` - cz#Doručeno
* `ready_to_pickup` - cz#Připraveno k vyzvednutí
*/
export type StateFdaEnum = (typeof StateFdaEnum)[keyof typeof StateFdaEnum];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const StateFdaEnum = {
ordered: "ordered",
shipped: "shipped",
delivered: "delivered",
ready_to_pickup: "ready_to_pickup",
} as const;

View File

@@ -0,0 +1,26 @@
/**
* Generated by orval v7.17.0 🍺
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
export interface VideoInfo {
/** Video ID */
id: string;
/** Video title */
title: string;
/**
* Video duration in seconds (null if unavailable)
* @nullable
*/
duration: number | null;
/**
* Base64 encoded thumbnail image as data URL (e.g., data:image/jpeg;base64,...)
* @nullable
*/
thumbnail: string | null;
/** List of available video quality options (e.g., '1080p', '720p', '480p') */
video_resolutions: string[];
/** List of available audio format options */
audio_resolutions: string[];
}

View File

@@ -3,22 +3,21 @@
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
import type { VideoInfo } from "./videoInfo";
export interface VideoInfoResponse {
/** Video title */
title: string;
/** Whether the URL is a playlist */
is_playlist: boolean;
/**
* Video duration in seconds (null if unavailable)
* Playlist title (if applicable)
* @nullable
*/
duration: number | null;
playlist_title: string | null;
/**
* URL to video thumbnail image
* Number of videos in playlist (if applicable)
* @nullable
*/
thumbnail: string | null;
/** List of available video quality options (e.g., '1080p', '720p', '480p') */
video_resolutions: string[];
/** List of available audio format options */
audio_resolutions: string[];
playlist_count: number | null;
/** Array of video information (single video for individual URLs, multiple for playlists) */
videos: VideoInfo[];
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { apiDownloaderDownloadRetrieve, apiDownloaderDownloadCreate } from '@/api/generated/public/downloader';
import { type VideoInfoResponse } from '@/api/generated/public/models';
import { type VideoInfoResponse, /*type VideoInfo*/ } from '@/api/generated/public/models';
import { FaLink, FaVideo, FaVolumeUp, FaFile, FaFont, FaCookie } from 'react-icons/fa';
// Common file extensions supported by ffmpeg
const FILE_EXTENSIONS = [
@@ -10,8 +11,6 @@ const FILE_EXTENSIONS = [
{ value: 'avi', label: 'AVI (Older format)' },
{ value: 'mov', label: 'MOV (Apple-friendly)' },
{ value: 'flv', label: 'FLV (Legacy)' },
{ value: 'm4a', label: 'M4A (Audio only, AAC)' },
{ value: 'mp3', label: 'MP3 (Audio only)' },
{ value: 'ogg', label: 'OGG (Audio/Video)' },
];
@@ -27,24 +26,74 @@ export default function Downloader() {
const [selectedAudioQuality, setSelectedAudioQuality] = useState<string>('');
const [selectedExtension, setSelectedExtension] = useState<string>('mp4');
// Advanced options
// Playlist selection
const [selectedVideos, setSelectedVideos] = useState<number[]>([]);
// Advanced options (removed start_time, end_time, playlist_items)
const [subtitles, setSubtitles] = useState<string>('');
const [embedSubtitles, setEmbedSubtitles] = useState<boolean>(false);
const [embedThumbnail, setEmbedThumbnail] = useState<boolean>(false);
const [extractAudio, setExtractAudio] = useState<boolean>(false);
const [startTime, setStartTime] = useState<string>('');
const [endTime, setEndTime] = useState<string>('');
const [playlistItems, setPlaylistItems] = useState<string>('');
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([]);
};
// Get all available qualities from all videos for consistent UI
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; // Sort descending
});
};
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; // Sort descending
});
};
async function retrieveVideoInfo() {
setIsLoading(true);
setError(null);
setVideoInfo(null);
setSelectedVideos([]); // Reset selected videos
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) {
setError({ error: err.message || 'Failed to retrieve video info' });
} finally {
@@ -74,17 +123,15 @@ export default function Downloader() {
const response = await apiDownloaderDownloadCreate({
url: videoUrl,
ext: selectedExtension,
video_quality: videoQuality,
audio_quality: audioQuality,
video_quality: videoQuality || null,
audio_quality: audioQuality || null,
// Playlist selection
selected_videos: videoInfo?.is_playlist && selectedVideos.length > 0 ? selectedVideos : null,
// Advanced options
subtitles: subtitles || undefined,
subtitles: subtitles || null,
embed_subtitles: embedSubtitles,
embed_thumbnail: embedThumbnail,
extract_audio: extractAudio,
start_time: startTime || undefined,
end_time: endTime || undefined,
playlist_items: playlistItems || undefined,
cookies: cookies || undefined,
cookies: cookies || null,
});
// The response should be a Blob, trigger download
@@ -92,7 +139,16 @@ export default function Downloader() {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `video.${selectedExtension}`;
// Better filename based on content type
if (videoInfo?.is_playlist) {
const playlistTitle = videoInfo.playlist_title || 'playlist';
a.download = `${playlistTitle.replace(/[<>:"/\\|?*]/g, '_').trim()}.zip`;
} else {
const title = videoInfo?.videos?.[0]?.title || 'video';
a.download = `${title.replace(/[<>:"/\\|?*]/g, '_').trim()}.${selectedExtension}`;
}
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
@@ -105,55 +161,142 @@ export default function Downloader() {
}
return (
<div className="p-6 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold mb-4">Video Downloader</h1>
<div className="p-8 w-full max-w-4xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Video Downloader</h1>
<div className="mb-4">
<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-2 border rounded"
className="w-full p-3 border rounded"
/>
</div>
<button
onClick={retrieveVideoInfo}
disabled={isLoading || !videoUrl}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
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-4 p-4 bg-red-100 text-red-700 rounded">
<div className="mt-6 mx-4 p-4 bg-red-100 text-red-700 rounded">
Error: {error.error}
</div>
)}
{videoInfo && (
<div className="mt-6 p-4 border rounded">
<h2 className="text-xl font-semibold mb-2">{videoInfo.title}</h2>
{videoInfo.thumbnail && (
<img
src={videoInfo.thumbnail}
alt={videoInfo.title}
className="mt-2 max-w-md rounded shadow"
/>
)}
{videoInfo.duration && (
<p className="mt-2 text-gray-600">
Duration: {Math.floor(videoInfo.duration / 60)}:{String(videoInfo.duration % 60).padStart(2, '0')}
</p>
<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>
)}
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 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">
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
<FaVideo className="text-gray-500" />
Video Quality (optional)
</label>
<select
@@ -162,7 +305,7 @@ export default function Downloader() {
className="w-full p-2 border rounded"
>
<option value="">Best available</option>
{videoInfo.video_resolutions?.map((res) => (
{getAllVideoQualities().map((res) => (
<option key={res} value={res}>{res}</option>
))}
</select>
@@ -170,7 +313,8 @@ export default function Downloader() {
{/* Audio Quality Dropdown */}
<div>
<label className="block text-sm font-medium mb-2">
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
<FaVolumeUp className="text-gray-500" />
Audio Quality (optional)
</label>
<select
@@ -179,7 +323,7 @@ export default function Downloader() {
className="w-full p-2 border rounded"
>
<option value="">Best available</option>
{videoInfo.audio_resolutions?.map((res) => (
{getAllAudioQualities().map((res) => (
<option key={res} value={res}>{res}</option>
))}
</select>
@@ -187,7 +331,8 @@ export default function Downloader() {
{/* File Extension Dropdown */}
<div>
<label className="block text-sm font-medium mb-2">
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
<FaFile className="text-gray-500" />
File Format
</label>
<select
@@ -206,26 +351,37 @@ export default function Downloader() {
<button
onClick={handleDownload}
disabled={isDownloading}
className="mt-6 px-6 py-3 bg-green-500 text-white rounded hover:bg-green-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
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 ? 'Downloading...' : 'Download'}
{isDownloading ? 'Downloading...' : (
videoInfo.is_playlist
? `Download Selected (${selectedVideos.length}) as ZIP`
: 'Download Video'
)}
</button>
{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-8 border-t pt-6">
<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"
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-4 space-y-4 p-4 bg-gray-50 rounded">
<div className="mt-6 space-y-6 p-6 rounded mx-2">
{/* Subtitles */}
<div>
<label className="block text-sm font-medium mb-2">
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
<FaFont className="text-gray-500" />
Subtitles (optional)
</label>
<input
@@ -241,7 +397,7 @@ export default function Downloader() {
</div>
{/* Checkboxes Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
@@ -261,67 +417,12 @@ export default function Downloader() {
/>
<span className="text-sm">Embed Thumbnail</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={extractAudio}
onChange={(e) => setExtractAudio(e.target.checked)}
className="w-4 h-4"
/>
<span className="text-sm">Audio Only</span>
</label>
</div>
{/* Trim Times */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">
Start Time (trim)
</label>
<input
type="text"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
placeholder="e.g., 00:01:30 or 90"
className="w-full p-2 border rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
End Time (trim)
</label>
<input
type="text"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
placeholder="e.g., 00:05:00 or 300"
className="w-full p-2 border rounded"
/>
</div>
</div>
{/* Playlist Items */}
<div>
<label className="block text-sm font-medium mb-2">
Playlist Items (optional)
</label>
<input
type="text"
value={playlistItems}
onChange={(e) => setPlaylistItems(e.target.value)}
placeholder="e.g., '1-5,8,10' or '1,2,3'"
className="w-full p-2 border rounded"
/>
<p className="text-xs text-gray-500 mt-1">
Specify which playlist items to download (e.g., '1-5,8,10')
</p>
</div>
{/* Cookies for Age-Restricted Content */}
<div>
<label className="block text-sm font-medium mb-2">
<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