ytconvert-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ytconvert/__init__.py +27 -0
- ytconvert/cli.py +146 -0
- ytconvert/converter.py +355 -0
- ytconvert/exceptions.py +69 -0
- ytconvert/py.typed +0 -0
- ytconvert/utils.py +186 -0
- ytconvert/validators.py +133 -0
- ytconvert_cli-1.0.0.dist-info/METADATA +224 -0
- ytconvert_cli-1.0.0.dist-info/RECORD +12 -0
- ytconvert_cli-1.0.0.dist-info/WHEEL +4 -0
- ytconvert_cli-1.0.0.dist-info/entry_points.txt +2 -0
- ytconvert_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
ytconvert/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ytconvert-cli: A YouTube video converter CLI tool.
|
|
3
|
+
|
|
4
|
+
Convert YouTube videos to MP3 or MP4 using yt-dlp.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
__author__ = "ytconvert-cli"
|
|
9
|
+
|
|
10
|
+
from ytconvert.converter import YouTubeConverter
|
|
11
|
+
from ytconvert.exceptions import (
|
|
12
|
+
YTConvertError,
|
|
13
|
+
InvalidURLError,
|
|
14
|
+
DownloadError,
|
|
15
|
+
FormatUnavailableError,
|
|
16
|
+
UnexpectedError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"YouTubeConverter",
|
|
21
|
+
"YTConvertError",
|
|
22
|
+
"InvalidURLError",
|
|
23
|
+
"DownloadError",
|
|
24
|
+
"FormatUnavailableError",
|
|
25
|
+
"UnexpectedError",
|
|
26
|
+
"__version__",
|
|
27
|
+
]
|
ytconvert/cli.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for ytconvert-cli.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from typing_extensions import Annotated
|
|
11
|
+
|
|
12
|
+
from ytconvert import __version__
|
|
13
|
+
from ytconvert.converter import YouTubeConverter
|
|
14
|
+
from ytconvert.exceptions import (
|
|
15
|
+
ExitCode,
|
|
16
|
+
InvalidURLError,
|
|
17
|
+
DownloadError,
|
|
18
|
+
FormatUnavailableError,
|
|
19
|
+
YTConvertError,
|
|
20
|
+
)
|
|
21
|
+
from ytconvert.utils import (
|
|
22
|
+
print_error,
|
|
23
|
+
print_info,
|
|
24
|
+
print_success,
|
|
25
|
+
setup_logging,
|
|
26
|
+
)
|
|
27
|
+
from ytconvert.validators import VALID_FORMATS, VALID_QUALITIES
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def version_callback(value: bool) -> None:
|
|
31
|
+
if value:
|
|
32
|
+
print(f"ytconvert-cli version {__version__}")
|
|
33
|
+
raise typer.Exit()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(
|
|
37
|
+
url: Annotated[
|
|
38
|
+
str,
|
|
39
|
+
typer.Argument(help="YouTube video URL to convert"),
|
|
40
|
+
],
|
|
41
|
+
format: Annotated[
|
|
42
|
+
str,
|
|
43
|
+
typer.Option("--format", "-f", help="Output format: mp3 or mp4"),
|
|
44
|
+
] = "mp3",
|
|
45
|
+
quality: Annotated[
|
|
46
|
+
str,
|
|
47
|
+
typer.Option("--quality", "-q", help="Video quality for MP4 (360p, 720p, 1080p, best)"),
|
|
48
|
+
] = "best",
|
|
49
|
+
output: Annotated[
|
|
50
|
+
Optional[Path],
|
|
51
|
+
typer.Option("--output", "-o", help="Output directory"),
|
|
52
|
+
] = None,
|
|
53
|
+
verbose: Annotated[
|
|
54
|
+
bool,
|
|
55
|
+
typer.Option("--verbose", "-v", help="Enable verbose output"),
|
|
56
|
+
] = False,
|
|
57
|
+
info_only: Annotated[
|
|
58
|
+
bool,
|
|
59
|
+
typer.Option("--info", "-i", help="Only show video info, don't download"),
|
|
60
|
+
] = False,
|
|
61
|
+
version: Annotated[
|
|
62
|
+
Optional[bool],
|
|
63
|
+
typer.Option("--version", "-V", help="Show version", callback=version_callback, is_eager=True),
|
|
64
|
+
] = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Convert YouTube videos to MP3 or MP4 format."""
|
|
67
|
+
setup_logging(verbose=verbose)
|
|
68
|
+
|
|
69
|
+
if info_only:
|
|
70
|
+
try:
|
|
71
|
+
converter = YouTubeConverter(verbose=verbose)
|
|
72
|
+
info = converter.get_video_info(url)
|
|
73
|
+
print()
|
|
74
|
+
print_info(f"Title: {info.get('title', 'Unknown')}")
|
|
75
|
+
duration = info.get('duration', 0) or 0
|
|
76
|
+
print_info(f"Duration: {int(duration) // 60}:{int(duration) % 60:02d}")
|
|
77
|
+
print_info(f"Uploader: {info.get('uploader', 'Unknown')}")
|
|
78
|
+
views = info.get('view_count', 0) or 0
|
|
79
|
+
print_info(f"Views: {views:,}")
|
|
80
|
+
sys.exit(ExitCode.SUCCESS)
|
|
81
|
+
except YTConvertError as e:
|
|
82
|
+
print_error(str(e))
|
|
83
|
+
sys.exit(e.exit_code)
|
|
84
|
+
except SystemExit:
|
|
85
|
+
raise
|
|
86
|
+
except Exception as e:
|
|
87
|
+
print_error(f"Unexpected error: {e}")
|
|
88
|
+
raise typer.Exit(code=ExitCode.UNEXPECTED_ERROR)
|
|
89
|
+
|
|
90
|
+
format_lower = format.lower()
|
|
91
|
+
if format_lower not in VALID_FORMATS:
|
|
92
|
+
print_error(f"Invalid format '{format}'. Must be: {', '.join(VALID_FORMATS)}")
|
|
93
|
+
raise typer.Exit(code=ExitCode.INVALID_URL)
|
|
94
|
+
|
|
95
|
+
quality_lower = quality.lower()
|
|
96
|
+
if format_lower == "mp4" and quality_lower not in VALID_QUALITIES:
|
|
97
|
+
print_error(f"Invalid quality '{quality}'. Must be: {', '.join(VALID_QUALITIES)}")
|
|
98
|
+
raise typer.Exit(code=ExitCode.FORMAT_UNAVAILABLE)
|
|
99
|
+
|
|
100
|
+
output_dir = output if output else Path.cwd()
|
|
101
|
+
|
|
102
|
+
print_info(f"URL: {url}")
|
|
103
|
+
print_info(f"Format: {format_lower.upper()}")
|
|
104
|
+
if format_lower == "mp4":
|
|
105
|
+
print_info(f"Quality: {quality_lower}")
|
|
106
|
+
print_info(f"Output: {output_dir}")
|
|
107
|
+
print()
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
converter = YouTubeConverter(output_dir=output_dir, verbose=verbose)
|
|
111
|
+
output_path = converter.convert(url=url, format_type=format_lower, quality=quality_lower)
|
|
112
|
+
print()
|
|
113
|
+
print_success("Conversion completed successfully!")
|
|
114
|
+
print_success(f"Output file: {output_path}")
|
|
115
|
+
sys.exit(ExitCode.SUCCESS)
|
|
116
|
+
except InvalidURLError as e:
|
|
117
|
+
print_error(str(e))
|
|
118
|
+
sys.exit(ExitCode.INVALID_URL)
|
|
119
|
+
except DownloadError as e:
|
|
120
|
+
print_error(str(e))
|
|
121
|
+
sys.exit(ExitCode.DOWNLOAD_FAILURE)
|
|
122
|
+
except FormatUnavailableError as e:
|
|
123
|
+
print_error(str(e))
|
|
124
|
+
sys.exit(ExitCode.FORMAT_UNAVAILABLE)
|
|
125
|
+
except YTConvertError as e:
|
|
126
|
+
print_error(str(e))
|
|
127
|
+
sys.exit(e.exit_code)
|
|
128
|
+
except KeyboardInterrupt:
|
|
129
|
+
print_error("Operation cancelled by user")
|
|
130
|
+
sys.exit(ExitCode.UNEXPECTED_ERROR)
|
|
131
|
+
except SystemExit:
|
|
132
|
+
raise
|
|
133
|
+
except Exception as e:
|
|
134
|
+
print_error(f"Unexpected error: {e}")
|
|
135
|
+
if verbose:
|
|
136
|
+
import traceback
|
|
137
|
+
traceback.print_exc()
|
|
138
|
+
sys.exit(ExitCode.UNEXPECTED_ERROR)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cli() -> None:
|
|
142
|
+
typer.run(main)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
cli()
|
ytconvert/converter.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""
|
|
2
|
+
YouTube video converter using yt-dlp.
|
|
3
|
+
|
|
4
|
+
This module provides the core conversion functionality for downloading
|
|
5
|
+
YouTube videos and converting them to MP3 or MP4 format.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
import imageio_ffmpeg
|
|
13
|
+
import yt_dlp
|
|
14
|
+
|
|
15
|
+
from ytconvert.exceptions import (
|
|
16
|
+
DownloadError,
|
|
17
|
+
FormatUnavailableError,
|
|
18
|
+
InvalidURLError,
|
|
19
|
+
UnexpectedError,
|
|
20
|
+
)
|
|
21
|
+
from ytconvert.utils import (
|
|
22
|
+
ensure_directory,
|
|
23
|
+
format_duration,
|
|
24
|
+
format_filesize,
|
|
25
|
+
get_logger,
|
|
26
|
+
get_quality_height,
|
|
27
|
+
print_info,
|
|
28
|
+
print_progress,
|
|
29
|
+
print_success,
|
|
30
|
+
sanitize_filename,
|
|
31
|
+
)
|
|
32
|
+
from ytconvert.validators import extract_video_id, validate_youtube_url
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ProgressHook:
|
|
36
|
+
"""Progress hook for yt-dlp to display download progress."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, callback: Callable[[dict[str, Any]], None] | None = None):
|
|
39
|
+
self.callback = callback
|
|
40
|
+
self._last_percent = -1
|
|
41
|
+
|
|
42
|
+
def __call__(self, d: dict[str, Any]) -> None:
|
|
43
|
+
"""Handle progress updates from yt-dlp."""
|
|
44
|
+
status = d.get("status")
|
|
45
|
+
|
|
46
|
+
if status == "downloading":
|
|
47
|
+
total = d.get("total_bytes") or d.get("total_bytes_estimate", 0)
|
|
48
|
+
downloaded = d.get("downloaded_bytes", 0)
|
|
49
|
+
|
|
50
|
+
if total > 0:
|
|
51
|
+
percent = int((downloaded / total) * 100)
|
|
52
|
+
# Only print on significant changes to reduce noise
|
|
53
|
+
if percent != self._last_percent and percent % 10 == 0:
|
|
54
|
+
speed = d.get("speed", 0)
|
|
55
|
+
speed_str = f"{format_filesize(speed)}/s" if speed else "-- KB/s"
|
|
56
|
+
eta = d.get("eta", 0)
|
|
57
|
+
eta_str = format_duration(eta) if eta else "--:--"
|
|
58
|
+
|
|
59
|
+
print_progress(
|
|
60
|
+
f"Downloading: {percent}% | "
|
|
61
|
+
f"Speed: {speed_str} | "
|
|
62
|
+
f"ETA: {eta_str}"
|
|
63
|
+
)
|
|
64
|
+
self._last_percent = percent
|
|
65
|
+
|
|
66
|
+
elif status == "finished":
|
|
67
|
+
filename = d.get("filename", "Unknown")
|
|
68
|
+
print_success(f"Download complete: {Path(filename).name}")
|
|
69
|
+
|
|
70
|
+
elif status == "error":
|
|
71
|
+
get_logger().error("Download encountered an error")
|
|
72
|
+
|
|
73
|
+
if self.callback:
|
|
74
|
+
self.callback(d)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class YouTubeConverter:
|
|
78
|
+
"""
|
|
79
|
+
YouTube video converter using yt-dlp.
|
|
80
|
+
|
|
81
|
+
Provides methods to download and convert YouTube videos to MP3 or MP4 format.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
output_dir: str | Path = ".",
|
|
87
|
+
verbose: bool = False,
|
|
88
|
+
progress_callback: Callable[[dict[str, Any]], None] | None = None,
|
|
89
|
+
):
|
|
90
|
+
"""
|
|
91
|
+
Initialize the YouTube converter.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
output_dir: Directory where converted files will be saved
|
|
95
|
+
verbose: If True, enable verbose logging
|
|
96
|
+
progress_callback: Optional callback for progress updates
|
|
97
|
+
"""
|
|
98
|
+
self.output_dir = ensure_directory(Path(output_dir))
|
|
99
|
+
self.verbose = verbose
|
|
100
|
+
self.progress_callback = progress_callback
|
|
101
|
+
self.logger = get_logger()
|
|
102
|
+
|
|
103
|
+
def _get_ffmpeg_path(self) -> str:
|
|
104
|
+
"""Get the path to the bundled FFmpeg executable."""
|
|
105
|
+
return imageio_ffmpeg.get_ffmpeg_exe()
|
|
106
|
+
|
|
107
|
+
def _get_base_options(self) -> dict[str, Any]:
|
|
108
|
+
"""Get base yt-dlp options common to all downloads."""
|
|
109
|
+
return {
|
|
110
|
+
"outtmpl": str(self.output_dir / "%(title)s.%(ext)s"),
|
|
111
|
+
"restrictfilenames": False,
|
|
112
|
+
"noplaylist": True,
|
|
113
|
+
"quiet": not self.verbose,
|
|
114
|
+
"no_warnings": not self.verbose,
|
|
115
|
+
"progress_hooks": [ProgressHook(self.progress_callback)],
|
|
116
|
+
"retries": 3,
|
|
117
|
+
"fragment_retries": 3,
|
|
118
|
+
"ignoreerrors": False,
|
|
119
|
+
"nocheckcertificate": False,
|
|
120
|
+
"ffmpeg_location": self._get_ffmpeg_path(),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
def _get_mp3_options(self) -> dict[str, Any]:
|
|
124
|
+
"""Get yt-dlp options for MP3 conversion."""
|
|
125
|
+
options = self._get_base_options()
|
|
126
|
+
options.update({
|
|
127
|
+
"format": "bestaudio/best",
|
|
128
|
+
"postprocessors": [{
|
|
129
|
+
"key": "FFmpegExtractAudio",
|
|
130
|
+
"preferredcodec": "mp3",
|
|
131
|
+
"preferredquality": "192",
|
|
132
|
+
}],
|
|
133
|
+
"outtmpl": str(self.output_dir / "%(title)s.%(ext)s"),
|
|
134
|
+
})
|
|
135
|
+
return options
|
|
136
|
+
|
|
137
|
+
def _get_mp4_options(self, quality: str = "best") -> dict[str, Any]:
|
|
138
|
+
"""
|
|
139
|
+
Get yt-dlp options for MP4 download.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
quality: Video quality (e.g., "720p", "1080p", "best")
|
|
143
|
+
"""
|
|
144
|
+
options = self._get_base_options()
|
|
145
|
+
|
|
146
|
+
height = get_quality_height(quality)
|
|
147
|
+
|
|
148
|
+
if height:
|
|
149
|
+
# Request specific quality with fallback
|
|
150
|
+
format_str = (
|
|
151
|
+
f"bestvideo[height<={height}][ext=mp4]+bestaudio[ext=m4a]/best[height<={height}][ext=mp4]/"
|
|
152
|
+
f"bestvideo[height<={height}]+bestaudio/best[height<={height}]/best"
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
# Best quality available
|
|
156
|
+
format_str = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/bestvideo+bestaudio/best"
|
|
157
|
+
|
|
158
|
+
options.update({
|
|
159
|
+
"format": format_str,
|
|
160
|
+
"merge_output_format": "mp4",
|
|
161
|
+
"postprocessors": [{
|
|
162
|
+
"key": "FFmpegVideoConvertor",
|
|
163
|
+
"preferedformat": "mp4",
|
|
164
|
+
}],
|
|
165
|
+
})
|
|
166
|
+
return options
|
|
167
|
+
|
|
168
|
+
def get_video_info(self, url: str) -> dict[str, Any]:
|
|
169
|
+
"""
|
|
170
|
+
Get information about a YouTube video without downloading.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
url: YouTube video URL
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Dictionary containing video metadata
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
InvalidURLError: If the URL is invalid
|
|
180
|
+
DownloadError: If video info cannot be retrieved
|
|
181
|
+
"""
|
|
182
|
+
url = validate_youtube_url(url)
|
|
183
|
+
|
|
184
|
+
options = {
|
|
185
|
+
"quiet": True,
|
|
186
|
+
"no_warnings": True,
|
|
187
|
+
"extract_flat": False,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
with yt_dlp.YoutubeDL(options) as ydl:
|
|
192
|
+
info = ydl.extract_info(url, download=False)
|
|
193
|
+
|
|
194
|
+
if not info:
|
|
195
|
+
raise DownloadError("Failed to retrieve video information")
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"id": info.get("id"),
|
|
199
|
+
"title": info.get("title"),
|
|
200
|
+
"duration": info.get("duration"),
|
|
201
|
+
"description": info.get("description"),
|
|
202
|
+
"thumbnail": info.get("thumbnail"),
|
|
203
|
+
"uploader": info.get("uploader"),
|
|
204
|
+
"view_count": info.get("view_count"),
|
|
205
|
+
"like_count": info.get("like_count"),
|
|
206
|
+
"formats": [
|
|
207
|
+
{
|
|
208
|
+
"format_id": f.get("format_id"),
|
|
209
|
+
"ext": f.get("ext"),
|
|
210
|
+
"resolution": f.get("resolution"),
|
|
211
|
+
"filesize": f.get("filesize"),
|
|
212
|
+
}
|
|
213
|
+
for f in info.get("formats", [])
|
|
214
|
+
],
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
except yt_dlp.utils.DownloadError as e:
|
|
218
|
+
error_msg = str(e)
|
|
219
|
+
if "Video unavailable" in error_msg or "Private video" in error_msg:
|
|
220
|
+
raise DownloadError(f"Video is unavailable or private: {error_msg}")
|
|
221
|
+
raise DownloadError(f"Failed to get video info: {error_msg}")
|
|
222
|
+
except Exception as e:
|
|
223
|
+
raise UnexpectedError(f"Unexpected error getting video info: {e}")
|
|
224
|
+
|
|
225
|
+
def convert_to_mp3(self, url: str) -> Path:
|
|
226
|
+
"""
|
|
227
|
+
Download and convert a YouTube video to MP3.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
url: YouTube video URL
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Path to the converted MP3 file
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
InvalidURLError: If the URL is invalid
|
|
237
|
+
DownloadError: If download fails
|
|
238
|
+
FormatUnavailableError: If MP3 conversion fails
|
|
239
|
+
"""
|
|
240
|
+
url = validate_youtube_url(url)
|
|
241
|
+
|
|
242
|
+
print_info("Starting MP3 conversion...")
|
|
243
|
+
self.logger.info(f"Converting to MP3: {url}")
|
|
244
|
+
|
|
245
|
+
options = self._get_mp3_options()
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
with yt_dlp.YoutubeDL(options) as ydl:
|
|
249
|
+
info = ydl.extract_info(url, download=True)
|
|
250
|
+
|
|
251
|
+
if not info:
|
|
252
|
+
raise DownloadError("Failed to download video")
|
|
253
|
+
|
|
254
|
+
# Determine output filename
|
|
255
|
+
title = sanitize_filename(info.get("title", "video"))
|
|
256
|
+
output_path = self.output_dir / f"{title}.mp3"
|
|
257
|
+
|
|
258
|
+
# yt-dlp might use a different filename, try to find it
|
|
259
|
+
if not output_path.exists():
|
|
260
|
+
# Look for any recently created mp3 file
|
|
261
|
+
mp3_files = list(self.output_dir.glob("*.mp3"))
|
|
262
|
+
if mp3_files:
|
|
263
|
+
# Get the most recently modified mp3 file
|
|
264
|
+
output_path = max(mp3_files, key=lambda p: p.stat().st_mtime)
|
|
265
|
+
|
|
266
|
+
print_success(f"Successfully converted to MP3: {output_path.name}")
|
|
267
|
+
return output_path
|
|
268
|
+
|
|
269
|
+
except yt_dlp.utils.DownloadError as e:
|
|
270
|
+
error_msg = str(e)
|
|
271
|
+
if "format" in error_msg.lower():
|
|
272
|
+
raise FormatUnavailableError(f"Audio format unavailable: {error_msg}")
|
|
273
|
+
raise DownloadError(f"Download failed: {error_msg}")
|
|
274
|
+
except Exception as e:
|
|
275
|
+
if isinstance(e, (InvalidURLError, DownloadError, FormatUnavailableError)):
|
|
276
|
+
raise
|
|
277
|
+
raise UnexpectedError(f"Unexpected error during MP3 conversion: {e}")
|
|
278
|
+
|
|
279
|
+
def convert_to_mp4(self, url: str, quality: str = "best") -> Path:
|
|
280
|
+
"""
|
|
281
|
+
Download a YouTube video as MP4.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
url: YouTube video URL
|
|
285
|
+
quality: Video quality (e.g., "720p", "1080p", "best")
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Path to the downloaded MP4 file
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
InvalidURLError: If the URL is invalid
|
|
292
|
+
DownloadError: If download fails
|
|
293
|
+
FormatUnavailableError: If requested quality is unavailable
|
|
294
|
+
"""
|
|
295
|
+
url = validate_youtube_url(url)
|
|
296
|
+
|
|
297
|
+
print_info(f"Starting MP4 download (quality: {quality})...")
|
|
298
|
+
self.logger.info(f"Converting to MP4 with quality '{quality}': {url}")
|
|
299
|
+
|
|
300
|
+
options = self._get_mp4_options(quality)
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
with yt_dlp.YoutubeDL(options) as ydl:
|
|
304
|
+
info = ydl.extract_info(url, download=True)
|
|
305
|
+
|
|
306
|
+
if not info:
|
|
307
|
+
raise DownloadError("Failed to download video")
|
|
308
|
+
|
|
309
|
+
# Determine output filename
|
|
310
|
+
title = sanitize_filename(info.get("title", "video"))
|
|
311
|
+
output_path = self.output_dir / f"{title}.mp4"
|
|
312
|
+
|
|
313
|
+
# yt-dlp might use a different filename, try to find it
|
|
314
|
+
if not output_path.exists():
|
|
315
|
+
# Look for any recently created mp4 file
|
|
316
|
+
mp4_files = list(self.output_dir.glob("*.mp4"))
|
|
317
|
+
if mp4_files:
|
|
318
|
+
# Get the most recently modified mp4 file
|
|
319
|
+
output_path = max(mp4_files, key=lambda p: p.stat().st_mtime)
|
|
320
|
+
|
|
321
|
+
print_success(f"Successfully downloaded MP4: {output_path.name}")
|
|
322
|
+
return output_path
|
|
323
|
+
|
|
324
|
+
except yt_dlp.utils.DownloadError as e:
|
|
325
|
+
error_msg = str(e)
|
|
326
|
+
if "format" in error_msg.lower() or "quality" in error_msg.lower():
|
|
327
|
+
raise FormatUnavailableError(
|
|
328
|
+
f"Requested quality '{quality}' unavailable: {error_msg}"
|
|
329
|
+
)
|
|
330
|
+
raise DownloadError(f"Download failed: {error_msg}")
|
|
331
|
+
except Exception as e:
|
|
332
|
+
if isinstance(e, (InvalidURLError, DownloadError, FormatUnavailableError)):
|
|
333
|
+
raise
|
|
334
|
+
raise UnexpectedError(f"Unexpected error during MP4 download: {e}")
|
|
335
|
+
|
|
336
|
+
def convert(self, url: str, format_type: str, quality: str = "best") -> Path:
|
|
337
|
+
"""
|
|
338
|
+
Convert a YouTube video to the specified format.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
url: YouTube video URL
|
|
342
|
+
format_type: Output format ("mp3" or "mp4")
|
|
343
|
+
quality: Video quality for MP4 (ignored for MP3)
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Path to the converted file
|
|
347
|
+
"""
|
|
348
|
+
format_type = format_type.lower()
|
|
349
|
+
|
|
350
|
+
if format_type == "mp3":
|
|
351
|
+
return self.convert_to_mp3(url)
|
|
352
|
+
elif format_type == "mp4":
|
|
353
|
+
return self.convert_to_mp4(url, quality)
|
|
354
|
+
else:
|
|
355
|
+
raise ValueError(f"Unsupported format: {format_type}")
|
ytconvert/exceptions.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for ytconvert-cli.
|
|
3
|
+
|
|
4
|
+
Exit codes:
|
|
5
|
+
0 = success
|
|
6
|
+
1 = invalid URL
|
|
7
|
+
2 = download failure
|
|
8
|
+
3 = format/quality unavailable
|
|
9
|
+
4 = unexpected error
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from enum import IntEnum
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExitCode(IntEnum):
|
|
16
|
+
"""Exit codes for the CLI application."""
|
|
17
|
+
|
|
18
|
+
SUCCESS = 0
|
|
19
|
+
INVALID_URL = 1
|
|
20
|
+
DOWNLOAD_FAILURE = 2
|
|
21
|
+
FORMAT_UNAVAILABLE = 3
|
|
22
|
+
UNEXPECTED_ERROR = 4
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class YTConvertError(Exception):
|
|
26
|
+
"""Base exception for ytconvert-cli errors."""
|
|
27
|
+
|
|
28
|
+
exit_code: int = ExitCode.UNEXPECTED_ERROR
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, exit_code: int | None = None):
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
if exit_code is not None:
|
|
33
|
+
self.exit_code = exit_code
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InvalidURLError(YTConvertError):
|
|
37
|
+
"""Raised when the provided URL is not a valid YouTube URL."""
|
|
38
|
+
|
|
39
|
+
exit_code = ExitCode.INVALID_URL
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str = "Invalid YouTube URL provided"):
|
|
42
|
+
super().__init__(message, self.exit_code)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DownloadError(YTConvertError):
|
|
46
|
+
"""Raised when the download process fails."""
|
|
47
|
+
|
|
48
|
+
exit_code = ExitCode.DOWNLOAD_FAILURE
|
|
49
|
+
|
|
50
|
+
def __init__(self, message: str = "Failed to download video"):
|
|
51
|
+
super().__init__(message, self.exit_code)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FormatUnavailableError(YTConvertError):
|
|
55
|
+
"""Raised when the requested format or quality is not available."""
|
|
56
|
+
|
|
57
|
+
exit_code = ExitCode.FORMAT_UNAVAILABLE
|
|
58
|
+
|
|
59
|
+
def __init__(self, message: str = "Requested format or quality unavailable"):
|
|
60
|
+
super().__init__(message, self.exit_code)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UnexpectedError(YTConvertError):
|
|
64
|
+
"""Raised when an unexpected error occurs."""
|
|
65
|
+
|
|
66
|
+
exit_code = ExitCode.UNEXPECTED_ERROR
|
|
67
|
+
|
|
68
|
+
def __init__(self, message: str = "An unexpected error occurred"):
|
|
69
|
+
super().__init__(message, self.exit_code)
|
ytconvert/py.typed
ADDED
|
File without changes
|
ytconvert/utils.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for ytconvert-cli.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
# ANSI color codes for terminal output
|
|
11
|
+
class Colors:
|
|
12
|
+
"""ANSI color codes for terminal styling."""
|
|
13
|
+
|
|
14
|
+
RESET = "\033[0m"
|
|
15
|
+
BOLD = "\033[1m"
|
|
16
|
+
RED = "\033[91m"
|
|
17
|
+
GREEN = "\033[92m"
|
|
18
|
+
YELLOW = "\033[93m"
|
|
19
|
+
BLUE = "\033[94m"
|
|
20
|
+
MAGENTA = "\033[95m"
|
|
21
|
+
CYAN = "\033[96m"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def setup_logging(verbose: bool = False) -> logging.Logger:
|
|
25
|
+
"""
|
|
26
|
+
Set up logging for the CLI application.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
verbose: If True, set log level to DEBUG; otherwise INFO
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Configured logger instance
|
|
33
|
+
"""
|
|
34
|
+
logger = logging.getLogger("ytconvert")
|
|
35
|
+
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
36
|
+
|
|
37
|
+
# Remove existing handlers
|
|
38
|
+
logger.handlers.clear()
|
|
39
|
+
|
|
40
|
+
# Create console handler with formatting
|
|
41
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
42
|
+
handler.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
43
|
+
|
|
44
|
+
formatter = logging.Formatter(
|
|
45
|
+
fmt="%(asctime)s - %(levelname)s - %(message)s",
|
|
46
|
+
datefmt="%Y-%m-%d %H:%M:%S"
|
|
47
|
+
)
|
|
48
|
+
handler.setFormatter(formatter)
|
|
49
|
+
logger.addHandler(handler)
|
|
50
|
+
|
|
51
|
+
return logger
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_logger() -> logging.Logger:
|
|
55
|
+
"""Get the ytconvert logger instance."""
|
|
56
|
+
return logging.getLogger("ytconvert")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def print_success(message: str) -> None:
|
|
60
|
+
"""Print a success message in green."""
|
|
61
|
+
print(f"{Colors.GREEN}✓ {message}{Colors.RESET}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def print_error(message: str) -> None:
|
|
65
|
+
"""Print an error message in red."""
|
|
66
|
+
print(f"{Colors.RED}✗ {message}{Colors.RESET}", file=sys.stderr)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def print_warning(message: str) -> None:
|
|
70
|
+
"""Print a warning message in yellow."""
|
|
71
|
+
print(f"{Colors.YELLOW}⚠ {message}{Colors.RESET}", file=sys.stderr)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def print_info(message: str) -> None:
|
|
75
|
+
"""Print an info message in blue."""
|
|
76
|
+
print(f"{Colors.BLUE}ℹ {message}{Colors.RESET}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def print_progress(message: str) -> None:
|
|
80
|
+
"""Print a progress message in cyan."""
|
|
81
|
+
print(f"{Colors.CYAN}→ {message}{Colors.RESET}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def ensure_directory(path: Path) -> Path:
|
|
85
|
+
"""
|
|
86
|
+
Ensure a directory exists, creating it if necessary.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
path: Path to the directory
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The resolved absolute path to the directory
|
|
93
|
+
"""
|
|
94
|
+
path = Path(path).resolve()
|
|
95
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
return path
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def format_filesize(size_bytes: int | float) -> str:
|
|
100
|
+
"""
|
|
101
|
+
Format a file size in bytes to a human-readable string.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
size_bytes: Size in bytes
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Human-readable size string (e.g., "15.5 MB")
|
|
108
|
+
"""
|
|
109
|
+
if size_bytes < 0:
|
|
110
|
+
return "Unknown"
|
|
111
|
+
|
|
112
|
+
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
113
|
+
if abs(size_bytes) < 1024.0:
|
|
114
|
+
return f"{size_bytes:.1f} {unit}"
|
|
115
|
+
size_bytes /= 1024.0
|
|
116
|
+
|
|
117
|
+
return f"{size_bytes:.1f} PB"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def format_duration(seconds: int | float) -> str:
|
|
121
|
+
"""
|
|
122
|
+
Format a duration in seconds to HH:MM:SS format.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
seconds: Duration in seconds
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Formatted duration string
|
|
129
|
+
"""
|
|
130
|
+
if seconds < 0:
|
|
131
|
+
return "Unknown"
|
|
132
|
+
|
|
133
|
+
hours, remainder = divmod(int(seconds), 3600)
|
|
134
|
+
minutes, secs = divmod(remainder, 60)
|
|
135
|
+
|
|
136
|
+
if hours > 0:
|
|
137
|
+
return f"{hours}:{minutes:02d}:{secs:02d}"
|
|
138
|
+
return f"{minutes}:{secs:02d}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def sanitize_filename(filename: str) -> str:
|
|
142
|
+
"""
|
|
143
|
+
Sanitize a filename by removing invalid characters.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
filename: The filename to sanitize
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Sanitized filename safe for use on most filesystems
|
|
150
|
+
"""
|
|
151
|
+
# Characters not allowed in filenames on Windows
|
|
152
|
+
invalid_chars = '<>:"/\\|?*'
|
|
153
|
+
|
|
154
|
+
for char in invalid_chars:
|
|
155
|
+
filename = filename.replace(char, "_")
|
|
156
|
+
|
|
157
|
+
# Remove leading/trailing spaces and dots
|
|
158
|
+
filename = filename.strip(". ")
|
|
159
|
+
|
|
160
|
+
# Ensure filename is not empty
|
|
161
|
+
if not filename:
|
|
162
|
+
filename = "untitled"
|
|
163
|
+
|
|
164
|
+
return filename
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def get_quality_height(quality: str) -> int | None:
|
|
168
|
+
"""
|
|
169
|
+
Convert a quality string to a height value.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
quality: Quality string (e.g., "720p", "1080p")
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Height in pixels, or None for "best"
|
|
176
|
+
"""
|
|
177
|
+
quality_map = {
|
|
178
|
+
"360p": 360,
|
|
179
|
+
"480p": 480,
|
|
180
|
+
"720p": 720,
|
|
181
|
+
"1080p": 1080,
|
|
182
|
+
"1440p": 1440,
|
|
183
|
+
"2160p": 2160,
|
|
184
|
+
"best": None,
|
|
185
|
+
}
|
|
186
|
+
return quality_map.get(quality.lower())
|
ytconvert/validators.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
URL and input validation utilities for ytconvert-cli.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from urllib.parse import urlparse, parse_qs
|
|
7
|
+
|
|
8
|
+
from ytconvert.exceptions import InvalidURLError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Supported YouTube URL patterns
|
|
12
|
+
YOUTUBE_PATTERNS = [
|
|
13
|
+
# Standard watch URLs
|
|
14
|
+
r"^(https?://)?(www\.)?youtube\.com/watch\?.*v=[\w-]+",
|
|
15
|
+
# Short URLs
|
|
16
|
+
r"^(https?://)?(www\.)?youtu\.be/[\w-]+",
|
|
17
|
+
# Embed URLs
|
|
18
|
+
r"^(https?://)?(www\.)?youtube\.com/embed/[\w-]+",
|
|
19
|
+
# Mobile URLs
|
|
20
|
+
r"^(https?://)?(m\.)?youtube\.com/watch\?.*v=[\w-]+",
|
|
21
|
+
# YouTube Shorts
|
|
22
|
+
r"^(https?://)?(www\.)?youtube\.com/shorts/[\w-]+",
|
|
23
|
+
# YouTube Live
|
|
24
|
+
r"^(https?://)?(www\.)?youtube\.com/live/[\w-]+",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# Valid output formats
|
|
28
|
+
VALID_FORMATS = ["mp3", "mp4"]
|
|
29
|
+
|
|
30
|
+
# Valid quality options for MP4
|
|
31
|
+
VALID_QUALITIES = ["360p", "480p", "720p", "1080p", "1440p", "2160p", "best"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_youtube_url(url: str) -> str:
|
|
35
|
+
"""
|
|
36
|
+
Validate that the provided URL is a valid YouTube URL.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
url: The URL to validate
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
The validated URL (normalized if necessary)
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
InvalidURLError: If the URL is not a valid YouTube URL
|
|
46
|
+
"""
|
|
47
|
+
if not url:
|
|
48
|
+
raise InvalidURLError("URL cannot be empty")
|
|
49
|
+
|
|
50
|
+
url = url.strip()
|
|
51
|
+
|
|
52
|
+
# Check against known YouTube URL patterns
|
|
53
|
+
for pattern in YOUTUBE_PATTERNS:
|
|
54
|
+
if re.match(pattern, url, re.IGNORECASE):
|
|
55
|
+
return url
|
|
56
|
+
|
|
57
|
+
raise InvalidURLError(f"Invalid YouTube URL: {url}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def extract_video_id(url: str) -> str | None:
|
|
61
|
+
"""
|
|
62
|
+
Extract the video ID from a YouTube URL.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
url: A valid YouTube URL
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The video ID if found, None otherwise
|
|
69
|
+
"""
|
|
70
|
+
# Handle youtu.be short URLs
|
|
71
|
+
if "youtu.be" in url:
|
|
72
|
+
parsed = urlparse(url)
|
|
73
|
+
return parsed.path.lstrip("/").split("?")[0]
|
|
74
|
+
|
|
75
|
+
# Handle youtube.com/shorts/ URLs
|
|
76
|
+
if "/shorts/" in url:
|
|
77
|
+
match = re.search(r"/shorts/([\w-]+)", url)
|
|
78
|
+
return match.group(1) if match else None
|
|
79
|
+
|
|
80
|
+
# Handle youtube.com/live/ URLs
|
|
81
|
+
if "/live/" in url:
|
|
82
|
+
match = re.search(r"/live/([\w-]+)", url)
|
|
83
|
+
return match.group(1) if match else None
|
|
84
|
+
|
|
85
|
+
# Handle youtube.com/embed/ URLs
|
|
86
|
+
if "/embed/" in url:
|
|
87
|
+
match = re.search(r"/embed/([\w-]+)", url)
|
|
88
|
+
return match.group(1) if match else None
|
|
89
|
+
|
|
90
|
+
# Handle standard watch URLs
|
|
91
|
+
parsed = urlparse(url)
|
|
92
|
+
query_params = parse_qs(parsed.query)
|
|
93
|
+
video_ids = query_params.get("v", [])
|
|
94
|
+
|
|
95
|
+
return video_ids[0] if video_ids else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def validate_format(format_type: str) -> str:
|
|
99
|
+
"""
|
|
100
|
+
Validate the output format.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
format_type: The format to validate (mp3 or mp4)
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The validated format in lowercase
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
ValueError: If the format is not valid
|
|
110
|
+
"""
|
|
111
|
+
format_lower = format_type.lower()
|
|
112
|
+
if format_lower not in VALID_FORMATS:
|
|
113
|
+
raise ValueError(f"Invalid format '{format_type}'. Must be one of: {', '.join(VALID_FORMATS)}")
|
|
114
|
+
return format_lower
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def validate_quality(quality: str) -> str:
|
|
118
|
+
"""
|
|
119
|
+
Validate the quality option for MP4.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
quality: The quality to validate (e.g., 720p, 1080p)
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The validated quality string
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ValueError: If the quality is not valid
|
|
129
|
+
"""
|
|
130
|
+
quality_lower = quality.lower()
|
|
131
|
+
if quality_lower not in VALID_QUALITIES:
|
|
132
|
+
raise ValueError(f"Invalid quality '{quality}'. Must be one of: {', '.join(VALID_QUALITIES)}")
|
|
133
|
+
return quality_lower
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ytconvert-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A command-line tool to convert YouTube videos to MP3 or MP4 using yt-dlp
|
|
5
|
+
Project-URL: Homepage, https://github.com/yourusername/ytconvert-cli
|
|
6
|
+
Project-URL: Documentation, https://github.com/yourusername/ytconvert-cli#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/yourusername/ytconvert-cli
|
|
8
|
+
Project-URL: Issues, https://github.com/yourusername/ytconvert-cli/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/yourusername/ytconvert-cli/blob/main/CHANGELOG.md
|
|
10
|
+
Author: ytconvert-cli contributors
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: audio,cli,converter,download,mp3,mp4,video,youtube,yt-dlp
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
26
|
+
Classifier: Topic :: Multimedia :: Video
|
|
27
|
+
Classifier: Topic :: Utilities
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Requires-Dist: imageio-ffmpeg>=0.4.9
|
|
30
|
+
Requires-Dist: typer>=0.9.0
|
|
31
|
+
Requires-Dist: typing-extensions>=4.0.0
|
|
32
|
+
Requires-Dist: yt-dlp>=2024.1.0
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: black>=23.0.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
38
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ytconvert-cli
|
|
43
|
+
|
|
44
|
+
**Production-ready YouTube to MP3/MP4 converter.**
|
|
45
|
+
|
|
46
|
+
[](https://pypi.org/project/ytconvert-cli/)
|
|
47
|
+
[](https://www.python.org/downloads/)
|
|
48
|
+
[](https://opensource.org/licenses/MIT)
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 🚀 Features
|
|
53
|
+
|
|
54
|
+
- Convert YouTube videos to **MP3** (audio extraction)
|
|
55
|
+
- Download YouTube videos as **MP4** (with quality selection)
|
|
56
|
+
- Custom output directory support
|
|
57
|
+
- Progress indicators with download speed and ETA
|
|
58
|
+
- Clean error handling with specific exit codes
|
|
59
|
+
- **FFmpeg is bundled**—no separate install needed
|
|
60
|
+
- Uses yt-dlp Python API (no shell calls)
|
|
61
|
+
- Pip-installable and PyPI-ready
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 📦 Installation
|
|
66
|
+
|
|
67
|
+
### From PyPI (recommended)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install ytconvert-cli
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### From source
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
git clone https://github.com/yourusername/ytconvert-cli.git
|
|
77
|
+
cd ytconvert-cli
|
|
78
|
+
pip install .
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 🖥️ CLI Usage
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# Convert to MP3 (default)
|
|
87
|
+
ytconvert https://www.youtube.com/watch?v=VIDEO_ID
|
|
88
|
+
|
|
89
|
+
# Convert to MP4
|
|
90
|
+
ytconvert https://www.youtube.com/watch?v=VIDEO_ID --format mp4
|
|
91
|
+
|
|
92
|
+
# MP4 with specific quality
|
|
93
|
+
ytconvert https://www.youtube.com/watch?v=VIDEO_ID --format mp4 --quality 720p
|
|
94
|
+
|
|
95
|
+
# Save to custom directory
|
|
96
|
+
ytconvert https://www.youtube.com/watch?v=VIDEO_ID -f mp3 -o ./downloads
|
|
97
|
+
|
|
98
|
+
# Get video info only (no download)
|
|
99
|
+
ytconvert https://www.youtube.com/watch?v=VIDEO_ID --info
|
|
100
|
+
|
|
101
|
+
# Show help
|
|
102
|
+
ytconvert --help
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Short options:**
|
|
106
|
+
| Long | Short | Description |
|
|
107
|
+
|-----------|-------|---------------------|
|
|
108
|
+
| --format | -f | mp3 or mp4 |
|
|
109
|
+
| --quality | -q | Video quality |
|
|
110
|
+
| --output | -o | Output directory |
|
|
111
|
+
| --info | -i | Info only |
|
|
112
|
+
| --verbose | -v | Debug mode |
|
|
113
|
+
| --version | -V | Show version |
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## 🐍 Python API Usage
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from ytconvert import YouTubeConverter
|
|
121
|
+
|
|
122
|
+
# Convert to MP3
|
|
123
|
+
converter = YouTubeConverter(output_dir="./downloads")
|
|
124
|
+
mp3_path = converter.convert_to_mp3("https://youtube.com/watch?v=VIDEO_ID")
|
|
125
|
+
print(f"Saved: {mp3_path}")
|
|
126
|
+
|
|
127
|
+
# Convert to MP4 with quality
|
|
128
|
+
mp4_path = converter.convert_to_mp4("https://youtube.com/watch?v=VIDEO_ID", quality="720p")
|
|
129
|
+
|
|
130
|
+
# Get video info only
|
|
131
|
+
info = converter.get_video_info("https://youtube.com/watch?v=VIDEO_ID")
|
|
132
|
+
print(f"Title: {info['title']}")
|
|
133
|
+
print(f"Duration: {info['duration']} seconds")
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## 🛠️ Integration Example (Django, subprocess)
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
import subprocess
|
|
142
|
+
import sys
|
|
143
|
+
|
|
144
|
+
def convert_youtube_video(url: str, format: str = "mp3", quality: str = "best"):
|
|
145
|
+
cmd = [
|
|
146
|
+
sys.executable, "-m", "ytconvert.cli",
|
|
147
|
+
url,
|
|
148
|
+
"--format", format,
|
|
149
|
+
"--output", "/path/to/downloads",
|
|
150
|
+
]
|
|
151
|
+
if format == "mp4" and quality != "best":
|
|
152
|
+
cmd.extend(["--quality", quality])
|
|
153
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
154
|
+
if result.returncode == 0:
|
|
155
|
+
return {"success": True, "output": result.stdout}
|
|
156
|
+
else:
|
|
157
|
+
return {"success": False, "exit_code": result.returncode, "error": result.stderr}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## ⚠️ Exit Codes
|
|
163
|
+
|
|
164
|
+
| Code | Meaning |
|
|
165
|
+
|------|-------------------------------|
|
|
166
|
+
| 0 | Success |
|
|
167
|
+
| 1 | Invalid YouTube URL |
|
|
168
|
+
| 2 | Download failure |
|
|
169
|
+
| 3 | Format or quality unavailable |
|
|
170
|
+
| 4 | Unexpected error |
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## 🏗️ Project Structure
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
ytconvert-cli/
|
|
178
|
+
├── ytconvert/
|
|
179
|
+
│ ├── __init__.py # Package exports
|
|
180
|
+
│ ├── cli.py # CLI entry point
|
|
181
|
+
│ ├── converter.py # yt-dlp logic
|
|
182
|
+
│ ├── validators.py # URL/format validation
|
|
183
|
+
│ ├── exceptions.py # Custom exceptions
|
|
184
|
+
│ └── utils.py # Utilities
|
|
185
|
+
├── pyproject.toml # Packaging config
|
|
186
|
+
├── README.md # This file
|
|
187
|
+
└── LICENSE # MIT License
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## 📝 Development
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
# Run tests
|
|
196
|
+
pytest
|
|
197
|
+
|
|
198
|
+
# Format code
|
|
199
|
+
black ytconvert/
|
|
200
|
+
ruff check ytconvert/ --fix
|
|
201
|
+
|
|
202
|
+
# Type check
|
|
203
|
+
mypy ytconvert/
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## 📄 License
|
|
209
|
+
|
|
210
|
+
MIT License - see [LICENSE](LICENSE)
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 🙏 Acknowledgments
|
|
215
|
+
|
|
216
|
+
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - Video downloader
|
|
217
|
+
- [Typer](https://typer.tiangolo.com/) - CLI framework
|
|
218
|
+
- [FFmpeg](https://ffmpeg.org/) - Media backend (bundled)
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## 📢 Disclaimer
|
|
223
|
+
|
|
224
|
+
This tool is intended for downloading videos you have the right to download. Please respect copyright laws and YouTube's Terms of Service. The authors are not responsible for misuse.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
ytconvert/__init__.py,sha256=eQWk1xhGIuarupBZTBHg2vT0S0PXOSttaMql4Xl5GNU,563
|
|
2
|
+
ytconvert/cli.py,sha256=S6tQRHqrYmvtAd2-xFpGSTe7b0SPnyg4ksn2Svqhg9s,4547
|
|
3
|
+
ytconvert/converter.py,sha256=Nqe8b3CWI0EFvdBzAoaNCc1s8IDDIGkAdJTJO6uPszc,13374
|
|
4
|
+
ytconvert/exceptions.py,sha256=81ZNF8UybmgVZO4DgW4mi4HiQMnm5Nr90RBztOswvXk,1887
|
|
5
|
+
ytconvert/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
ytconvert/utils.py,sha256=1TnYOiEw60jSvmh2cmDdasOoF3mpzu5WRBgQDmHQU_o,4710
|
|
7
|
+
ytconvert/validators.py,sha256=sHlYfIyWpZfaChVt5NQjU9DjfqDIUMBe6RNZ1urZytE,3711
|
|
8
|
+
ytconvert_cli-1.0.0.dist-info/METADATA,sha256=bdg3VGY4QLvJCDbFRM-SMqZrTrEIdJ9v8VfgDRX_d1I,6379
|
|
9
|
+
ytconvert_cli-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
ytconvert_cli-1.0.0.dist-info/entry_points.txt,sha256=Pu10ig5rBHQe_WWHd--nB1e4euLEvuDgCctBsLsh9MU,48
|
|
11
|
+
ytconvert_cli-1.0.0.dist-info/licenses/LICENSE,sha256=_4kcUQSSk-8V8RdYzr4Qym1voMibl4y-cHP1XqeMeHQ,1104
|
|
12
|
+
ytconvert_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ytconvert-cli contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|