diff --git a/backend/thirdparty/downloader/views.py b/backend/thirdparty/downloader/views.py index 404305d..aa0ec28 100644 --- a/backend/thirdparty/downloader/views.py +++ b/backend/thirdparty/downloader/views.py @@ -8,6 +8,9 @@ import shutil import mimetypes import base64 import urllib.request +import zipfile +import requests +from urllib.parse import urlparse from rest_framework import serializers from rest_framework.views import APIView @@ -43,18 +46,24 @@ class Downloader(APIView): tags=["downloader", "public"], summary="Get video info from URL", description=""" - 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 ``` """, parameters=[ @@ -62,7 +71,7 @@ class Downloader(APIView): name="VideoInfoParams", fields={ "url": serializers.URLField( - help_text="Video URL from YouTube, TikTok, Vimeo, etc. Must be a valid URL from a supported platform." + help_text="Video/Playlist URL from YouTube, TikTok, Vimeo, etc. Must be a valid URL from a supported platform." ), }, ) @@ -71,16 +80,28 @@ class Downloader(APIView): 200: inline_serializer( name="VideoInfoResponse", fields={ - "title": serializers.CharField(help_text="Video title"), - "duration": serializers.IntegerField(allow_null=True, help_text="Video duration in seconds (null if unavailable)"), - "thumbnail": serializers.URLField(allow_null=True, help_text="URL to video thumbnail image"), - "video_resolutions": serializers.ListField( - child=serializers.CharField(), - help_text="List of available video quality options (e.g., '1080p', '720p', '480p')" - ), - "audio_resolutions": serializers.ListField( - child=serializers.CharField(), - help_text="List of available audio format options" + "is_playlist": serializers.BooleanField(help_text="Whether the URL is a playlist"), + "playlist_title": serializers.CharField(allow_null=True, help_text="Playlist title (if applicable)"), + "playlist_count": serializers.IntegerField(allow_null=True, help_text="Number of videos in playlist (if applicable)"), + "videos": serializers.ListField( + child=inline_serializer( + name="VideoInfo", + fields={ + "id": serializers.CharField(help_text="Video ID"), + "title": serializers.CharField(help_text="Video title"), + "duration": serializers.IntegerField(allow_null=True, help_text="Video duration in seconds (null if unavailable)"), + "thumbnail": serializers.CharField(allow_null=True, help_text="Base64 encoded thumbnail image as data URL (e.g., data:image/jpeg;base64,...)"), + "video_resolutions": serializers.ListField( + child=serializers.CharField(), + help_text="List of available video quality options (e.g., '1080p', '720p', '480p')" + ), + "audio_resolutions": serializers.ListField( + child=serializers.CharField(), + help_text="List of available audio format options" + ), + } + ), + help_text="Array of video information (single video for individual URLs, multiple for playlists)" ), }, ), @@ -98,70 +119,127 @@ class Downloader(APIView): ydl_options = { "quiet": True, "no_check_certificates": True, # Bypass SSL verification in Docker + "extract_flat": False, # Extract full info for playlists too + "ignoreerrors": False, # Don't ignore errors to get accurate info } + try: with yt_dlp.YoutubeDL(ydl_options) as ydl: info = ydl.extract_info(url, download=False) except Exception as e: return Response({"error": f"Failed to retrieve video info: {str(e)}"}, status=400) - formats = info.get("formats", []) or [] + def extract_video_info(video_data): + """Extract video info from yt-dlp data""" + formats = video_data.get("formats", []) or [] - # Video: collect unique heights and sort desc - heights = { - int(f.get("height")) - for f in formats - if f.get("vcodec") != "none" and isinstance(f.get("height"), int) - } - video_resolutions = [f"{h}p" for h in sorted(heights, reverse=True)] + # Video: collect unique heights and sort desc (highest quality first) + heights = { + int(f.get("height")) + for f in formats + if f.get("vcodec") != "none" and isinstance(f.get("height"), int) + } + video_resolutions = [f"{h}p" for h in sorted(heights, reverse=True)] - # Audio: collect unique bitrates (abr kbps), fallback to tbr when abr missing - bitrates = set() - for f in formats: - if f.get("acodec") != "none" and f.get("vcodec") == "none": - abr = f.get("abr") - tbr = f.get("tbr") - val = None - if isinstance(abr, (int, float)): - val = int(abr) - elif isinstance(tbr, (int, float)): - val = int(tbr) - if val and val > 0: - bitrates.add(val) + # Audio: collect unique bitrates (abr kbps), fallback to tbr when abr missing + bitrates = set() + for f in formats: + if f.get("acodec") != "none" and f.get("vcodec") == "none": + abr = f.get("abr") + tbr = f.get("tbr") + val = None + if isinstance(abr, (int, float)): + val = int(abr) + elif isinstance(tbr, (int, float)): + val = int(tbr) + if val and val > 0: + bitrates.add(val) - audio_resolutions = [f"{b}kbps" for b in sorted(bitrates, reverse=True)] + audio_resolutions = [f"{b}kbps" for b in sorted(bitrates, reverse=True)] - # Convert thumbnail to data URL to avoid mixed content issues (HTTPS thumbnail on HTTP site) - thumbnail_url = info.get("thumbnail") - thumbnail_data_url = None - if thumbnail_url: - try: - with urllib.request.urlopen(thumbnail_url, timeout=10) as response: - image_data = response.read() - content_type = response.headers.get('Content-Type', 'image/jpeg') - thumbnail_data_url = f"data:{content_type};base64,{base64.b64encode(image_data).decode('utf-8')}" - except Exception: - # If thumbnail fetch fails, just use the original URL - thumbnail_data_url = thumbnail_url + # Fetch thumbnail and convert to base64 blob + thumbnail_blob = None + thumbnail_url = video_data.get("thumbnail") + if thumbnail_url: + try: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8', + } + response = requests.get(thumbnail_url, headers=headers, timeout=10) + response.raise_for_status() + + if response.headers.get('content-type', '').startswith('image/'): + # Convert to base64 + image_data = base64.b64encode(response.content).decode('utf-8') + content_type = response.headers.get('content-type', 'image/jpeg') + thumbnail_blob = f"data:{content_type};base64,{image_data}" + except Exception: + # If thumbnail fetch fails, just continue without it + pass - return Response( - { - "title": info.get("title"), - "duration": info.get("duration"), - "thumbnail": thumbnail_data_url, + return { + "id": video_data.get("id", ""), + "title": video_data.get("title", ""), + "duration": video_data.get("duration"), + "thumbnail": thumbnail_blob, # Now a base64 blob instead of URL "video_resolutions": video_resolutions, "audio_resolutions": audio_resolutions, - }, - status=200, - ) + } + + # Check if this is a playlist + is_playlist = "entries" in info and info.get("entries") is not None + + if is_playlist: + # Handle playlist + videos = [] + entries = info.get("entries", []) + + for entry in entries: + if entry: # Skip None entries + try: + # For playlist entries, we need to extract full info if not already available + if not entry.get("formats"): + # Re-extract with full info for this specific video + with yt_dlp.YoutubeDL(ydl_options) as ydl: + full_entry = ydl.extract_info(entry.get("url") or entry.get("webpage_url"), download=False) + + videos.append(extract_video_info(full_entry)) + else: + videos.append(extract_video_info(entry)) + except Exception as e: + # Skip videos that fail to extract, but don't fail the entire request + continue + + return Response({ + "is_playlist": True, + "playlist_title": info.get("title"), + "playlist_count": len(videos), + "videos": videos, + }, status=200) + else: + # Handle single video + video_info = extract_video_info(info) + + return Response({ + "is_playlist": False, + "playlist_title": None, + "playlist_count": None, + "videos": [video_info], + }, status=200) @extend_schema( tags=["downloader", "public"], - summary="Download video from URL", + summary="Download video or playlist from URL", description=""" - 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. @@ -178,15 +256,12 @@ class Downloader(APIView): - `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) """, request=inline_serializer( name="DownloadRequest", fields={ - "url": serializers.URLField(help_text="Video URL to download from supported platforms"), + "url": serializers.URLField(help_text="Video/Playlist URL to download from supported platforms"), "ext": serializers.CharField( required=False, default="mp4", @@ -202,6 +277,13 @@ class Downloader(APIView): allow_null=True, help_text="Optional: Target max audio bitrate in kbps (e.g. 320, 192, 128). If omitted, best quality is selected." ), + "selected_videos": serializers.ListField( + child=serializers.IntegerField(), + required=False, + allow_null=True, + allow_empty=True, + help_text="For playlists: specify which videos to download as array of numbers (e.g., [1,3,5]). If omitted, all videos are downloaded." + ), "subtitles": serializers.CharField( required=False, allow_null=True, @@ -223,24 +305,6 @@ class Downloader(APIView): default=False, help_text="Extract audio only, ignoring video quality settings" ), - "start_time": serializers.CharField( - required=False, - allow_null=True, - allow_blank=True, - help_text="Start time for trimming (format: HH:MM:SS or seconds as integer)" - ), - "end_time": serializers.CharField( - required=False, - allow_null=True, - allow_blank=True, - help_text="End time for trimming (format: HH:MM:SS or seconds as integer)" - ), - "playlist_items": serializers.CharField( - required=False, - allow_null=True, - allow_blank=True, - help_text="Playlist items to download (e.g., '1-5,8,10' or '1,2,3')" - ), "cookies": serializers.CharField( required=False, allow_null=True, @@ -280,14 +344,12 @@ class Downloader(APIView): except (ValueError, TypeError): return Response({"error": "Invalid audio_quality parameter, must be an integer!"}, status=400) - # Advanced options + # Advanced options (removed start_time and end_time) + selected_videos = request.data.get("selected_videos") subtitles = request.data.get("subtitles") embed_subtitles = request.data.get("embed_subtitles", False) embed_thumbnail = request.data.get("embed_thumbnail", False) extract_audio = request.data.get("extract_audio", False) - start_time = request.data.get("start_time") - end_time = request.data.get("end_time") - playlist_items = request.data.get("playlist_items") cookies = request.data.get("cookies") if not url: @@ -298,38 +360,45 @@ class Downloader(APIView): # Ensure base tmp dir exists os.makedirs(settings.DOWNLOADER_TMP_DIR, exist_ok=True) tmpdir = tempfile.mkdtemp(prefix="downloader_", dir=settings.DOWNLOADER_TMP_DIR) - outtmpl = os.path.join(tmpdir, "download.%(ext)s") - # Build a format selector using optional quality caps - # If quality params are not provided, use best quality + # First, check if this is a playlist + ydl_info_options = { + "quiet": True, + "no_check_certificates": True, + "extract_flat": False, + } + + try: + with yt_dlp.YoutubeDL(ydl_info_options) as ydl: + info = ydl.extract_info(url, download=False) + except Exception as e: + shutil.rmtree(tmpdir, ignore_errors=True) + return Response({"error": f"Failed to retrieve URL info: {str(e)}"}, status=400) + + is_playlist = "entries" in info and info.get("entries") is not None + + # Build format selector using optional quality caps if video_quality is not None and audio_quality is not None: - # Both specified: "bv[height<=1080]+ba[abr<=160]/b" format_selector = f"bv[height<={video_quality}]+ba[abr<={audio_quality}]/b" elif video_quality is not None: - # Only video quality specified: "bv[height<=1080]+ba/b" format_selector = f"bv[height<={video_quality}]+ba/b" elif audio_quality is not None: - # Only audio quality specified: "bv+ba[abr<=160]/b" format_selector = f"bv+ba[abr<={audio_quality}]/b" else: - # No quality constraints, let yt-dlp choose best format_selector = "b/bv+ba" - + # Common ydl options ydl_options = { - "format": format_selector, # select by requested quality - "merge_output_format": ext, # container - "outtmpl": outtmpl, # temp dir + "format": format_selector, + "merge_output_format": ext, "quiet": True, - "no_check_certificates": True, # Bypass SSL verification in Docker + "no_check_certificates": True, "max_filesize": settings.DOWNLOADER_MAX_SIZE_BYTES, - "socket_timeout": settings.DOWNLOADER_TIMEOUT, "postprocessors": [], } # Handle cookies for age-restricted content if cookies: - # Save cookies to a temporary file cookie_file = os.path.join(tmpdir, "cookies.txt") try: with open(cookie_file, "w") as f: @@ -369,18 +438,11 @@ class Downloader(APIView): "preferredcodec": ext if ext in ["mp3", "m4a", "opus", "vorbis", "wav"] else "mp3", }) - # Download sections (trim) - if start_time or end_time: - download_ranges = {} - if start_time: - download_ranges["start_time"] = start_time - if end_time: - download_ranges["end_time"] = end_time - ydl_options["download_ranges"] = lambda info_dict, ydl: [download_ranges] - - # Playlist items - if playlist_items: - ydl_options["playlist_items"] = playlist_items + # Playlist items (use selected_videos parameter) + if is_playlist and selected_videos: + # Convert array of numbers to yt-dlp format string + playlist_items_str = ",".join(str(num) for num in selected_videos) + ydl_options["playlist_items"] = playlist_items_str # Add remux postprocessor if not extracting audio if not extract_audio: @@ -388,53 +450,125 @@ class Downloader(APIView): {"key": "FFmpegVideoRemuxer", "preferedformat": ext} ) - file_path = "" try: - with yt_dlp.YoutubeDL(ydl_options) as ydl: - info = ydl.extract_info(url, download=True) - base = ydl.prepare_filename(info) - file_path = base if base.endswith(f".{ext}") else os.path.splitext(base)[0] + f".{ext}" + if is_playlist: + # Handle playlist - create ZIP file + ydl_options["outtmpl"] = os.path.join(tmpdir, "%(playlist_index)02d - %(title)s.%(ext)s") + + with yt_dlp.YoutubeDL(ydl_options) as ydl: + ydl.download([url]) + + # Create ZIP file + zip_path = os.path.join(tmpdir, f"playlist.zip") + downloaded_files = [] + + # Find all downloaded files + for filename in os.listdir(tmpdir): + if filename != "playlist.zip" and filename != "cookies.txt" and not filename.startswith("."): + file_path = os.path.join(tmpdir, filename) + if os.path.isfile(file_path): + downloaded_files.append((filename, file_path)) + + if not downloaded_files: + shutil.rmtree(tmpdir, ignore_errors=True) + return Response({"error": "No files were downloaded from the playlist"}, status=400) + + # Create ZIP + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + for filename, file_path in downloaded_files: + zipf.write(file_path, filename) + + # Stats for database + total_duration = 0 + total_size = os.path.getsize(zip_path) + + # Try to get duration from info + if info.get("entries"): + for entry in info["entries"]: + if entry and entry.get("duration"): + total_duration += int(entry.get("duration", 0)) + + DownloaderRecord.objects.create( + url=url, + format="zip", + length_of_media=total_duration, + file_size=total_size, + ) - # Stats before streaming - duration = int((info or {}).get("duration") or 0) - size = os.path.getsize(file_path) if os.path.exists(file_path) else 0 - - DownloaderRecord.objects.create( - url=url, - format=ext, - length_of_media=duration, - file_size=size, - ) - - # Streaming generator that deletes file & temp dir after send (or on abort) - def stream_and_cleanup(path: str, temp_dir: str, chunk_size: int = 8192): - try: - with open(path, "rb") as f: - while True: - chunk = f.read(chunk_size) - if not chunk: - break - yield chunk - finally: + # Streaming response for ZIP + def stream_and_cleanup_zip(zip_file_path: str, temp_dir: str, chunk_size: int = 8192): try: - if os.path.exists(path): - os.remove(path) + with open(zip_file_path, "rb") as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + yield chunk finally: shutil.rmtree(temp_dir, ignore_errors=True) - safe_title = slugify(info.get("title") or "video") - filename = f"{safe_title}.{ext}" - content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + playlist_title = slugify(info.get("title", "playlist")) + zip_filename = f"{playlist_title}.zip" - response = StreamingHttpResponse( - streaming_content=stream_and_cleanup(file_path, tmpdir), - content_type=content_type, - ) - if size: - response["Content-Length"] = str(size) - response["Content-Disposition"] = f'attachment; filename="{filename}"' + response = StreamingHttpResponse( + streaming_content=stream_and_cleanup_zip(zip_path, tmpdir), + content_type="application/zip", + ) + response["Content-Length"] = str(total_size) + response["Content-Disposition"] = f'attachment; filename="{zip_filename}"' - return response + return response + + else: + # Handle single video (existing logic) + outtmpl = os.path.join(tmpdir, "download.%(ext)s") + ydl_options["outtmpl"] = outtmpl + + with yt_dlp.YoutubeDL(ydl_options) as ydl: + info = ydl.extract_info(url, download=True) + base = ydl.prepare_filename(info) + file_path = base if base.endswith(f".{ext}") else os.path.splitext(base)[0] + f".{ext}" + + # Stats before streaming + duration = int((info or {}).get("duration") or 0) + size = os.path.getsize(file_path) if os.path.exists(file_path) else 0 + + DownloaderRecord.objects.create( + url=url, + format=ext, + length_of_media=duration, + file_size=size, + ) + + # Streaming generator that deletes file & temp dir after send + def stream_and_cleanup(path: str, temp_dir: str, chunk_size: int = 8192): + try: + with open(path, "rb") as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + yield chunk + finally: + try: + if os.path.exists(path): + os.remove(path) + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + safe_title = slugify(info.get("title") or "video") + filename = f"{safe_title}.{ext}" + content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + + response = StreamingHttpResponse( + streaming_content=stream_and_cleanup(file_path, tmpdir), + content_type=content_type, + ) + if size: + response["Content-Length"] = str(size) + response["Content-Disposition"] = f'attachment; filename="{filename}"' + + return response except Exception as e: shutil.rmtree(tmpdir, ignore_errors=True) diff --git a/backups/backup-20251224-021048.sql b/backups/backup-20251224-021048.sql new file mode 100644 index 0000000..e69de29 diff --git a/backups/backup-20251224-144159.sql b/backups/backup-20251224-144159.sql new file mode 100644 index 0000000..e69de29 diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod index 1c32c6b..5ce2da7 100644 --- a/frontend/Dockerfile.prod +++ b/frontend/Dockerfile.prod @@ -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 . . diff --git a/frontend/nginx/nginx.conf b/frontend/nginx/nginx.conf index ffe449d..a6bf1af 100644 --- a/frontend/nginx/nginx.conf +++ b/frontend/nginx/nginx.conf @@ -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; } } diff --git a/frontend/src/api/generated/private/models/downloadRequest.ts b/frontend/src/api/generated/private/models/downloadRequest.ts index dd61b6f..72d0110 100644 --- a/frontend/src/api/generated/private/models/downloadRequest.ts +++ b/frontend/src/api/generated/private/models/downloadRequest.ts @@ -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 diff --git a/frontend/src/api/generated/private/models/index.ts b/frontend/src/api/generated/private/models/index.ts index ab641ed..42a4d9d 100644 --- a/frontend/src/api/generated/private/models/index.ts +++ b/frontend/src/api/generated/private/models/index.ts @@ -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"; diff --git a/frontend/src/api/generated/private/models/stateE15Enum.ts b/frontend/src/api/generated/private/models/stateE15Enum.ts deleted file mode 100644 index e2ca3c9..0000000 --- a/frontend/src/api/generated/private/models/stateE15Enum.ts +++ /dev/null @@ -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; diff --git a/frontend/src/api/generated/private/models/stateFdaEnum.ts b/frontend/src/api/generated/private/models/stateFdaEnum.ts deleted file mode 100644 index d76f293..0000000 --- a/frontend/src/api/generated/private/models/stateFdaEnum.ts +++ /dev/null @@ -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; diff --git a/frontend/src/api/generated/private/models/videoInfo.ts b/frontend/src/api/generated/private/models/videoInfo.ts new file mode 100644 index 0000000..ee6811d --- /dev/null +++ b/frontend/src/api/generated/private/models/videoInfo.ts @@ -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[]; +} diff --git a/frontend/src/api/generated/private/models/videoInfoResponse.ts b/frontend/src/api/generated/private/models/videoInfoResponse.ts index e23e8b1..a47d833 100644 --- a/frontend/src/api/generated/private/models/videoInfoResponse.ts +++ b/frontend/src/api/generated/private/models/videoInfoResponse.ts @@ -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[]; } diff --git a/frontend/src/api/generated/public/downloader.ts b/frontend/src/api/generated/public/downloader.ts index 054fc93..30bdac3 100644 --- a/frontend/src/api/generated/public/downloader.ts +++ b/frontend/src/api/generated/public/downloader.ts @@ -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, diff --git a/frontend/src/api/generated/public/models/apiDownloaderDownloadRetrieveParams.ts b/frontend/src/api/generated/public/models/apiDownloaderDownloadRetrieveParams.ts index 5c520ce..a68c492 100644 --- a/frontend/src/api/generated/public/models/apiDownloaderDownloadRetrieveParams.ts +++ b/frontend/src/api/generated/public/models/apiDownloaderDownloadRetrieveParams.ts @@ -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; diff --git a/frontend/src/api/generated/public/models/downloadRequest.ts b/frontend/src/api/generated/public/models/downloadRequest.ts index dd61b6f..72d0110 100644 --- a/frontend/src/api/generated/public/models/downloadRequest.ts +++ b/frontend/src/api/generated/public/models/downloadRequest.ts @@ -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 diff --git a/frontend/src/api/generated/public/models/index.ts b/frontend/src/api/generated/public/models/index.ts index 70c8331..609b709 100644 --- a/frontend/src/api/generated/public/models/index.ts +++ b/frontend/src/api/generated/public/models/index.ts @@ -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"; diff --git a/frontend/src/api/generated/public/models/stateE15Enum.ts b/frontend/src/api/generated/public/models/stateE15Enum.ts deleted file mode 100644 index e2ca3c9..0000000 --- a/frontend/src/api/generated/public/models/stateE15Enum.ts +++ /dev/null @@ -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; diff --git a/frontend/src/api/generated/public/models/stateFdaEnum.ts b/frontend/src/api/generated/public/models/stateFdaEnum.ts deleted file mode 100644 index d76f293..0000000 --- a/frontend/src/api/generated/public/models/stateFdaEnum.ts +++ /dev/null @@ -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; diff --git a/frontend/src/api/generated/public/models/videoInfo.ts b/frontend/src/api/generated/public/models/videoInfo.ts new file mode 100644 index 0000000..ee6811d --- /dev/null +++ b/frontend/src/api/generated/public/models/videoInfo.ts @@ -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[]; +} diff --git a/frontend/src/api/generated/public/models/videoInfoResponse.ts b/frontend/src/api/generated/public/models/videoInfoResponse.ts index e23e8b1..a47d833 100644 --- a/frontend/src/api/generated/public/models/videoInfoResponse.ts +++ b/frontend/src/api/generated/public/models/videoInfoResponse.ts @@ -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[]; } diff --git a/frontend/src/pages/downloader/Downloader.tsx b/frontend/src/pages/downloader/Downloader.tsx index 6eed859..11fb573 100644 --- a/frontend/src/pages/downloader/Downloader.tsx +++ b/frontend/src/pages/downloader/Downloader.tsx @@ -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(''); const [selectedExtension, setSelectedExtension] = useState('mp4'); - // Advanced options + // Playlist selection + const [selectedVideos, setSelectedVideos] = useState([]); + + // Advanced options (removed start_time, end_time, playlist_items) const [subtitles, setSubtitles] = useState(''); const [embedSubtitles, setEmbedSubtitles] = useState(false); const [embedThumbnail, setEmbedThumbnail] = useState(false); - const [extractAudio, setExtractAudio] = useState(false); - const [startTime, setStartTime] = useState(''); - const [endTime, setEndTime] = useState(''); - const [playlistItems, setPlaylistItems] = useState(''); const [cookies, setCookies] = useState(''); const [showAdvanced, setShowAdvanced] = useState(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(); + 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(); + 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 ( -
-

Video Downloader

+
+

Video Downloader

-
+
+
+ + Video URL +
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" />
{error && ( -
+
Error: {error.error}
)} {videoInfo && ( -
-

{videoInfo.title}

- - {videoInfo.thumbnail && ( - {videoInfo.title} - )} - - {videoInfo.duration && ( -

- Duration: {Math.floor(videoInfo.duration / 60)}:{String(videoInfo.duration % 60).padStart(2, '0')} -

+
+ {videoInfo.is_playlist ? ( +
+

+ 📋 {videoInfo.playlist_title || 'Playlist'} +

+

+ {videoInfo.playlist_count} videos found +

+ + {/* Playlist Video Selection */} +
+
+

Select Videos to Download:

+
+ + +
+
+ +
+ {videoInfo.videos.map((video, index) => { + const videoNumber = index + 1; + return ( +
+ +
+ ); + })} +
+ +
+ {selectedVideos.length} of {videoInfo.videos.length} videos selected +
+
+
+ ) : ( +
+

+ 🎥 {videoInfo.videos[0]?.title || 'Video'} +

+ + {videoInfo.videos[0]?.thumbnail && ( + {videoInfo.videos[0].title} + )} + + {videoInfo.videos[0]?.duration && ( +

+ Duration: {Math.floor(videoInfo.videos[0].duration / 60)}:{String(videoInfo.videos[0].duration % 60).padStart(2, '0')} +

+ )} +
)} -
+ {/* Quality and Format Selection */} +
{/* Video Quality Dropdown */}
-