mmcli-dl 0.1.0b1__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.
- app/__init__.py +3 -0
- app/cli.py +18 -0
- app/tools/__init__.py +0 -0
- app/tools/media_converter.py +233 -0
- app/tools/media_downloader.py +233 -0
- app/tools/youtube_downloader.py +227 -0
- app/utils/__init__.py +0 -0
- app/utils/command_manager.py +51 -0
- app/utils/constants.py +6 -0
- app/utils/media_format.py +55 -0
- mmcli_dl-0.1.0b1.dist-info/METADATA +77 -0
- mmcli_dl-0.1.0b1.dist-info/RECORD +16 -0
- mmcli_dl-0.1.0b1.dist-info/WHEEL +5 -0
- mmcli_dl-0.1.0b1.dist-info/entry_points.txt +2 -0
- mmcli_dl-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- mmcli_dl-0.1.0b1.dist-info/top_level.txt +1 -0
app/__init__.py
ADDED
app/cli.py
ADDED
|
@@ -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}")
|
app/tools/__init__.py
ADDED
|
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)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Optional, Dict, Any, Callable, List
|
|
3
|
+
from pytubefix import YouTube, Playlist
|
|
4
|
+
from pytubefix.cli import on_progress
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def create_youtube_instance(
|
|
8
|
+
url: str, progress_callback: Callable = on_progress
|
|
9
|
+
) -> YouTube:
|
|
10
|
+
"""Create YouTube instance with progress callback."""
|
|
11
|
+
return YouTube(url, on_progress_callback=progress_callback)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def create_playlist_instance(url: str) -> Playlist:
|
|
15
|
+
"""Create YouTube playlist instance."""
|
|
16
|
+
return Playlist(url)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def select_video_stream(yt: YouTube, resolution: Optional[str]):
|
|
20
|
+
"""Select video stream based on resolution preference."""
|
|
21
|
+
if resolution is not None:
|
|
22
|
+
return yt.streams.get_by_resolution(resolution)
|
|
23
|
+
return yt.streams.get_highest_resolution()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def select_audio_stream(yt: YouTube):
|
|
27
|
+
"""Select best available audio stream."""
|
|
28
|
+
return yt.streams.get_audio_only()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def download_stream(stream, output_path: str) -> Optional[str]:
|
|
32
|
+
"""Download stream to specified output path."""
|
|
33
|
+
return stream.download(output_path=output_path)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_video_metadata(yt: YouTube) -> Dict[str, Any]:
|
|
37
|
+
"""Extract video metadata."""
|
|
38
|
+
return {
|
|
39
|
+
"title": yt.title,
|
|
40
|
+
"length": yt.length,
|
|
41
|
+
"views": yt.views,
|
|
42
|
+
"author": yt.author,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_playlist_metadata(playlist: Playlist) -> Dict[str, Any]:
|
|
47
|
+
"""Extract playlist metadata."""
|
|
48
|
+
return {
|
|
49
|
+
"title": playlist.title,
|
|
50
|
+
"video_count": len(list(playlist.videos)),
|
|
51
|
+
"owner": playlist.owner,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def download_single_video(
|
|
56
|
+
url: str,
|
|
57
|
+
output_path: str,
|
|
58
|
+
resolution: Optional[str] = None,
|
|
59
|
+
progress_callback: Callable = on_progress,
|
|
60
|
+
) -> Dict[str, Any]:
|
|
61
|
+
"""Download single YouTube video asynchronously."""
|
|
62
|
+
loop = asyncio.get_event_loop()
|
|
63
|
+
|
|
64
|
+
def _download():
|
|
65
|
+
yt = create_youtube_instance(url, progress_callback)
|
|
66
|
+
stream = select_video_stream(yt, resolution)
|
|
67
|
+
downloaded_file = download_stream(stream, output_path)
|
|
68
|
+
metadata = get_video_metadata(yt)
|
|
69
|
+
return {
|
|
70
|
+
"success": downloaded_file is not None,
|
|
71
|
+
"file_path": downloaded_file,
|
|
72
|
+
"metadata": metadata,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return await loop.run_in_executor(None, _download)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def download_single_audio(
|
|
79
|
+
url: str, output_path: str, progress_callback: Callable = on_progress
|
|
80
|
+
) -> Dict[str, Any]:
|
|
81
|
+
"""Download single YouTube audio asynchronously."""
|
|
82
|
+
loop = asyncio.get_event_loop()
|
|
83
|
+
|
|
84
|
+
def _download():
|
|
85
|
+
yt = create_youtube_instance(url, progress_callback)
|
|
86
|
+
stream = select_audio_stream(yt)
|
|
87
|
+
downloaded_file = download_stream(stream, output_path)
|
|
88
|
+
metadata = get_video_metadata(yt)
|
|
89
|
+
return {
|
|
90
|
+
"success": downloaded_file is not None,
|
|
91
|
+
"file_path": downloaded_file,
|
|
92
|
+
"metadata": metadata,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return await loop.run_in_executor(None, _download)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def download_playlist_videos(
|
|
99
|
+
url: str,
|
|
100
|
+
output_path: str,
|
|
101
|
+
resolution: Optional[str] = None,
|
|
102
|
+
progress_callback: Callable = on_progress,
|
|
103
|
+
max_concurrent: int = 3,
|
|
104
|
+
) -> List[Dict[str, Any]]:
|
|
105
|
+
"""Download all videos from YouTube playlist asynchronously."""
|
|
106
|
+
playlist = create_playlist_instance(url)
|
|
107
|
+
playlist_meta = get_playlist_metadata(playlist)
|
|
108
|
+
|
|
109
|
+
async def download_with_info(index: int, yt) -> Dict[str, Any]:
|
|
110
|
+
print(f"[{index + 1}/{playlist_meta['video_count']}] Downloading: {yt.title}")
|
|
111
|
+
try:
|
|
112
|
+
result = await download_single_video(
|
|
113
|
+
yt.watch_url, output_path, resolution, progress_callback
|
|
114
|
+
)
|
|
115
|
+
if result["success"]:
|
|
116
|
+
print(f"[OK] Successfully downloaded {yt.title}")
|
|
117
|
+
else:
|
|
118
|
+
print(f"[FAIL] Failed to download {yt.title}")
|
|
119
|
+
return result
|
|
120
|
+
except Exception as e:
|
|
121
|
+
print(f"[FAIL] Error downloading {yt.title}: {e}")
|
|
122
|
+
return {
|
|
123
|
+
"success": False,
|
|
124
|
+
"file_path": None,
|
|
125
|
+
"metadata": {"title": yt.title, "error": str(e)},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# Use semaphore to limit concurrent downloads
|
|
129
|
+
semaphore = asyncio.Semaphore(max_concurrent)
|
|
130
|
+
|
|
131
|
+
async def download_with_semaphore(index: int, yt):
|
|
132
|
+
async with semaphore:
|
|
133
|
+
return await download_with_info(index, yt)
|
|
134
|
+
|
|
135
|
+
tasks = [
|
|
136
|
+
download_with_semaphore(index, yt) for index, yt in enumerate(playlist.videos)
|
|
137
|
+
]
|
|
138
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
139
|
+
|
|
140
|
+
# Handle exceptions in results
|
|
141
|
+
processed_results = []
|
|
142
|
+
for i, result in enumerate(results):
|
|
143
|
+
if isinstance(result, Exception):
|
|
144
|
+
processed_results.append(
|
|
145
|
+
{
|
|
146
|
+
"success": False,
|
|
147
|
+
"file_path": None,
|
|
148
|
+
"metadata": {"title": f"video_{i}", "error": str(result)},
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
processed_results.append(result)
|
|
153
|
+
|
|
154
|
+
return processed_results
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def download_playlist_audios(
|
|
158
|
+
url: str,
|
|
159
|
+
output_path: str,
|
|
160
|
+
progress_callback: Callable = on_progress,
|
|
161
|
+
max_concurrent: int = 3,
|
|
162
|
+
) -> List[Dict[str, Any]]:
|
|
163
|
+
"""Download all audios from YouTube playlist asynchronously."""
|
|
164
|
+
playlist = create_playlist_instance(url)
|
|
165
|
+
playlist_meta = get_playlist_metadata(playlist)
|
|
166
|
+
|
|
167
|
+
async def download_with_info(index: int, yt) -> Dict[str, Any]:
|
|
168
|
+
print(f"[{index + 1}/{playlist_meta['video_count']}] Downloading: {yt.title}")
|
|
169
|
+
try:
|
|
170
|
+
result = await download_single_audio(
|
|
171
|
+
yt.watch_url, output_path, progress_callback
|
|
172
|
+
)
|
|
173
|
+
if result["success"]:
|
|
174
|
+
print(f"[OK] Successfully downloaded {yt.title}")
|
|
175
|
+
else:
|
|
176
|
+
print(f"[FAIL] Failed to download {yt.title}")
|
|
177
|
+
return result
|
|
178
|
+
except Exception as e:
|
|
179
|
+
print(f"[FAIL] Error downloading {yt.title}: {e}")
|
|
180
|
+
return {
|
|
181
|
+
"success": False,
|
|
182
|
+
"file_path": None,
|
|
183
|
+
"metadata": {"title": yt.title, "error": str(e)},
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# Use semaphore to limit concurrent downloads
|
|
187
|
+
semaphore = asyncio.Semaphore(max_concurrent)
|
|
188
|
+
|
|
189
|
+
async def download_with_semaphore(index: int, yt):
|
|
190
|
+
async with semaphore:
|
|
191
|
+
return await download_with_info(index, yt)
|
|
192
|
+
|
|
193
|
+
tasks = [
|
|
194
|
+
download_with_semaphore(index, yt) for index, yt in enumerate(playlist.videos)
|
|
195
|
+
]
|
|
196
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
197
|
+
|
|
198
|
+
# Handle exceptions in results
|
|
199
|
+
processed_results = []
|
|
200
|
+
for i, result in enumerate(results):
|
|
201
|
+
if isinstance(result, Exception):
|
|
202
|
+
processed_results.append(
|
|
203
|
+
{
|
|
204
|
+
"success": False,
|
|
205
|
+
"file_path": None,
|
|
206
|
+
"metadata": {"title": f"audio_{i}", "error": str(result)},
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
processed_results.append(result)
|
|
211
|
+
|
|
212
|
+
return processed_results
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def is_youtube_url(url: str) -> bool:
|
|
216
|
+
"""Check if URL is a YouTube URL."""
|
|
217
|
+
return "youtube.com" in url or "youtu.be" in url
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def is_playlist_url(url: str) -> bool:
|
|
221
|
+
"""Check if URL is a playlist URL."""
|
|
222
|
+
return "list=" in url
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def validate_youtube_url(url: str) -> Dict[str, bool]:
|
|
226
|
+
"""Validate YouTube URL and determine type."""
|
|
227
|
+
return {"is_valid": is_youtube_url(url), "is_playlist": is_playlist_url(url)}
|
app/utils/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from .media_format import all_formats, video_formats, audio_formats
|
|
3
|
+
from .constants import APP_VERSION
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def command_manager():
|
|
7
|
+
epilog_text = """
|
|
8
|
+
Examples:
|
|
9
|
+
mmcli "https://youtube.com/watch?v=..." # best-quality video
|
|
10
|
+
mmcli "https://youtube.com/watch?v=..." --resolution 720 # video at 720p
|
|
11
|
+
mmcli "https://youtube.com/watch?v=..." --format mp3 # audio only, as mp3
|
|
12
|
+
mmcli "https://youtube.com/watch?v=..." --format mkv # video as mkv
|
|
13
|
+
mmcli "https://youtube.com/playlist?list=..." --format mp3 # whole playlist as mp3
|
|
14
|
+
mmcli "<url>" --output-dir ~/Downloads # choose a directory
|
|
15
|
+
"""
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="mmcli",
|
|
18
|
+
description="YouTube downloader with format conversion",
|
|
19
|
+
epilog=epilog_text,
|
|
20
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--version", "-v", action="version", version=f"mmcli {APP_VERSION}"
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("url", help="YouTube video or playlist URL")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--resolution",
|
|
28
|
+
"-r",
|
|
29
|
+
help="Video resolution, e.g. 720 or 720p (ignored for audio formats)",
|
|
30
|
+
)
|
|
31
|
+
video_aliases = ", ".join(f["alias"] for f in video_formats)
|
|
32
|
+
audio_aliases = ", ".join(f["alias"] for f in audio_formats)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--format",
|
|
35
|
+
"-f",
|
|
36
|
+
choices=[f["alias"] for f in all_formats],
|
|
37
|
+
metavar="FORMAT",
|
|
38
|
+
help=(
|
|
39
|
+
"Output format. Audio formats download audio only "
|
|
40
|
+
f"({audio_aliases}); video formats convert the container "
|
|
41
|
+
f"({video_aliases})."
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--output-dir",
|
|
46
|
+
"-o",
|
|
47
|
+
dest="output_dir",
|
|
48
|
+
help="Output directory (default: current directory)",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return parser.parse_args()
|
app/utils/constants.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
video_formats = [
|
|
2
|
+
{"alias": "mp4", "format": "mp4", "desc": "MPEG-4 Part 14"},
|
|
3
|
+
{"alias": "mkv", "format": "matroska", "desc": "Matroska Multimedia Container"},
|
|
4
|
+
{"alias": "avi", "format": "avi", "desc": "Audio Video Interleaved"},
|
|
5
|
+
{"alias": "mov", "format": "mov", "desc": "QuickTime Movie"},
|
|
6
|
+
{"alias": "flv", "format": "flv", "desc": "Flash Video"},
|
|
7
|
+
{"alias": "webm", "format": "webm", "desc": "WebM Video"},
|
|
8
|
+
{"alias": "mpeg", "format": "mpeg", "desc": "MPEG Program Stream"},
|
|
9
|
+
{"alias": "mpg", "format": "mpeg", "desc": "MPEG Program Stream"},
|
|
10
|
+
{"alias": "ts", "format": "mpegts", "desc": "MPEG Transport Stream"},
|
|
11
|
+
{"alias": "m2ts", "format": "mpegts", "desc": "MPEG-2 Transport Stream"},
|
|
12
|
+
{"alias": "ogv", "format": "ogg", "desc": "Ogg Video"},
|
|
13
|
+
{"alias": "3gp", "format": "3gp", "desc": "3GPP Multimedia Container"},
|
|
14
|
+
{"alias": "3g2", "format": "3g2", "desc": "3GPP2 Multimedia Container"},
|
|
15
|
+
{"alias": "vob", "format": "vob", "desc": "DVD Video Object"},
|
|
16
|
+
{"alias": "f4v", "format": "f4v", "desc": "Flash Video F4V"},
|
|
17
|
+
{"alias": "wmv", "format": "asf", "desc": "Windows Media Video"},
|
|
18
|
+
{"alias": "rm", "format": "rm", "desc": "RealMedia"},
|
|
19
|
+
{"alias": "rmvb", "format": "rm", "desc": "RealMedia Variable Bitrate"},
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
audio_formats = [
|
|
23
|
+
{"alias": "mp3", "format": "mp3", "desc": "MPEG Audio Layer III"},
|
|
24
|
+
{"alias": "wav", "format": "wav", "desc": "Waveform Audio File Format"},
|
|
25
|
+
{"alias": "flac", "format": "flac", "desc": "Free Lossless Audio Codec"},
|
|
26
|
+
{"alias": "aac", "format": "aac", "desc": "Advanced Audio Coding"},
|
|
27
|
+
{"alias": "m4a", "format": "ipod", "desc": "MPEG-4 Audio"},
|
|
28
|
+
{"alias": "ogg", "format": "ogg", "desc": "Ogg Vorbis/Opus"},
|
|
29
|
+
{"alias": "oga", "format": "ogg", "desc": "Ogg Audio"},
|
|
30
|
+
{"alias": "opus", "format": "ogg", "desc": "Opus in Ogg"},
|
|
31
|
+
{"alias": "wma", "format": "asf", "desc": "Windows Media Audio"},
|
|
32
|
+
{"alias": "alac", "format": "ipod", "desc": "Apple Lossless Audio Codec"},
|
|
33
|
+
{"alias": "amr", "format": "amr", "desc": "Adaptive Multi-Rate Audio"},
|
|
34
|
+
{"alias": "ac3", "format": "ac3", "desc": "Dolby Digital AC-3"},
|
|
35
|
+
{"alias": "dts", "format": "dts", "desc": "Digital Theater Systems"},
|
|
36
|
+
{"alias": "eac3", "format": "eac3", "desc": "Enhanced AC-3"},
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
all_formats = video_formats + audio_formats
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_format(format: str, formats: list = all_formats) -> list:
|
|
43
|
+
return list(
|
|
44
|
+
filter(lambda f: f["alias"] == format or f["format"] == format, formats)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_audio_format(alias: str) -> bool:
|
|
49
|
+
"""True when alias names an audio output format."""
|
|
50
|
+
return any(f["alias"] == alias for f in audio_formats)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_video_format(alias: str) -> bool:
|
|
54
|
+
"""True when alias names a video output format."""
|
|
55
|
+
return any(f["alias"] == alias for f in video_formats)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mmcli-dl
|
|
3
|
+
Version: 0.1.0b1
|
|
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,16 @@
|
|
|
1
|
+
app/__init__.py,sha256=bgjtefMCsDatNGIlFOcXbqNtob2dzu7rxRj04KwOBwI,170
|
|
2
|
+
app/cli.py,sha256=knopJsZhJTYUVHKgWvBMSKP_VrIcZO3PytLbagm66R0,437
|
|
3
|
+
app/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
app/tools/media_converter.py,sha256=wHJFp4DgwvBkqMjum0PoykDtUQyhnfgs4HVgeeT11IM,8029
|
|
5
|
+
app/tools/media_downloader.py,sha256=tiGaJPnaBQn50z31NzQARQQFCcjgiGvsmyZvgdjlPnE,8434
|
|
6
|
+
app/tools/youtube_downloader.py,sha256=eXpO3xajfm3aQNi1rg8OzcNDh1__Y8t3h3aOlIVCcCo,7386
|
|
7
|
+
app/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
app/utils/command_manager.py,sha256=ZiDJyCrkhImvE_x2poIeHmAbDZzhDxXUjT3l0ZAuRDU,1922
|
|
9
|
+
app/utils/constants.py,sha256=PK7NnAOMl59SfF42pIoPORBtz-u425YjhXHlyCvbnk4,211
|
|
10
|
+
app/utils/media_format.py,sha256=mZ6V2VNJVohPfj_purw9Jm5yXHdlNDNVJJ7F38UgRPQ,2819
|
|
11
|
+
mmcli_dl-0.1.0b1.dist-info/licenses/LICENSE,sha256=U_xp6fNHr77KKxgMyEBpilbr3Pfj3pRFV85cBNy6fGY,1071
|
|
12
|
+
mmcli_dl-0.1.0b1.dist-info/METADATA,sha256=FUO5SisMiWlHA2_tOVBajCayzjvREZ6NTmHM8R72c8s,3294
|
|
13
|
+
mmcli_dl-0.1.0b1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
14
|
+
mmcli_dl-0.1.0b1.dist-info/entry_points.txt,sha256=xbJcgmZwoyNa8h6mIHc6At5UhNv7feW8tJgYybhz1cg,39
|
|
15
|
+
mmcli_dl-0.1.0b1.dist-info/top_level.txt,sha256=io9g7LCbfmTG1SFKgEOGXmCFB9uMP2H5lerm0HiHWQE,4
|
|
16
|
+
mmcli_dl-0.1.0b1.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
app
|