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 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}")
@@ -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())
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/ytconvert-cli.svg)](https://pypi.org/project/ytconvert-cli/)
47
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
48
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ytconvert = ytconvert.cli:cli
@@ -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.