mmcli-dl 0.1.0__tar.gz

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.
mmcli_dl-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rizki Rakasiwi
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.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: mmcli-dl
3
+ Version: 0.1.0
4
+ Summary: A command-line YouTube downloader with built-in audio/video format conversion
5
+ Author: rizkirakasiwi
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/rizukirr/mmcli
8
+ Project-URL: Repository, https://github.com/rizukirr/mmcli
9
+ Project-URL: Issues, https://github.com/rizukirr/mmcli/issues
10
+ Keywords: youtube,downloader,cli,ffmpeg,video,audio,playlist
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Multimedia :: Sound/Audio
18
+ Classifier: Topic :: Multimedia :: Video
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.12
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: ffmpeg-python>=0.2.0
24
+ Requires-Dist: pytubefix>=10.10.1
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=9.0; extra == "test"
27
+ Requires-Dist: pytest-cov>=7.0; extra == "test"
28
+ Requires-Dist: pytest-asyncio>=1.0; extra == "test"
29
+ Dynamic: license-file
30
+
31
+ # mmcli
32
+
33
+ mmcli is a command-line YouTube downloader with built-in format conversion. Give
34
+ it a video or playlist URL and it downloads the media, optionally converting it
35
+ to the audio or video format you choose. Playlists are detected automatically and
36
+ saved into a per-playlist folder. Requires Python 3.12+ and FFmpeg on your `PATH`.
37
+
38
+ ## Install
39
+
40
+ ```
41
+ pip install mmcli-dl # or: pipx install mmcli-dl / uv tool install mmcli-dl
42
+ ```
43
+
44
+ The installed command is `mmcli`. FFmpeg must be on your `PATH` for format conversion.
45
+
46
+ ## Command
47
+
48
+ ```
49
+ mmcli <url> [--resolution RES] [--format FMT] [--output-dir DIR]
50
+ ```
51
+
52
+ | Argument | Short | Description |
53
+ |----------|-------|-------------|
54
+ | `url` | | YouTube video or playlist URL (required). |
55
+ | `--resolution` | `-r` | Video resolution, e.g. `720` or `720p`. Ignored for audio formats. Default: highest available. |
56
+ | `--format` | `-f` | Output format. An audio format (`mp3`, `m4a`, `wav`, ...) downloads audio only; a video format (`mp4`, `mkv`, `webm`, ...) converts the container. Default: keep the downloaded video as-is. |
57
+ | `--output-dir` | `-o` | Directory to save into. Default: current directory. |
58
+ | `--version` | `-v` | Print the version and exit. |
59
+
60
+ Examples:
61
+
62
+ ```
63
+ mmcli "https://youtube.com/watch?v=..." # best-quality video
64
+ mmcli "https://youtube.com/watch?v=..." --resolution 720 # video at 720p
65
+ mmcli "https://youtube.com/watch?v=..." --format mp3 # audio only, as mp3
66
+ mmcli "https://youtube.com/watch?v=..." --format mkv # video, converted to mkv
67
+ mmcli "https://youtube.com/playlist?list=..." --format mp3 # whole playlist as mp3
68
+ mmcli "https://youtube.com/watch?v=..." --output-dir ~/Videos # choose the output directory
69
+ ```
70
+
71
+ ## Supported `--format` values
72
+
73
+ An **audio** format downloads audio only; a **video** format downloads video and
74
+ converts the container.
75
+
76
+ - **Audio:** `mp3`, `wav`, `flac`, `aac`, `m4a`, `ogg`, `oga`, `opus`, `wma`, `alac`, `amr`, `ac3`, `dts`, `eac3`
77
+ - **Video:** `mp4`, `mkv`, `avi`, `mov`, `flv`, `webm`, `mpeg`, `mpg`, `ts`, `m2ts`, `ogv`, `3gp`, `3g2`, `vob`, `f4v`, `wmv`, `rm`, `rmvb`
@@ -0,0 +1,47 @@
1
+ # mmcli
2
+
3
+ mmcli is a command-line YouTube downloader with built-in format conversion. Give
4
+ it a video or playlist URL and it downloads the media, optionally converting it
5
+ to the audio or video format you choose. Playlists are detected automatically and
6
+ saved into a per-playlist folder. Requires Python 3.12+ and FFmpeg on your `PATH`.
7
+
8
+ ## Install
9
+
10
+ ```
11
+ pip install mmcli-dl # or: pipx install mmcli-dl / uv tool install mmcli-dl
12
+ ```
13
+
14
+ The installed command is `mmcli`. FFmpeg must be on your `PATH` for format conversion.
15
+
16
+ ## Command
17
+
18
+ ```
19
+ mmcli <url> [--resolution RES] [--format FMT] [--output-dir DIR]
20
+ ```
21
+
22
+ | Argument | Short | Description |
23
+ |----------|-------|-------------|
24
+ | `url` | | YouTube video or playlist URL (required). |
25
+ | `--resolution` | `-r` | Video resolution, e.g. `720` or `720p`. Ignored for audio formats. Default: highest available. |
26
+ | `--format` | `-f` | Output format. An audio format (`mp3`, `m4a`, `wav`, ...) downloads audio only; a video format (`mp4`, `mkv`, `webm`, ...) converts the container. Default: keep the downloaded video as-is. |
27
+ | `--output-dir` | `-o` | Directory to save into. Default: current directory. |
28
+ | `--version` | `-v` | Print the version and exit. |
29
+
30
+ Examples:
31
+
32
+ ```
33
+ mmcli "https://youtube.com/watch?v=..." # best-quality video
34
+ mmcli "https://youtube.com/watch?v=..." --resolution 720 # video at 720p
35
+ mmcli "https://youtube.com/watch?v=..." --format mp3 # audio only, as mp3
36
+ mmcli "https://youtube.com/watch?v=..." --format mkv # video, converted to mkv
37
+ mmcli "https://youtube.com/playlist?list=..." --format mp3 # whole playlist as mp3
38
+ mmcli "https://youtube.com/watch?v=..." --output-dir ~/Videos # choose the output directory
39
+ ```
40
+
41
+ ## Supported `--format` values
42
+
43
+ An **audio** format downloads audio only; a **video** format downloads video and
44
+ converts the container.
45
+
46
+ - **Audio:** `mp3`, `wav`, `flac`, `aac`, `m4a`, `ogg`, `oga`, `opus`, `wma`, `alac`, `amr`, `ac3`, `dts`, `eac3`
47
+ - **Video:** `mp4`, `mkv`, `avi`, `mov`, `flv`, `webm`, `mpeg`, `mpg`, `ts`, `m2ts`, `ogv`, `3gp`, `3g2`, `vob`, `f4v`, `wmv`, `rm`, `rmvb`
@@ -0,0 +1,3 @@
1
+ from .tools.media_downloader import download
2
+ from .utils.command_manager import command_manager
3
+ from .utils.media_format import all_formats, audio_formats, video_formats
@@ -0,0 +1,18 @@
1
+ import asyncio
2
+ from app import download, command_manager
3
+
4
+
5
+ async def _async_main():
6
+ """Parse arguments and run the download."""
7
+ args = command_manager()
8
+ await download(args)
9
+
10
+
11
+ def main():
12
+ """CLI entry point: run the async download pipeline."""
13
+ try:
14
+ asyncio.run(_async_main())
15
+ except KeyboardInterrupt:
16
+ print("\nOperation cancelled by user")
17
+ except Exception as e:
18
+ print(f"Error: {e}")
File without changes
@@ -0,0 +1,233 @@
1
+ import asyncio
2
+ import ffmpeg
3
+ import os
4
+ import shutil
5
+ import textwrap
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+ from typing import Optional, List, Dict, Any
9
+ from functools import reduce
10
+ from ..utils.media_format import all_formats
11
+
12
+
13
+ def ensure_output_directory(output_dir: Optional[str]) -> Path:
14
+ """Create output directory if needed and return Path object."""
15
+ path = Path(output_dir) if output_dir else Path(os.getcwd()) / "convert"
16
+ path.mkdir(parents=True, exist_ok=True)
17
+ return path
18
+
19
+
20
+ def generate_output_filename(input_file: Path, output_format: str) -> str:
21
+ """Generate unique output filename with timestamp."""
22
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
23
+ return f"{input_file.stem}_{timestamp}.{output_format}"
24
+
25
+
26
+ def create_output_path(input_file: Path, output_format: str, output_dir: Path) -> Path:
27
+ """Create full output file path."""
28
+ output_filename = generate_output_filename(input_file, output_format)
29
+ return output_dir / output_filename
30
+
31
+
32
+ def find_ffmpeg_format(output_format: str) -> Optional[str]:
33
+ """Find matching ffmpeg format from format alias."""
34
+ format_matches = list(
35
+ filter(lambda fmt: fmt["alias"] == output_format, all_formats)
36
+ )
37
+ return format_matches[0]["format"] if format_matches else None
38
+
39
+
40
+ def create_conversion_config(
41
+ input_file: Path, output_format: str, output_dir: Path
42
+ ) -> Dict[str, Any]:
43
+ """Create conversion configuration object."""
44
+ ffmpeg_format = find_ffmpeg_format(output_format)
45
+ output_path = create_output_path(input_file, output_format, output_dir)
46
+
47
+ return {
48
+ "input_file": input_file,
49
+ "output_path": output_path,
50
+ "ffmpeg_format": ffmpeg_format,
51
+ "output_format": output_format,
52
+ }
53
+
54
+
55
+ async def execute_ffmpeg_conversion(config: Dict[str, Any]) -> bool:
56
+ """Execute ffmpeg conversion with given configuration asynchronously."""
57
+ if not config["ffmpeg_format"]:
58
+ print(f"Unsupported format: {config['output_format']}")
59
+ return False
60
+
61
+ def _convert():
62
+ try:
63
+ ffmpeg.input(str(config["input_file"])).output(
64
+ str(config["output_path"]), format=config["ffmpeg_format"]
65
+ ).run(quiet=True, overwrite_output=True)
66
+ return True
67
+ except Exception as e:
68
+ print(f"Error converting {config['input_file']}: {e}")
69
+ return False
70
+
71
+ loop = asyncio.get_event_loop()
72
+ return await loop.run_in_executor(None, _convert)
73
+
74
+
75
+ async def convert_single_file_functional(
76
+ input_file: Path, output_format: str, output_dir: Path
77
+ ) -> Dict[str, Any]:
78
+ """Convert single file using functional approach with detailed result."""
79
+ config = create_conversion_config(input_file, output_format, output_dir)
80
+ success = await execute_ffmpeg_conversion(config)
81
+
82
+ return {
83
+ "input_file": str(input_file),
84
+ "output_file": str(config["output_path"]) if success else None,
85
+ "success": success,
86
+ "format": output_format,
87
+ }
88
+
89
+
90
+ async def process_conversion_batch(
91
+ input_files: List[Path],
92
+ output_format: str,
93
+ output_dir: Path,
94
+ max_concurrent: int = 1,
95
+ ) -> List[Dict[str, Any]]:
96
+ """Process batch conversion using async concurrency control."""
97
+
98
+ async def convert_with_feedback(input_file: Path) -> Dict[str, Any]:
99
+ try:
100
+ result = await convert_single_file_functional(
101
+ input_file, output_format, output_dir
102
+ )
103
+ if result["success"]:
104
+ print(f"[OK] Converted {input_file.name}")
105
+ else:
106
+ print(f"[FAIL] Failed to convert {input_file.name}")
107
+ return result
108
+ except Exception as e:
109
+ print(f"[ERROR] Error converting {input_file.name}: {e}")
110
+ return {
111
+ "input_file": str(input_file),
112
+ "output_file": None,
113
+ "success": False,
114
+ "format": output_format,
115
+ "error": str(e),
116
+ }
117
+
118
+ if len(input_files) == 1 or max_concurrent <= 1:
119
+ # Sequential conversion for single files or when max_concurrent is 1
120
+ print(f"Converting {len(input_files)} file(s) to {output_format}...")
121
+ results = []
122
+ for input_file in input_files:
123
+ result = await convert_with_feedback(input_file)
124
+ results.append(result)
125
+ return results
126
+ else:
127
+ # Concurrent conversion for multiple files
128
+ print(
129
+ f"Converting {len(input_files)} file(s) to {output_format} using max {max_concurrent} concurrent conversions..."
130
+ )
131
+
132
+ # Use semaphore to limit concurrent conversions
133
+ semaphore = asyncio.Semaphore(max_concurrent)
134
+
135
+ async def convert_with_semaphore(input_file: Path):
136
+ async with semaphore:
137
+ return await convert_with_feedback(input_file)
138
+
139
+ tasks = [convert_with_semaphore(input_file) for input_file in input_files]
140
+ results = await asyncio.gather(*tasks, return_exceptions=True)
141
+
142
+ # Handle exceptions in results
143
+ processed_results = []
144
+ for i, result in enumerate(results):
145
+ if isinstance(result, Exception):
146
+ processed_results.append(
147
+ {
148
+ "input_file": str(input_files[i]),
149
+ "output_file": None,
150
+ "success": False,
151
+ "format": output_format,
152
+ "error": str(result),
153
+ }
154
+ )
155
+ else:
156
+ processed_results.append(result)
157
+
158
+ return processed_results
159
+
160
+
161
+ def calculate_conversion_stats(results: List[Dict[str, Any]]) -> Dict[str, int]:
162
+ """Calculate conversion statistics from results."""
163
+ return reduce(
164
+ lambda acc, result: {
165
+ "total": acc["total"] + 1,
166
+ "success": acc["success"] + (1 if result["success"] else 0),
167
+ "failed": acc["failed"] + (0 if result["success"] else 1),
168
+ },
169
+ results,
170
+ {"total": 0, "success": 0, "failed": 0},
171
+ )
172
+
173
+
174
+ def format_conversion_summary(
175
+ stats: Dict[str, int], output_format: str, output_dir: str
176
+ ) -> str:
177
+ """Format conversion summary message."""
178
+ return textwrap.dedent(f"""
179
+ Summary:
180
+ Successfully converted: {stats["success"]}
181
+ Failed to convert: {stats["failed"]}
182
+ Format: {output_format}
183
+ Output directory: {output_dir}
184
+ """).strip()
185
+
186
+
187
+ def print_conversion_results(
188
+ results: List[Dict[str, Any]], output_format: str, output_dir: str
189
+ ) -> None:
190
+ """Print detailed conversion results and summary."""
191
+ stats = calculate_conversion_stats(results)
192
+
193
+ print("Conversion complete.")
194
+ print(format_conversion_summary(stats, output_format, output_dir))
195
+
196
+ if stats["failed"] > 0:
197
+ print("\nFailed conversions:")
198
+ failed_files = [r["input_file"] for r in results if not r["success"]]
199
+ for file_path in failed_files:
200
+ print(f" - {file_path}")
201
+
202
+
203
+ async def convert_files_functional(
204
+ input_files: List[Path],
205
+ output_format: str,
206
+ output_dir: Optional[str] = None,
207
+ max_workers: int = 1,
208
+ ) -> List[Dict[str, Any]]:
209
+ """Convert batch of files using async concurrency."""
210
+ if shutil.which("ffmpeg") is None:
211
+ print(
212
+ "FFmpeg not found on PATH. Install FFmpeg and try again: "
213
+ "https://ffmpeg.org/download.html"
214
+ )
215
+ return [
216
+ {
217
+ "input_file": str(input_file),
218
+ "output_file": None,
219
+ "success": False,
220
+ "format": output_format,
221
+ "error": "ffmpeg not found",
222
+ }
223
+ for input_file in input_files
224
+ ]
225
+
226
+ resolved_output_dir = ensure_output_directory(output_dir)
227
+
228
+ results = await process_conversion_batch(
229
+ input_files, output_format, resolved_output_dir, max_workers
230
+ )
231
+ print_conversion_results(results, output_format, str(resolved_output_dir))
232
+
233
+ return results
@@ -0,0 +1,233 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Optional, Dict, Any, List
5
+ from functools import reduce
6
+ from ..utils.media_format import is_audio_format
7
+ from ..utils.constants import PLAYLIST_MAX_CONCURRENT
8
+ from . import media_converter
9
+ from . import youtube_downloader
10
+
11
+
12
+ def resolve_output_dir(args) -> str:
13
+ """Resolve the base output directory, defaulting to the current directory."""
14
+ target = getattr(args, "output_dir", None)
15
+ return os.path.abspath(target) if target else os.getcwd()
16
+
17
+
18
+ def ensure_directory_exists(path: str) -> str:
19
+ """Create the directory if missing and return it."""
20
+ os.makedirs(path, exist_ok=True)
21
+ return path
22
+
23
+
24
+ def normalize_resolution(resolution: Optional[str]) -> Optional[str]:
25
+ """Normalize '720' -> '720p' for pytubefix; pass through None and '720p'."""
26
+ if not resolution:
27
+ return None
28
+ return resolution if resolution.endswith("p") else f"{resolution}p"
29
+
30
+
31
+ def extract_file_extension(filepath: str) -> str:
32
+ """Return the lowercased extension without the leading dot."""
33
+ return os.path.splitext(filepath)[1][1:].lower()
34
+
35
+
36
+ def should_convert(current_ext: str, target_format: Optional[str]) -> bool:
37
+ """True when a non-empty target format differs from the current extension."""
38
+ if not target_format:
39
+ return False
40
+ return current_ext.lower() != target_format.lower()
41
+
42
+
43
+ async def convert_downloaded_file(downloaded_file: str, target_format: str) -> str:
44
+ """Convert one downloaded file in place; remove the original on success."""
45
+ results = await media_converter.convert_files_functional(
46
+ [Path(downloaded_file)],
47
+ target_format,
48
+ output_dir=os.path.dirname(downloaded_file),
49
+ )
50
+ if results and results[0]["success"]:
51
+ try:
52
+ os.remove(downloaded_file)
53
+ except OSError:
54
+ pass
55
+ return results[0]["output_file"]
56
+ print(f"Failed to convert {downloaded_file}")
57
+ return downloaded_file
58
+
59
+
60
+ def sanitize_subfolder(name: str) -> str:
61
+ """Reduce a playlist title to a safe single path segment."""
62
+ # Drop path separators and surrounding whitespace so a title can never
63
+ # escape base_dir or create nested folders; fall back to 'playlist' when
64
+ # nothing meaningful survives (empty, or only separators/dots).
65
+ cleaned = name.replace("/", "_").replace("\\", "_").strip().strip(".")
66
+ if not cleaned or set(cleaned) <= {"_"}:
67
+ return "playlist"
68
+ return cleaned
69
+
70
+
71
+ def resolve_playlist_output(base_dir: str, url: str) -> str:
72
+ """Return <base_dir>/<playlist-title>/, falling back to 'playlist'."""
73
+ try:
74
+ playlist = youtube_downloader.create_playlist_instance(url)
75
+ title = playlist.title
76
+ except Exception:
77
+ title = "playlist"
78
+ return ensure_directory_exists(os.path.join(base_dir, sanitize_subfolder(title)))
79
+
80
+
81
+ def _finalize_single(result: Dict[str, Any], converted_path: str) -> Dict[str, Any]:
82
+ """Build the user-facing result dict for a single download."""
83
+ return {
84
+ "success": True,
85
+ "title": result["metadata"]["title"],
86
+ "file_path": converted_path,
87
+ "format": extract_file_extension(converted_path),
88
+ }
89
+
90
+
91
+ def _failed(result: Dict[str, Any]) -> Dict[str, Any]:
92
+ """Build a failure result dict from a download result."""
93
+ return {
94
+ "success": False,
95
+ "title": result["metadata"].get("title", "Unknown"),
96
+ "error": result["metadata"].get("error", "Download failed"),
97
+ }
98
+
99
+
100
+ async def _download_single(
101
+ url: str, output_path: str, audio_only: bool, resolution, target_format
102
+ ) -> Dict[str, Any]:
103
+ if audio_only:
104
+ result = await youtube_downloader.download_single_audio(url, output_path)
105
+ else:
106
+ result = await youtube_downloader.download_single_video(
107
+ url, output_path, resolution
108
+ )
109
+
110
+ if not result["success"]:
111
+ return _failed(result)
112
+
113
+ file_path = result["file_path"]
114
+ if target_format and should_convert(
115
+ extract_file_extension(file_path), target_format
116
+ ):
117
+ file_path = await convert_downloaded_file(file_path, target_format)
118
+ return _finalize_single(result, file_path)
119
+
120
+
121
+ async def _download_playlist(
122
+ url: str, output_path: str, audio_only: bool, resolution, target_format
123
+ ) -> List[Dict[str, Any]]:
124
+ if audio_only:
125
+ results = await youtube_downloader.download_playlist_audios(
126
+ url, output_path, max_concurrent=PLAYLIST_MAX_CONCURRENT
127
+ )
128
+ else:
129
+ results = await youtube_downloader.download_playlist_videos(
130
+ url, output_path, resolution, max_concurrent=PLAYLIST_MAX_CONCURRENT
131
+ )
132
+ return await _finalize_playlist(results, target_format)
133
+
134
+
135
+ async def _finalize_playlist(
136
+ results: List[Dict[str, Any]], target_format: Optional[str]
137
+ ) -> List[Dict[str, Any]]:
138
+ downloaded = [r["file_path"] for r in results if r["success"] and r["file_path"]]
139
+ conversion_map: Dict[str, Dict[str, Any]] = {}
140
+
141
+ if target_format:
142
+ to_convert = [
143
+ f
144
+ for f in downloaded
145
+ if should_convert(extract_file_extension(f), target_format)
146
+ ]
147
+ if to_convert:
148
+ conv_results = await media_converter.convert_files_functional(
149
+ [Path(f) for f in to_convert],
150
+ target_format,
151
+ output_dir=os.path.dirname(to_convert[0]),
152
+ max_workers=PLAYLIST_MAX_CONCURRENT,
153
+ )
154
+ for i, cr in enumerate(conv_results):
155
+ if cr["success"]:
156
+ try:
157
+ os.remove(to_convert[i])
158
+ except OSError:
159
+ pass
160
+ # Key on the original download path, not cr["input_file"], so the
161
+ # lookup below matches regardless of Path round-trip normalization.
162
+ conversion_map[to_convert[i]] = cr
163
+
164
+ processed = []
165
+ for r in results:
166
+ if not r["success"]:
167
+ processed.append(_failed(r))
168
+ continue
169
+ file_path = r["file_path"]
170
+ cr = conversion_map.get(file_path)
171
+ final_path = cr["output_file"] if cr and cr["success"] else file_path
172
+ processed.append(_finalize_single(r, final_path))
173
+ return processed
174
+
175
+
176
+ def calculate_success_stats(results: List[Dict[str, Any]]) -> Dict[str, int]:
177
+ """Tally total/success/failed across a list of result dicts."""
178
+ return reduce(
179
+ lambda acc, r: {
180
+ "total": acc["total"] + 1,
181
+ "success": acc["success"] + (1 if r["success"] else 0),
182
+ "failed": acc["failed"] + (0 if r["success"] else 1),
183
+ },
184
+ results,
185
+ {"total": 0, "success": 0, "failed": 0},
186
+ )
187
+
188
+
189
+ def print_summary(results) -> None:
190
+ """Print a human-readable summary for single or playlist results."""
191
+ if isinstance(results, list):
192
+ stats = calculate_success_stats(results)
193
+ print("Download Summary:")
194
+ print(f"- Total items: {stats['total']}")
195
+ print(f"- Successfully downloaded: {stats['success']}")
196
+ print(f"- Failed: {stats['failed']}")
197
+ elif results.get("success"):
198
+ print(f"Done: {results.get('title', '')}")
199
+ else:
200
+ print(f"Failed: {results.get('error', 'Download failed')}")
201
+
202
+
203
+ async def download(args):
204
+ """Download a YouTube URL (single or playlist), converting to --format if given."""
205
+ url = args.url
206
+ validation = youtube_downloader.validate_youtube_url(url)
207
+ if not validation["is_valid"]:
208
+ print(f"Error: Unsupported URL: {url}. Only YouTube URLs are supported.")
209
+ sys.exit(1)
210
+
211
+ target_format = getattr(args, "format", None)
212
+ audio_only = target_format is not None and is_audio_format(target_format)
213
+ resolution = normalize_resolution(getattr(args, "resolution", None))
214
+ base_dir = resolve_output_dir(args)
215
+
216
+ try:
217
+ if validation["is_playlist"]:
218
+ output_path = resolve_playlist_output(base_dir, url)
219
+ results = await _download_playlist(
220
+ url, output_path, audio_only, resolution, target_format
221
+ )
222
+ else:
223
+ output_path = ensure_directory_exists(base_dir)
224
+ results = await _download_single(
225
+ url, output_path, audio_only, resolution, target_format
226
+ )
227
+ print_summary(results)
228
+ return results
229
+ except SystemExit:
230
+ raise
231
+ except Exception as e:
232
+ print(f"Error: {e}")
233
+ sys.exit(1)