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:
428
backend/thirdparty/downloader/views.py
vendored
428
backend/thirdparty/downloader/views.py
vendored
@@ -8,6 +8,9 @@ import shutil
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import base64
|
import base64
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
import requests
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
@@ -43,18 +46,24 @@ class Downloader(APIView):
|
|||||||
tags=["downloader", "public"],
|
tags=["downloader", "public"],
|
||||||
summary="Get video info from URL",
|
summary="Get video info from URL",
|
||||||
description="""
|
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.
|
**Supported platforms:** YouTube, TikTok, Vimeo, Twitter, Instagram, Facebook, Reddit, and many more.
|
||||||
|
|
||||||
**Returns:**
|
**Returns:**
|
||||||
|
For single videos:
|
||||||
- Video title, duration, and thumbnail
|
- Video title, duration, and thumbnail
|
||||||
- Available video qualities/resolutions
|
- Available video qualities/resolutions
|
||||||
- Available audio formats
|
- 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:**
|
**Usage:**
|
||||||
```
|
```
|
||||||
GET /api/downloader/download/?url=https://youtube.com/watch?v=VIDEO_ID
|
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=[
|
parameters=[
|
||||||
@@ -62,7 +71,7 @@ class Downloader(APIView):
|
|||||||
name="VideoInfoParams",
|
name="VideoInfoParams",
|
||||||
fields={
|
fields={
|
||||||
"url": serializers.URLField(
|
"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(
|
200: inline_serializer(
|
||||||
name="VideoInfoResponse",
|
name="VideoInfoResponse",
|
||||||
fields={
|
fields={
|
||||||
"title": serializers.CharField(help_text="Video title"),
|
"is_playlist": serializers.BooleanField(help_text="Whether the URL is a playlist"),
|
||||||
"duration": serializers.IntegerField(allow_null=True, help_text="Video duration in seconds (null if unavailable)"),
|
"playlist_title": serializers.CharField(allow_null=True, help_text="Playlist title (if applicable)"),
|
||||||
"thumbnail": serializers.URLField(allow_null=True, help_text="URL to video thumbnail image"),
|
"playlist_count": serializers.IntegerField(allow_null=True, help_text="Number of videos in playlist (if applicable)"),
|
||||||
"video_resolutions": serializers.ListField(
|
"videos": serializers.ListField(
|
||||||
child=serializers.CharField(),
|
child=inline_serializer(
|
||||||
help_text="List of available video quality options (e.g., '1080p', '720p', '480p')"
|
name="VideoInfo",
|
||||||
),
|
fields={
|
||||||
"audio_resolutions": serializers.ListField(
|
"id": serializers.CharField(help_text="Video ID"),
|
||||||
child=serializers.CharField(),
|
"title": serializers.CharField(help_text="Video title"),
|
||||||
help_text="List of available audio format options"
|
"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 = {
|
ydl_options = {
|
||||||
"quiet": True,
|
"quiet": True,
|
||||||
"no_check_certificates": True, # Bypass SSL verification in Docker
|
"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:
|
try:
|
||||||
with yt_dlp.YoutubeDL(ydl_options) as ydl:
|
with yt_dlp.YoutubeDL(ydl_options) as ydl:
|
||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response({"error": f"Failed to retrieve video info: {str(e)}"}, status=400)
|
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
|
# Video: collect unique heights and sort desc (highest quality first)
|
||||||
heights = {
|
heights = {
|
||||||
int(f.get("height"))
|
int(f.get("height"))
|
||||||
for f in formats
|
for f in formats
|
||||||
if f.get("vcodec") != "none" and isinstance(f.get("height"), int)
|
if f.get("vcodec") != "none" and isinstance(f.get("height"), int)
|
||||||
}
|
}
|
||||||
video_resolutions = [f"{h}p" for h in sorted(heights, reverse=True)]
|
video_resolutions = [f"{h}p" for h in sorted(heights, reverse=True)]
|
||||||
|
|
||||||
# Audio: collect unique bitrates (abr kbps), fallback to tbr when abr missing
|
# Audio: collect unique bitrates (abr kbps), fallback to tbr when abr missing
|
||||||
bitrates = set()
|
bitrates = set()
|
||||||
for f in formats:
|
for f in formats:
|
||||||
if f.get("acodec") != "none" and f.get("vcodec") == "none":
|
if f.get("acodec") != "none" and f.get("vcodec") == "none":
|
||||||
abr = f.get("abr")
|
abr = f.get("abr")
|
||||||
tbr = f.get("tbr")
|
tbr = f.get("tbr")
|
||||||
val = None
|
val = None
|
||||||
if isinstance(abr, (int, float)):
|
if isinstance(abr, (int, float)):
|
||||||
val = int(abr)
|
val = int(abr)
|
||||||
elif isinstance(tbr, (int, float)):
|
elif isinstance(tbr, (int, float)):
|
||||||
val = int(tbr)
|
val = int(tbr)
|
||||||
if val and val > 0:
|
if val and val > 0:
|
||||||
bitrates.add(val)
|
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)
|
# Fetch thumbnail and convert to base64 blob
|
||||||
thumbnail_url = info.get("thumbnail")
|
thumbnail_blob = None
|
||||||
thumbnail_data_url = None
|
thumbnail_url = video_data.get("thumbnail")
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(thumbnail_url, timeout=10) as response:
|
headers = {
|
||||||
image_data = response.read()
|
'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',
|
||||||
content_type = response.headers.get('Content-Type', 'image/jpeg')
|
'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8',
|
||||||
thumbnail_data_url = f"data:{content_type};base64,{base64.b64encode(image_data).decode('utf-8')}"
|
}
|
||||||
except Exception:
|
response = requests.get(thumbnail_url, headers=headers, timeout=10)
|
||||||
# If thumbnail fetch fails, just use the original URL
|
response.raise_for_status()
|
||||||
thumbnail_data_url = thumbnail_url
|
|
||||||
|
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(
|
return {
|
||||||
{
|
"id": video_data.get("id", ""),
|
||||||
"title": info.get("title"),
|
"title": video_data.get("title", ""),
|
||||||
"duration": info.get("duration"),
|
"duration": video_data.get("duration"),
|
||||||
"thumbnail": thumbnail_data_url,
|
"thumbnail": thumbnail_blob, # Now a base64 blob instead of URL
|
||||||
"video_resolutions": video_resolutions,
|
"video_resolutions": video_resolutions,
|
||||||
"audio_resolutions": audio_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(
|
@extend_schema(
|
||||||
tags=["downloader", "public"],
|
tags=["downloader", "public"],
|
||||||
summary="Download video from URL",
|
summary="Download video or playlist from URL",
|
||||||
description="""
|
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):**
|
**Quality Parameters (optional):**
|
||||||
- If not specified, yt-dlp will automatically select the best available quality.
|
- 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_subtitles`: Embed subtitles into video file
|
||||||
- `embed_thumbnail`: Embed thumbnail as cover art
|
- `embed_thumbnail`: Embed thumbnail as cover art
|
||||||
- `extract_audio`: Extract audio only (ignores video quality)
|
- `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)
|
- `cookies`: Browser cookies for age-restricted content (Netscape format)
|
||||||
""",
|
""",
|
||||||
request=inline_serializer(
|
request=inline_serializer(
|
||||||
name="DownloadRequest",
|
name="DownloadRequest",
|
||||||
fields={
|
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(
|
"ext": serializers.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
default="mp4",
|
default="mp4",
|
||||||
@@ -202,6 +277,13 @@ class Downloader(APIView):
|
|||||||
allow_null=True,
|
allow_null=True,
|
||||||
help_text="Optional: Target max audio bitrate in kbps (e.g. 320, 192, 128). If omitted, best quality is selected."
|
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(
|
"subtitles": serializers.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
@@ -223,24 +305,6 @@ class Downloader(APIView):
|
|||||||
default=False,
|
default=False,
|
||||||
help_text="Extract audio only, ignoring video quality settings"
|
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(
|
"cookies": serializers.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
@@ -280,14 +344,12 @@ class Downloader(APIView):
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return Response({"error": "Invalid audio_quality parameter, must be an integer!"}, status=400)
|
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")
|
subtitles = request.data.get("subtitles")
|
||||||
embed_subtitles = request.data.get("embed_subtitles", False)
|
embed_subtitles = request.data.get("embed_subtitles", False)
|
||||||
embed_thumbnail = request.data.get("embed_thumbnail", False)
|
embed_thumbnail = request.data.get("embed_thumbnail", False)
|
||||||
extract_audio = request.data.get("extract_audio", 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")
|
cookies = request.data.get("cookies")
|
||||||
|
|
||||||
if not url:
|
if not url:
|
||||||
@@ -298,38 +360,45 @@ class Downloader(APIView):
|
|||||||
# Ensure base tmp dir exists
|
# Ensure base tmp dir exists
|
||||||
os.makedirs(settings.DOWNLOADER_TMP_DIR, exist_ok=True)
|
os.makedirs(settings.DOWNLOADER_TMP_DIR, exist_ok=True)
|
||||||
tmpdir = tempfile.mkdtemp(prefix="downloader_", dir=settings.DOWNLOADER_TMP_DIR)
|
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
|
# First, check if this is a playlist
|
||||||
# If quality params are not provided, use best quality
|
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:
|
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"
|
format_selector = f"bv[height<={video_quality}]+ba[abr<={audio_quality}]/b"
|
||||||
elif video_quality is not None:
|
elif video_quality is not None:
|
||||||
# Only video quality specified: "bv[height<=1080]+ba/b"
|
|
||||||
format_selector = f"bv[height<={video_quality}]+ba/b"
|
format_selector = f"bv[height<={video_quality}]+ba/b"
|
||||||
elif audio_quality is not None:
|
elif audio_quality is not None:
|
||||||
# Only audio quality specified: "bv+ba[abr<=160]/b"
|
|
||||||
format_selector = f"bv+ba[abr<={audio_quality}]/b"
|
format_selector = f"bv+ba[abr<={audio_quality}]/b"
|
||||||
else:
|
else:
|
||||||
# No quality constraints, let yt-dlp choose best
|
|
||||||
format_selector = "b/bv+ba"
|
format_selector = "b/bv+ba"
|
||||||
|
|
||||||
|
# Common ydl options
|
||||||
ydl_options = {
|
ydl_options = {
|
||||||
"format": format_selector, # select by requested quality
|
"format": format_selector,
|
||||||
"merge_output_format": ext, # container
|
"merge_output_format": ext,
|
||||||
"outtmpl": outtmpl, # temp dir
|
|
||||||
"quiet": True,
|
"quiet": True,
|
||||||
"no_check_certificates": True, # Bypass SSL verification in Docker
|
"no_check_certificates": True,
|
||||||
"max_filesize": settings.DOWNLOADER_MAX_SIZE_BYTES,
|
"max_filesize": settings.DOWNLOADER_MAX_SIZE_BYTES,
|
||||||
"socket_timeout": settings.DOWNLOADER_TIMEOUT,
|
|
||||||
"postprocessors": [],
|
"postprocessors": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Handle cookies for age-restricted content
|
# Handle cookies for age-restricted content
|
||||||
if cookies:
|
if cookies:
|
||||||
# Save cookies to a temporary file
|
|
||||||
cookie_file = os.path.join(tmpdir, "cookies.txt")
|
cookie_file = os.path.join(tmpdir, "cookies.txt")
|
||||||
try:
|
try:
|
||||||
with open(cookie_file, "w") as f:
|
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",
|
"preferredcodec": ext if ext in ["mp3", "m4a", "opus", "vorbis", "wav"] else "mp3",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Download sections (trim)
|
# Playlist items (use selected_videos parameter)
|
||||||
if start_time or end_time:
|
if is_playlist and selected_videos:
|
||||||
download_ranges = {}
|
# Convert array of numbers to yt-dlp format string
|
||||||
if start_time:
|
playlist_items_str = ",".join(str(num) for num in selected_videos)
|
||||||
download_ranges["start_time"] = start_time
|
ydl_options["playlist_items"] = playlist_items_str
|
||||||
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
|
|
||||||
|
|
||||||
# Add remux postprocessor if not extracting audio
|
# Add remux postprocessor if not extracting audio
|
||||||
if not extract_audio:
|
if not extract_audio:
|
||||||
@@ -388,53 +450,125 @@ class Downloader(APIView):
|
|||||||
{"key": "FFmpegVideoRemuxer", "preferedformat": ext}
|
{"key": "FFmpegVideoRemuxer", "preferedformat": ext}
|
||||||
)
|
)
|
||||||
|
|
||||||
file_path = ""
|
|
||||||
try:
|
try:
|
||||||
with yt_dlp.YoutubeDL(ydl_options) as ydl:
|
if is_playlist:
|
||||||
info = ydl.extract_info(url, download=True)
|
# Handle playlist - create ZIP file
|
||||||
base = ydl.prepare_filename(info)
|
ydl_options["outtmpl"] = os.path.join(tmpdir, "%(playlist_index)02d - %(title)s.%(ext)s")
|
||||||
file_path = base if base.endswith(f".{ext}") else os.path.splitext(base)[0] + f".{ext}"
|
|
||||||
|
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
|
# Streaming response for ZIP
|
||||||
duration = int((info or {}).get("duration") or 0)
|
def stream_and_cleanup_zip(zip_file_path: str, temp_dir: str, chunk_size: int = 8192):
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
if os.path.exists(path):
|
with open(zip_file_path, "rb") as f:
|
||||||
os.remove(path)
|
while True:
|
||||||
|
chunk = f.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
safe_title = slugify(info.get("title") or "video")
|
playlist_title = slugify(info.get("title", "playlist"))
|
||||||
filename = f"{safe_title}.{ext}"
|
zip_filename = f"{playlist_title}.zip"
|
||||||
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
||||||
|
|
||||||
response = StreamingHttpResponse(
|
response = StreamingHttpResponse(
|
||||||
streaming_content=stream_and_cleanup(file_path, tmpdir),
|
streaming_content=stream_and_cleanup_zip(zip_path, tmpdir),
|
||||||
content_type=content_type,
|
content_type="application/zip",
|
||||||
)
|
)
|
||||||
if size:
|
response["Content-Length"] = str(total_size)
|
||||||
response["Content-Length"] = str(size)
|
response["Content-Disposition"] = f'attachment; filename="{zip_filename}"'
|
||||||
response["Content-Disposition"] = f'attachment; filename="{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:
|
except Exception as e:
|
||||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
|||||||
0
backups/backup-20251224-021048.sql
Normal file
0
backups/backup-20251224-021048.sql
Normal file
0
backups/backup-20251224-144159.sql
Normal file
0
backups/backup-20251224-144159.sql
Normal file
@@ -1,17 +1,12 @@
|
|||||||
# Step 1: Build React (Vite) app
|
# Step 1: Build React (Vite) app
|
||||||
FROM node:22-alpine AS build
|
FROM node:22 AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Clean install with force flag to bypass cache issues
|
# Clean install
|
||||||
#RUN rm -rf node_modules package-lock.json && \
|
RUN npm ci --legacy-peer-deps
|
||||||
# npm cache clean --force && \
|
|
||||||
# npm install --legacy-peer-deps
|
|
||||||
|
|
||||||
# install
|
|
||||||
RUN npm install --legacy-peer-deps
|
|
||||||
|
|
||||||
# Copy source files
|
# Copy source files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ http {
|
|||||||
sendfile on;
|
sendfile on;
|
||||||
keepalive_timeout 65;
|
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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
@@ -27,7 +32,7 @@ http {
|
|||||||
location / {
|
location / {
|
||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
# Ensure CSP is present on SPA document responses too
|
# 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;
|
client_max_body_size 50m;
|
||||||
|
|
||||||
# Ensure CSP is also present on proxied responses
|
# 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 X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
# Minimal, valid CSP for development (apply on all responses)
|
# CSP Policy - Centrally defined above for better maintainability
|
||||||
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;
|
# 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface DownloadRequest {
|
export interface DownloadRequest {
|
||||||
/** Video URL to download from supported platforms */
|
/** Video/Playlist URL to download from supported platforms */
|
||||||
url: string;
|
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. */
|
/** 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;
|
ext?: string;
|
||||||
@@ -19,6 +19,11 @@ export interface DownloadRequest {
|
|||||||
* @nullable
|
* @nullable
|
||||||
*/
|
*/
|
||||||
audio_quality?: number | null;
|
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
|
* Language codes (e.g., 'en', 'cs', 'en,cs') or 'all' for all available subtitles
|
||||||
* @nullable
|
* @nullable
|
||||||
@@ -30,21 +35,6 @@ export interface DownloadRequest {
|
|||||||
embed_thumbnail?: boolean;
|
embed_thumbnail?: boolean;
|
||||||
/** Extract audio only, ignoring video quality settings */
|
/** Extract audio only, ignoring video quality settings */
|
||||||
extract_audio?: boolean;
|
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'
|
* Browser cookies in Netscape format for age-restricted content. Export from browser extensions like 'Get cookies.txt'
|
||||||
* @nullable
|
* @nullable
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export * from "./stateFdaEnum";
|
|||||||
export * from "./statusEnum";
|
export * from "./statusEnum";
|
||||||
export * from "./trackingURL";
|
export * from "./trackingURL";
|
||||||
export * from "./userRegistration";
|
export * from "./userRegistration";
|
||||||
|
export * from "./videoInfo";
|
||||||
export * from "./videoInfoResponse";
|
export * from "./videoInfoResponse";
|
||||||
export * from "./zasilkovnaPacket";
|
export * from "./zasilkovnaPacket";
|
||||||
export * from "./zasilkovnaPacketRead";
|
export * from "./zasilkovnaPacketRead";
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
26
frontend/src/api/generated/private/models/videoInfo.ts
Normal file
26
frontend/src/api/generated/private/models/videoInfo.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -3,22 +3,21 @@
|
|||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* OpenAPI spec version: 0.0.0
|
* OpenAPI spec version: 0.0.0
|
||||||
*/
|
*/
|
||||||
|
import type { VideoInfo } from "./videoInfo";
|
||||||
|
|
||||||
export interface VideoInfoResponse {
|
export interface VideoInfoResponse {
|
||||||
/** Video title */
|
/** Whether the URL is a playlist */
|
||||||
title: string;
|
is_playlist: boolean;
|
||||||
/**
|
/**
|
||||||
* Video duration in seconds (null if unavailable)
|
* Playlist title (if applicable)
|
||||||
* @nullable
|
* @nullable
|
||||||
*/
|
*/
|
||||||
duration: number | null;
|
playlist_title: string | null;
|
||||||
/**
|
/**
|
||||||
* URL to video thumbnail image
|
* Number of videos in playlist (if applicable)
|
||||||
* @nullable
|
* @nullable
|
||||||
*/
|
*/
|
||||||
thumbnail: string | null;
|
playlist_count: number | null;
|
||||||
/** List of available video quality options (e.g., '1080p', '720p', '480p') */
|
/** Array of video information (single video for individual URLs, multiple for playlists) */
|
||||||
video_resolutions: string[];
|
videos: VideoInfo[];
|
||||||
/** List of available audio format options */
|
|
||||||
audio_resolutions: string[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
**Supported platforms:** YouTube, TikTok, Vimeo, Twitter, Instagram, Facebook, Reddit, and many more.
|
||||||
|
|
||||||
**Returns:**
|
**Returns:**
|
||||||
|
For single videos:
|
||||||
- Video title, duration, and thumbnail
|
- Video title, duration, and thumbnail
|
||||||
- Available video qualities/resolutions
|
- Available video qualities/resolutions
|
||||||
- Available audio formats
|
- 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:**
|
**Usage:**
|
||||||
```
|
```
|
||||||
GET /api/downloader/download/?url=https://youtube.com/watch?v=VIDEO_ID
|
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
|
* @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):**
|
**Quality Parameters (optional):**
|
||||||
- If not specified, yt-dlp will automatically select the best available quality.
|
- 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_subtitles`: Embed subtitles into video file
|
||||||
- `embed_thumbnail`: Embed thumbnail as cover art
|
- `embed_thumbnail`: Embed thumbnail as cover art
|
||||||
- `extract_audio`: Extract audio only (ignores video quality)
|
- `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)
|
- `cookies`: Browser cookies for age-restricted content (Netscape format)
|
||||||
|
|
||||||
* @summary Download video from URL
|
* @summary Download video or playlist from URL
|
||||||
*/
|
*/
|
||||||
export const apiDownloaderDownloadCreate = (
|
export const apiDownloaderDownloadCreate = (
|
||||||
downloadRequest: DownloadRequest,
|
downloadRequest: DownloadRequest,
|
||||||
@@ -293,7 +301,7 @@ export type ApiDownloaderDownloadCreateMutationBody = DownloadRequest;
|
|||||||
export type ApiDownloaderDownloadCreateMutationError = DownloadErrorResponse;
|
export type ApiDownloaderDownloadCreateMutationError = DownloadErrorResponse;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Download video from URL
|
* @summary Download video or playlist from URL
|
||||||
*/
|
*/
|
||||||
export const useApiDownloaderDownloadCreate = <
|
export const useApiDownloaderDownloadCreate = <
|
||||||
TError = DownloadErrorResponse,
|
TError = DownloadErrorResponse,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
export type ApiDownloaderDownloadRetrieveParams = {
|
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
|
* @minLength 1
|
||||||
*/
|
*/
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface DownloadRequest {
|
export interface DownloadRequest {
|
||||||
/** Video URL to download from supported platforms */
|
/** Video/Playlist URL to download from supported platforms */
|
||||||
url: string;
|
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. */
|
/** 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;
|
ext?: string;
|
||||||
@@ -19,6 +19,11 @@ export interface DownloadRequest {
|
|||||||
* @nullable
|
* @nullable
|
||||||
*/
|
*/
|
||||||
audio_quality?: number | null;
|
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
|
* Language codes (e.g., 'en', 'cs', 'en,cs') or 'all' for all available subtitles
|
||||||
* @nullable
|
* @nullable
|
||||||
@@ -30,21 +35,6 @@ export interface DownloadRequest {
|
|||||||
embed_thumbnail?: boolean;
|
embed_thumbnail?: boolean;
|
||||||
/** Extract audio only, ignoring video quality settings */
|
/** Extract audio only, ignoring video quality settings */
|
||||||
extract_audio?: boolean;
|
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'
|
* Browser cookies in Netscape format for age-restricted content. Export from browser extensions like 'Get cookies.txt'
|
||||||
* @nullable
|
* @nullable
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export * from "./stateFdaEnum";
|
|||||||
export * from "./statusEnum";
|
export * from "./statusEnum";
|
||||||
export * from "./trackingURL";
|
export * from "./trackingURL";
|
||||||
export * from "./userRegistration";
|
export * from "./userRegistration";
|
||||||
|
export * from "./videoInfo";
|
||||||
export * from "./videoInfoResponse";
|
export * from "./videoInfoResponse";
|
||||||
export * from "./zasilkovnaPacket";
|
export * from "./zasilkovnaPacket";
|
||||||
export * from "./zasilkovnaPacketRead";
|
export * from "./zasilkovnaPacketRead";
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
26
frontend/src/api/generated/public/models/videoInfo.ts
Normal file
26
frontend/src/api/generated/public/models/videoInfo.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -3,22 +3,21 @@
|
|||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* OpenAPI spec version: 0.0.0
|
* OpenAPI spec version: 0.0.0
|
||||||
*/
|
*/
|
||||||
|
import type { VideoInfo } from "./videoInfo";
|
||||||
|
|
||||||
export interface VideoInfoResponse {
|
export interface VideoInfoResponse {
|
||||||
/** Video title */
|
/** Whether the URL is a playlist */
|
||||||
title: string;
|
is_playlist: boolean;
|
||||||
/**
|
/**
|
||||||
* Video duration in seconds (null if unavailable)
|
* Playlist title (if applicable)
|
||||||
* @nullable
|
* @nullable
|
||||||
*/
|
*/
|
||||||
duration: number | null;
|
playlist_title: string | null;
|
||||||
/**
|
/**
|
||||||
* URL to video thumbnail image
|
* Number of videos in playlist (if applicable)
|
||||||
* @nullable
|
* @nullable
|
||||||
*/
|
*/
|
||||||
thumbnail: string | null;
|
playlist_count: number | null;
|
||||||
/** List of available video quality options (e.g., '1080p', '720p', '480p') */
|
/** Array of video information (single video for individual URLs, multiple for playlists) */
|
||||||
video_resolutions: string[];
|
videos: VideoInfo[];
|
||||||
/** List of available audio format options */
|
|
||||||
audio_resolutions: string[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { apiDownloaderDownloadRetrieve, apiDownloaderDownloadCreate } from '@/api/generated/public/downloader';
|
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
|
// Common file extensions supported by ffmpeg
|
||||||
const FILE_EXTENSIONS = [
|
const FILE_EXTENSIONS = [
|
||||||
@@ -10,8 +11,6 @@ const FILE_EXTENSIONS = [
|
|||||||
{ value: 'avi', label: 'AVI (Older format)' },
|
{ value: 'avi', label: 'AVI (Older format)' },
|
||||||
{ value: 'mov', label: 'MOV (Apple-friendly)' },
|
{ value: 'mov', label: 'MOV (Apple-friendly)' },
|
||||||
{ value: 'flv', label: 'FLV (Legacy)' },
|
{ value: 'flv', label: 'FLV (Legacy)' },
|
||||||
{ value: 'm4a', label: 'M4A (Audio only, AAC)' },
|
|
||||||
{ value: 'mp3', label: 'MP3 (Audio only)' },
|
|
||||||
{ value: 'ogg', label: 'OGG (Audio/Video)' },
|
{ value: 'ogg', label: 'OGG (Audio/Video)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -27,24 +26,74 @@ export default function Downloader() {
|
|||||||
const [selectedAudioQuality, setSelectedAudioQuality] = useState<string>('');
|
const [selectedAudioQuality, setSelectedAudioQuality] = useState<string>('');
|
||||||
const [selectedExtension, setSelectedExtension] = useState<string>('mp4');
|
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 [subtitles, setSubtitles] = useState<string>('');
|
||||||
const [embedSubtitles, setEmbedSubtitles] = useState<boolean>(false);
|
const [embedSubtitles, setEmbedSubtitles] = useState<boolean>(false);
|
||||||
const [embedThumbnail, setEmbedThumbnail] = 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 [cookies, setCookies] = useState<string>('');
|
||||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
|
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() {
|
async function retrieveVideoInfo() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setVideoInfo(null);
|
setVideoInfo(null);
|
||||||
|
setSelectedVideos([]); // Reset selected videos
|
||||||
try {
|
try {
|
||||||
const info = await apiDownloaderDownloadRetrieve({ url: videoUrl });
|
const info = await apiDownloaderDownloadRetrieve({ url: videoUrl });
|
||||||
setVideoInfo(info);
|
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) {
|
} catch (err: any) {
|
||||||
setError({ error: err.message || 'Failed to retrieve video info' });
|
setError({ error: err.message || 'Failed to retrieve video info' });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -74,17 +123,15 @@ export default function Downloader() {
|
|||||||
const response = await apiDownloaderDownloadCreate({
|
const response = await apiDownloaderDownloadCreate({
|
||||||
url: videoUrl,
|
url: videoUrl,
|
||||||
ext: selectedExtension,
|
ext: selectedExtension,
|
||||||
video_quality: videoQuality,
|
video_quality: videoQuality || null,
|
||||||
audio_quality: audioQuality,
|
audio_quality: audioQuality || null,
|
||||||
|
// Playlist selection
|
||||||
|
selected_videos: videoInfo?.is_playlist && selectedVideos.length > 0 ? selectedVideos : null,
|
||||||
// Advanced options
|
// Advanced options
|
||||||
subtitles: subtitles || undefined,
|
subtitles: subtitles || null,
|
||||||
embed_subtitles: embedSubtitles,
|
embed_subtitles: embedSubtitles,
|
||||||
embed_thumbnail: embedThumbnail,
|
embed_thumbnail: embedThumbnail,
|
||||||
extract_audio: extractAudio,
|
cookies: cookies || null,
|
||||||
start_time: startTime || undefined,
|
|
||||||
end_time: endTime || undefined,
|
|
||||||
playlist_items: playlistItems || undefined,
|
|
||||||
cookies: cookies || undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// The response should be a Blob, trigger download
|
// The response should be a Blob, trigger download
|
||||||
@@ -92,7 +139,16 @@ export default function Downloader() {
|
|||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
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);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
@@ -105,55 +161,142 @@ export default function Downloader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl mx-auto">
|
<div className="p-8 w-full max-w-4xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold mb-4">Video Downloader</h1>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={videoUrl}
|
value={videoUrl}
|
||||||
onChange={(e) => setVideoUrl(e.target.value)}
|
onChange={(e) => setVideoUrl(e.target.value)}
|
||||||
placeholder="Paste video URL here (YouTube, TikTok, Vimeo, etc.)"
|
placeholder="Paste video URL here (YouTube, TikTok, Vimeo, etc.)"
|
||||||
className="w-full p-2 border rounded"
|
className="w-full p-3 border rounded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={retrieveVideoInfo}
|
onClick={retrieveVideoInfo}
|
||||||
disabled={isLoading || !videoUrl}
|
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'}
|
{isLoading ? 'Loading...' : 'Retrieve Options'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{error && (
|
{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}
|
Error: {error.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{videoInfo && (
|
{videoInfo && (
|
||||||
<div className="mt-6 p-4 border rounded">
|
<div className="mt-8 mx-4 p-6 border rounded">
|
||||||
<h2 className="text-xl font-semibold mb-2">{videoInfo.title}</h2>
|
{videoInfo.is_playlist ? (
|
||||||
|
<div>
|
||||||
{videoInfo.thumbnail && (
|
<h2 className="text-xl font-semibold mb-4">
|
||||||
<img
|
📋 {videoInfo.playlist_title || 'Playlist'}
|
||||||
src={videoInfo.thumbnail}
|
</h2>
|
||||||
alt={videoInfo.title}
|
<p className="text-gray-600 mb-6">
|
||||||
className="mt-2 max-w-md rounded shadow"
|
{videoInfo.playlist_count} videos found
|
||||||
/>
|
</p>
|
||||||
)}
|
|
||||||
|
{/* Playlist Video Selection */}
|
||||||
{videoInfo.duration && (
|
<div className="mb-6">
|
||||||
<p className="mt-2 text-gray-600">
|
<div className="flex items-center gap-4 mb-3">
|
||||||
Duration: {Math.floor(videoInfo.duration / 60)}:{String(videoInfo.duration % 60).padStart(2, '0')}
|
<h3 className="text-lg font-medium">Select Videos to Download:</h3>
|
||||||
</p>
|
<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 */}
|
{/* Video Quality Dropdown */}
|
||||||
<div>
|
<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)
|
Video Quality (optional)
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -162,7 +305,7 @@ export default function Downloader() {
|
|||||||
className="w-full p-2 border rounded"
|
className="w-full p-2 border rounded"
|
||||||
>
|
>
|
||||||
<option value="">Best available</option>
|
<option value="">Best available</option>
|
||||||
{videoInfo.video_resolutions?.map((res) => (
|
{getAllVideoQualities().map((res) => (
|
||||||
<option key={res} value={res}>{res}</option>
|
<option key={res} value={res}>{res}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -170,7 +313,8 @@ export default function Downloader() {
|
|||||||
|
|
||||||
{/* Audio Quality Dropdown */}
|
{/* Audio Quality Dropdown */}
|
||||||
<div>
|
<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)
|
Audio Quality (optional)
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -179,7 +323,7 @@ export default function Downloader() {
|
|||||||
className="w-full p-2 border rounded"
|
className="w-full p-2 border rounded"
|
||||||
>
|
>
|
||||||
<option value="">Best available</option>
|
<option value="">Best available</option>
|
||||||
{videoInfo.audio_resolutions?.map((res) => (
|
{getAllAudioQualities().map((res) => (
|
||||||
<option key={res} value={res}>{res}</option>
|
<option key={res} value={res}>{res}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -187,7 +331,8 @@ export default function Downloader() {
|
|||||||
|
|
||||||
{/* File Extension Dropdown */}
|
{/* File Extension Dropdown */}
|
||||||
<div>
|
<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
|
File Format
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -206,26 +351,37 @@ export default function Downloader() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={isDownloading}
|
disabled={isDownloading || (videoInfo.is_playlist && selectedVideos.length === 0)}
|
||||||
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"
|
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>
|
</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 */}
|
{/* Advanced Options Toggle */}
|
||||||
<div className="mt-8 border-t pt-6">
|
<div className="mt-10 mx-2 border-t pt-8">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
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
|
{showAdvanced ? '▼' : '▶'} Advanced Options
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showAdvanced && (
|
{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 */}
|
{/* Subtitles */}
|
||||||
<div>
|
<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)
|
Subtitles (optional)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -241,7 +397,7 @@ export default function Downloader() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Checkboxes Row */}
|
{/* 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">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -261,67 +417,12 @@ export default function Downloader() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-sm">Embed Thumbnail</span>
|
<span className="text-sm">Embed Thumbnail</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Cookies for Age-Restricted Content */}
|
{/* Cookies for Age-Restricted Content */}
|
||||||
<div>
|
<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)
|
Cookies (for age-restricted content)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
Reference in New Issue
Block a user