├── utils ├── __init__.py ├── process_articles.py ├── process_captions.py ├── process_mp4.py ├── process_assets.py ├── process_m3u8.py └── process_mpd.py ├── .github ├── FUNDING.yml └── dependabot.yml ├── .gitignore ├── requirements.txt ├── LICENSE ├── README.md ├── constants.py └── main.py /utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: swargaraj 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | logs/ 3 | cookie* 4 | venv/ 5 | courses/ 6 | dist/ 7 | build/ 8 | main.spec 9 | course.json -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2024.12.14 2 | charset-normalizer==3.4.0 3 | colorama==0.4.6 4 | idna==3.10 5 | iso8601==2.1.0 6 | m3u8==6.0.0 7 | markdown-it-py==3.0.0 8 | mdurl==0.1.2 9 | pathvalidate==3.2.1 10 | pycryptodome==3.21.0 11 | Pygments==2.18.0 12 | requests==2.32.3 13 | rich==13.9.4 14 | tqdm==4.67.1 15 | urllib3==2.2.3 16 | webvtt-py==0.5.1 -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /utils/process_articles.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from urllib.parse import urlparse 4 | from constants import ARTICLE_URL 5 | 6 | def download_article(udemy, article, download_folder_path, title_of_output_article, task_id, progress): 7 | 8 | progress.update(task_id, description=f"Downloading Article {title_of_output_article}", completed=0) 9 | 10 | article_filename = f"{title_of_output_article}.html" 11 | article_response = udemy.request(ARTICLE_URL.format(article_id=article['id'])).json() 12 | 13 | with open(os.path.join(os.path.dirname(download_folder_path), article_filename), 'w', encoding='utf-8', errors='replace') as file: 14 | file.write(article_response['body']) 15 | 16 | progress.console.log(f"[green]Downloaded {title_of_output_article}[/green] ✓") 17 | progress.remove_task(task_id) 18 | 19 | shutil.rmtree(download_folder_path) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Swargaraj Bhowmik 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. 22 | -------------------------------------------------------------------------------- /utils/process_captions.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import webvtt 4 | 5 | def download_captions(captions, download_folder_path, title_of_output_mp4, captions_list, convert_to_srt): 6 | filtered_captions = [caption for caption in captions if caption["locale_id"] in captions_list] 7 | 8 | for caption in filtered_captions: 9 | response = requests.get(caption['url']) 10 | response.raise_for_status() 11 | if caption['file_name'].endswith('.vtt'): 12 | caption_name = f"{title_of_output_mp4} - {caption['video_label']}.vtt" 13 | vtt_path = os.path.join(download_folder_path, caption_name) 14 | with open(vtt_path, 'wb') as file: 15 | file.write(response.content) 16 | 17 | if convert_to_srt: 18 | srt_name = caption_name.replace('.vtt', '.srt') 19 | srt_path = os.path.join(download_folder_path, srt_name) 20 | srt_content = webvtt.read(vtt_path) 21 | srt_content.save_as_srt(srt_path) 22 | 23 | else: 24 | print("Only VTT captions are supported. Please create a github issue if you'd like to add support for other formats.") -------------------------------------------------------------------------------- /utils/process_mp4.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import requests 4 | from constants import remove_emojis_and_binary 5 | 6 | def download_mp4(mp4_file_url, download_folder_path, title_of_output_mp4, task_id, progress): 7 | progress.update(task_id, description=f"Downloading Video {remove_emojis_and_binary(title_of_output_mp4)}", completed=0) 8 | output_path = os.path.dirname(download_folder_path) 9 | 10 | try: 11 | response = requests.get(mp4_file_url, stream=True) 12 | response.raise_for_status() 13 | total_size = int(response.headers.get('content-length', 0)) 14 | 15 | downloaded_size = 0 16 | 17 | 18 | output_file = os.path.join(output_path, title_of_output_mp4 + ".mp4") 19 | with open(output_file, 'wb') as f: 20 | for chunk in response.iter_content(chunk_size=8192): 21 | if chunk: 22 | f.write(chunk) 23 | downloaded_size += len(chunk) 24 | percentage = (downloaded_size / total_size) * 100 25 | progress.update(task_id, completed=percentage) 26 | 27 | progress.update(task_id, completed=100) 28 | progress.console.log(f"[green]Downloaded {remove_emojis_and_binary(title_of_output_mp4)}[/green] ✓") 29 | progress.remove_task(task_id) 30 | shutil.rmtree(download_folder_path) 31 | except Exception as e: 32 | print(e) 33 | progress.console.log(f"[red]Error Downloading {remove_emojis_and_binary(title_of_output_mp4)}[/red] ✕") -------------------------------------------------------------------------------- /utils/process_assets.py: -------------------------------------------------------------------------------- 1 | import os 2 | from urllib.parse import urlparse 3 | from constants import LINK_ASSET_URL, FILE_ASSET_URL 4 | 5 | def download_supplementary_assets(udemy, assets, download_folder_path, course_id, lecture_id): 6 | for asset in assets: 7 | match asset['asset_type']: 8 | case 'File': 9 | process_files(udemy, asset, course_id, lecture_id, download_folder_path) 10 | case 'ExternalLink': 11 | process_external_links(udemy, asset, course_id, lecture_id, download_folder_path) 12 | case _: 13 | pass 14 | # Unsupported asset type. Please create a github issue if you'd like to add support for other types 15 | 16 | def process_files(udemy, asset, course_id, lecture_id, download_folder_path): 17 | 18 | assets_folder = os.path.join(download_folder_path, "assets") 19 | if not os.path.exists(assets_folder): 20 | os.makedirs(assets_folder) 21 | 22 | asset_file_path = os.path.join(assets_folder, asset['filename']) 23 | 24 | file_response = udemy.request(udemy.request(FILE_ASSET_URL.format(course_id=course_id, lecture_id=lecture_id, asset_id=asset['id'])).json()['download_urls']['File'][0]['file']) 25 | 26 | file_response.raise_for_status() 27 | 28 | with open(asset_file_path, 'wb') as file: 29 | for chunk in file_response.iter_content(chunk_size=8192): 30 | if chunk: 31 | file.write(chunk) 32 | 33 | def process_external_links(udemy, asset, course_id, lecture_id, download_folder_path): 34 | 35 | external_links_folder = os.path.join(download_folder_path, "external-links") 36 | if not os.path.exists(external_links_folder): 37 | os.makedirs(external_links_folder) 38 | 39 | asset_filename = f"{asset['filename']}.url" 40 | asset_file_path = os.path.join(external_links_folder, asset_filename) 41 | 42 | response = udemy.request(LINK_ASSET_URL.format(course_id=course_id, lecture_id=lecture_id, asset_id=asset['id'])).json() 43 | 44 | asset_url = response['external_url'] 45 | 46 | with open(asset_file_path, 'w') as file: 47 | file.write(f"[InternetShortcut]\nURL={asset_url}\n") -------------------------------------------------------------------------------- /utils/process_m3u8.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | import m3u8 4 | import shutil 5 | import requests 6 | import subprocess 7 | from constants import remove_emojis_and_binary 8 | 9 | def download_and_merge_m3u8(m3u8_file_url, download_folder_path, title_of_output_mp4, task_id, progress): 10 | progress.update(task_id, description=f"Downloading Stream {remove_emojis_and_binary(title_of_output_mp4)}", completed=0) 11 | 12 | response = requests.get(m3u8_file_url) 13 | response.raise_for_status() 14 | 15 | m3u8_content = response.text 16 | m3u8_obj = m3u8.loads(m3u8_content) 17 | playlists = m3u8_obj.playlists 18 | 19 | highest_quality_playlist = None 20 | max_resolution = (0, 0) 21 | 22 | progress.update(task_id, completed=99) 23 | 24 | for pl in playlists: 25 | resolution = pl.stream_info.resolution 26 | 27 | if resolution and (resolution[0] * resolution[1] > max_resolution[0] * max_resolution[1]): 28 | highest_quality_playlist = pl 29 | max_resolution = resolution 30 | 31 | if not highest_quality_playlist: 32 | progress.console.log(f"No valid playlists {remove_emojis_and_binary(title_of_output_mp4)} ✕") 33 | progress.remove_task(task_id) 34 | return 35 | 36 | highest_quality_url = highest_quality_playlist.uri 37 | 38 | highest_quality_response = requests.get(highest_quality_url) 39 | m3u8_file_path = os.path.join(download_folder_path, "index.m3u8") 40 | 41 | with open(m3u8_file_path, 'wb') as file: 42 | file.write(highest_quality_response.content) 43 | 44 | merge_segments_into_mp4(m3u8_file_path, download_folder_path, title_of_output_mp4, task_id, progress) 45 | 46 | def merge_segments_into_mp4(m3u8_file_path, download_folder_path, output_file_name, task_id, progress): 47 | output_path = os.path.dirname(download_folder_path) 48 | 49 | progress.update(task_id, description=f"Merging segments {remove_emojis_and_binary(output_file_name)}", completed=0) 50 | 51 | nm3u8dl_command = ( 52 | f"n_m3u8dl-re \"{m3u8_file_path}\" --save-dir \"{output_path}\" " 53 | f"--save-name \"{output_file_name}\" --auto-select --concurrent-download " 54 | f"--del-after-done --no-log --tmp-dir \"{output_path}\" --log-level ERROR" 55 | ) 56 | 57 | pattern = re.compile(r'(\d+\.\d+%)') 58 | process = subprocess.Popen(nm3u8dl_command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) 59 | 60 | while True: 61 | output = process.stdout.readline() 62 | if output == '' and process.poll() is not None: 63 | break 64 | if output: 65 | stripped_output = output.strip().replace(' ', '') 66 | if stripped_output.startswith('Vid'): 67 | matches = pattern.findall(output) 68 | if matches: 69 | first_percentage = float(matches[0].replace('%', '')) 70 | progress.update(task_id, completed=first_percentage) 71 | 72 | stdout, stderr = process.communicate() 73 | 74 | if stderr or process.returncode != 0: 75 | progress.console.log(f"[red]Error Merging {remove_emojis_and_binary(output_file_name)}[/red] ✕") 76 | progress.remove_task(task_id) 77 | return 78 | 79 | progress.console.log(f"[green]Downloaded {remove_emojis_and_binary(output_file_name)}[/green] ✓") 80 | progress.remove_task(task_id) 81 | shutil.rmtree(download_folder_path) -------------------------------------------------------------------------------- /utils/process_mpd.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | import subprocess 5 | import requests 6 | from urllib.parse import urlparse 7 | from constants import remove_emojis_and_binary, timestamp_to_seconds 8 | 9 | def download_and_merge_mpd(mpd_file_url, download_folder_path, title_of_output_mp4, length, key, task_id, progress): 10 | progress.update(task_id, description=f"Downloading Stream {remove_emojis_and_binary(title_of_output_mp4)}", completed=0) 11 | 12 | mpd_filename = os.path.basename(urlparse(mpd_file_url).path) 13 | mpd_file_path = os.path.join(download_folder_path, mpd_filename) 14 | 15 | response = requests.get(mpd_file_url) 16 | response.raise_for_status() 17 | 18 | with open(mpd_file_path, 'wb') as file: 19 | file.write(response.content) 20 | 21 | process_mpd(mpd_file_path, download_folder_path, title_of_output_mp4, length, key, task_id, progress) 22 | 23 | def process_mpd(mpd_file_path, download_folder_path, output_file_name, length, key, task_id, progress): 24 | nm3u8dl_command = ( 25 | f"n_m3u8dl-re \"{mpd_file_path}\" --save-dir \"{download_folder_path}\" " 26 | f"--save-name \"{output_file_name}.mp4\" --auto-select --concurrent-download " 27 | f"--key {key} --del-after-done --no-log --tmp-dir \"{download_folder_path}\" " 28 | f"--log-level ERROR" 29 | ) 30 | 31 | pattern = re.compile(r'(\d+\.\d+%)') 32 | process_nm3u8dl = subprocess.Popen( 33 | nm3u8dl_command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True 34 | ) 35 | 36 | progress.update(task_id, description=f"Merging segments {remove_emojis_and_binary(output_file_name)}", completed=0) 37 | 38 | while True: 39 | output = process_nm3u8dl.stdout.readline() 40 | if output == '' and process_nm3u8dl.poll() is not None: 41 | break 42 | if output: 43 | stripped_output = output.strip().replace(' ', '') 44 | if stripped_output.startswith('Vid'): 45 | matches = pattern.findall(output) 46 | if matches: 47 | first_percentage = float(matches[0].replace('%', '')) 48 | if first_percentage < 100.0: 49 | progress.update(task_id, completed=first_percentage) 50 | else: 51 | progress.update(task_id, completed=99) 52 | 53 | stdout_nm3u8dl, stderr_nm3u8dl = process_nm3u8dl.communicate() 54 | 55 | if stderr_nm3u8dl or process_nm3u8dl.returncode != 0: 56 | progress.console.log(f"[red]Error Downloading Segments {remove_emojis_and_binary(output_file_name)}[/red] ✕") 57 | progress.remove_task(task_id) 58 | return 59 | 60 | files = os.listdir(download_folder_path) 61 | mp4_files = [f for f in files if f.endswith('.mp4')] 62 | m4a_files = [f for f in files if f.endswith('.m4a')] 63 | 64 | if not mp4_files or not m4a_files: 65 | progress.console.log(f"[red]Missing Video and Audio files {output_file_name}[/red] ✕") 66 | progress.remove_task(task_id) 67 | return 68 | 69 | progress.update(task_id, description=f"Merging Video and Audio {remove_emojis_and_binary(output_file_name)}", completed=0) 70 | 71 | video_path = os.path.join(download_folder_path, mp4_files[0]) 72 | audio_path = os.path.join(download_folder_path, m4a_files[0]) 73 | output_path = os.path.join(os.path.dirname(download_folder_path), output_file_name) 74 | 75 | ffmpeg_command = ( 76 | f"ffmpeg -i \"{video_path}\" -i \"{audio_path}\" -c:v copy -c:a aac -y " 77 | f"\"{output_path}.mp4\"" 78 | ) 79 | 80 | process_ffmpeg = subprocess.Popen( 81 | ffmpeg_command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True 82 | ) 83 | 84 | time_pattern = re.compile(r'time=(\d{2}:\d{2}:\d{2}\.\d{2})') 85 | 86 | while True: 87 | output = process_ffmpeg.stderr.readline() 88 | if output == '' and process_ffmpeg.poll() is not None: 89 | break 90 | if output: 91 | match = time_pattern.search(output) 92 | if match: 93 | timestamp = match.group(1) 94 | seconds = timestamp_to_seconds(timestamp) 95 | progress.update(task_id, completed=(int(seconds) / length) * 100) 96 | 97 | stdout_ffmpeg, stderr_ffmpeg = process_ffmpeg.communicate() 98 | 99 | if stderr_ffmpeg or process_ffmpeg.returncode != 0: 100 | progress.console.log(f"[red]Error Merging Video and Audio files {remove_emojis_and_binary(output_file_name)}[/red] ✕") 101 | progress.remove_task(task_id) 102 | return 103 | 104 | progress.console.log(f"[green]Downloaded {remove_emojis_and_binary(output_file_name)}[/green] ✓") 105 | progress.remove_task(task_id) 106 | shutil.rmtree(download_folder_path) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

udemy-py 🎓

3 |

A python-based tool enabling users to fetch udemy course content and save it locally, allowing for offline access.

4 | 5 | 6 | 7 | 8 |
9 | 10 | > [!CAUTION] 11 | > Downloading and decrypting content from Udemy without proper authorization or in violation of their terms of service is illegal and unethical. By using this tool, you agree to comply with all applicable laws and respect the intellectual property rights of content creators. The creator of this tool is not responsible for any illegal use or consequences arising from the use of this software. 12 | 13 | ## Requirements 14 | To use this tool, you need to install some third-party software and Python modules. Follow the instructions below to set up your environment: 15 | ### Third-Party Software 16 | 1. [FFmpeg](https://www.ffmpeg.org/download.html): This tool is required for handling multimedia files. You can download it from [FFmpeg's official website](https://www.ffmpeg.org/download.html) and follow the installation instructions specific to your operating system. 17 | 2. [n_m3u8_dl-re](https://github.com/nilaoda/N_m3u8DL-RE/releases): This tool is used for downloading and processing m3u8 & mpd streams. Make sure to rename the downloaded binary to n_m3u8_dl-re (case-sensitive) for compatibility with this tool. You can find it on GitHub at [n_m3u8_dl-re](https://github.com/nilaoda/N_m3u8DL-RE/releases). 18 | 3. [MP4 Decrypt](https://www.bento4.com/downloads/): This software is necessary for decrypting MP4 files. You can download their SDK from their [official site](https://www.bento4.com/downloads/). 19 | 20 | ### Python Modules 21 | Install the required Python modules using the following command: 22 | ``` 23 | pip install -r requirements.txt 24 | ``` 25 | Make sure you have a working Python environment and pip installed to handle the dependencies listed in requirements.txt. 26 | 27 | ## Getting Started 28 | To use this tool, you'll need to set up a few prerequisites: 29 | 30 | ### Udemy Cookies 31 | You need to provide Udemy cookies to authenticate your requests. To extract these cookies: 32 | - Use the [Cookie Editor extension](https://cookie-editor.com/) (available for Chrome or Firefox). 33 | - Extract the cookies as a Netscape format. 34 | - Save the extracted cookies as `cookies.txt` and place this file in the same directory where you execute the tool. 35 | 36 | ### Decryption Key 37 | If you're dealing with DRM-protected videos, you'll need a decryption key. This key is essential for decrypting such content. 38 | > [!WARNING] 39 | > No guidance or assistance on obtaining the decryption key will be provided, as circumventing DRM protection is illegal. Ensure you comply with all applicable laws and respect intellectual property rights. 40 | 41 | ## Example Usage 42 | 43 | ``` 44 | python .\main.py --url "https://www.udemy.com/course/example-course" --key decryption_key --cookies /path/to/cookies.txt --concurrent 8 --captions en_US 45 | ``` 46 | 47 | ## Advance Usage 48 | 49 | ``` 50 | usage: main.py [-h] [--id ID] [--url URL] [--key KEY] [--cookies COOKIES] [--load [LOAD]] [--save [SAVE]] [--concurrent CONCURRENT] 51 | [--captions CAPTIONS] [--tree [TREE]] [--skip-captions [SKIP_CAPTIONS]] [--skip-assets [SKIP_ASSETS]] 52 | [--skip-lectures [SKIP_LECTURES]] [--skip-articles [SKIP_ARTICLES]] [--skip-assignments [SKIP_ASSIGNMENTS]] 53 | 54 | Udemy Course Downloader 55 | 56 | options: 57 | -h, --help show this help message and exit 58 | --id ID, -i ID The ID of the Udemy course to download 59 | --url URL, -u URL The URL of the Udemy course to download 60 | --key KEY, -k KEY Key to decrypt the DRM-protected videos 61 | --cookies COOKIES, -c COOKIES 62 | Path to cookies.txt file 63 | --load [LOAD], -l [LOAD] 64 | Load course curriculum from file 65 | --save [SAVE], -s [SAVE] 66 | Save course curriculum to a file 67 | --concurrent CONCURRENT, -cn CONCURRENT 68 | Maximum number of concurrent downloads 69 | --captions CAPTIONS Specify what captions to download. Separate multiple captions with commas 70 | --tree [TREE] Create a tree view of the course curriculum 71 | --skip-captions [SKIP_CAPTIONS] 72 | Skip downloading captions 73 | --skip-assets [SKIP_ASSETS] 74 | Skip downloading assets 75 | --skip-lectures [SKIP_LECTURES] 76 | Skip downloading lectures 77 | --skip-articles [SKIP_ARTICLES] 78 | Skip downloading articles 79 | --skip-assignments [SKIP_ASSIGNMENTS] 80 | Skip downloading assignments 81 | ``` 82 | 83 | ## License 84 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 85 | -------------------------------------------------------------------------------- /constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import time 4 | import logging 5 | import argparse 6 | from itertools import cycle 7 | from shutil import get_terminal_size 8 | from threading import Thread 9 | from rich.progress import TextColumn 10 | 11 | COURSE_URL = "https://udemy.com/api-2.0/courses/{course_id}/" 12 | CURRICULUM_URL = "https://udemy.com/api-2.0/courses/{course_id}/subscriber-curriculum-items/?page_size=200&fields[lecture]=title,object_index,is_published,sort_order,created,asset,supplementary_assets,is_free&fields[quiz]=title,object_index,is_published,sort_order,type&fields[practice]=title,object_index,is_published,sort_order&fields[chapter]=title,object_index,is_published,sort_order&fields[asset]=title,filename,asset_type,status,time_estimation,is_external&caching_intent=True" 13 | LECTURE_URL = "https://www.udemy.com/api-2.0/users/me/subscribed-courses/{course_id}/lectures/{lecture_id}?fields[lecture]=asset,description,download_url,is_free,last_watched_second&fields[asset]=asset_type,media_sources,captions" 14 | QUIZ_URL = "https://udemy.com/api-2.0/quizzes/{quiz_id}/assessments/?version=1&page_size=200&fields[assessment]=id,assessment_type,prompt,correct_response,section,question_plain,related_lectures" 15 | LINK_ASSET_URL = "https://www.udemy.com/api-2.0/users/me/subscribed-courses/{course_id}/lectures/{lecture_id}/supplementary-assets/{asset_id}/?fields[asset]=external_url" 16 | FILE_ASSET_URL = "https://www.udemy.com/api-2.0/users/me/subscribed-courses/{course_id}/lectures/{lecture_id}/supplementary-assets/{asset_id}/?fields[asset]=download_urls" 17 | ARTICLE_URL = "https://www.udemy.com/api-2.0/assets/{article_id}/?fields[asset]=@min,status,delayed_asset_message,processing_errors,body" 18 | 19 | HOME_DIR = os.getcwd() 20 | DOWNLOAD_DIR = os.path.join(HOME_DIR, "courses") 21 | 22 | LOG_DIR = os.path.join(HOME_DIR, "logs") 23 | os.makedirs(LOG_DIR, exist_ok=True) 24 | LOG_FILE_PATH = os.path.join(LOG_DIR, f"{time.strftime('%Y-%m-%d')}.log") 25 | 26 | class LogFormatter(logging.Formatter): 27 | RESET = "\x1b[0m" 28 | COLOR_CODES = { 29 | 'INFO': "\x1b[32m", # Green 30 | 'WARNING': "\x1b[33m", # Yellow 31 | 'ERROR': "\x1b[31m", # Red 32 | 'CRITICAL': "\x1b[41m" # Red background 33 | } 34 | 35 | def format(self, record): 36 | original_levelname = record.levelname 37 | 38 | log_color = self.COLOR_CODES.get(record.levelname, self.RESET) 39 | record.levelname = f"{log_color}{record.levelname}{self.RESET}" 40 | 41 | formatted_message = super().format(record) 42 | 43 | record.levelname = original_levelname 44 | 45 | return formatted_message 46 | 47 | logger = logging.getLogger(__name__) 48 | logger.setLevel(logging.INFO) 49 | 50 | console_handler = logging.StreamHandler() 51 | console_handler.setFormatter(LogFormatter('%(asctime)s %(levelname)s : %(message)s')) 52 | logger.addHandler(console_handler) 53 | 54 | file_handler = logging.FileHandler(LOG_FILE_PATH) 55 | file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s : %(message)s')) 56 | logger.addHandler(file_handler) 57 | 58 | class LoadAction(argparse.Action): 59 | def __call__(self, parser, namespace, values, option_string=None): 60 | setattr(namespace, self.dest, values if values is not None else True) 61 | 62 | 63 | # Source: https://stackoverflow.com/questions/22029562/python-how-to-make-simple-animated-loading-while-process-is-running 64 | class Loader: 65 | def __init__(self, desc="Processing", timeout=0.1): 66 | self.desc = desc 67 | self.timeout = timeout 68 | self._thread = Thread(target=self._animate, daemon=True) 69 | self.steps = ["⢿", "⣻", "⣽", "⣾", "⣷", "⣯", "⣟", "⡿"] 70 | self.done = False 71 | 72 | def start(self): 73 | self._thread.start() 74 | return self 75 | 76 | def _animate(self): 77 | for c in cycle(self.steps): 78 | if self.done: 79 | break 80 | print(f"\r{self.desc} {c}", flush=True, end="") 81 | time.sleep(self.timeout) 82 | 83 | def __enter__(self): 84 | self.start() 85 | return self 86 | 87 | def stop(self): 88 | self.done = True 89 | # Clear the spinner line 90 | cols = os.get_terminal_size().columns 91 | print("\r" + " " * cols, end="", flush=True) 92 | print("\r", end="", flush=True) 93 | 94 | def __exit__(self, exc_type, exc_value, tb): 95 | self.stop() 96 | 97 | class ElapsedTimeColumn(TextColumn): 98 | def __init__(self, *args, **kwargs): 99 | super().__init__("{elapsed_time}", *args, **kwargs) 100 | self.start_time = time.time() 101 | 102 | def render(self, task): 103 | if task.completed==100: 104 | return "[green]Completed[/green]" 105 | 106 | elapsed = time.time() - self.start_time 107 | formatted_time = f"[yellow]{elapsed:.2f}s[/yellow]" 108 | return formatted_time 109 | 110 | def remove_emojis_and_binary(text): 111 | emoji_pattern = re.compile( 112 | "[" 113 | "\U0001F600-\U0001F64F" # Emoticons 114 | "\U0001F300-\U0001F5FF" # Symbols & Pictographs 115 | "\U0001F680-\U0001F6FF" # Transport & Map Symbols 116 | "\U0001F700-\U0001F77F" # Alchemical Symbols 117 | "\U0001F780-\U0001F7FF" # Geometric Shapes Extended 118 | "\U0001F800-\U0001F8FF" # Supplemental Arrows-C 119 | "\U0001F900-\U0001F9FF" # Supplemental Symbols and Pictographs 120 | "\U0001FA00-\U0001FA6F" # Chess Symbols 121 | "\U0001FA70-\U0001FAFF" # Symbols and Pictographs Extended-A 122 | "\U00002702-\U000027B0" # Dingbats 123 | "\U000024C2-\U0001F251" # Enclosed Characters 124 | "]+", 125 | flags=re.UNICODE 126 | ) 127 | 128 | text = emoji_pattern.sub(r'', text) 129 | 130 | text = ''.join(c for c in text if 32 <= ord(c) <= 126) 131 | 132 | return text 133 | 134 | def timestamp_to_seconds(timestamp): 135 | hours, minutes, seconds = timestamp.split(':') 136 | seconds, fraction = seconds.split('.') 137 | total_seconds = int(hours) * 3600 + int(minutes) * 60 + int(seconds) + int(fraction) / 100 138 | return total_seconds 139 | 140 | def format_time(seconds): 141 | hours, remainder = divmod(seconds, 3600) 142 | minutes, seconds = divmod(remainder, 60) 143 | return f"{hours}hr {minutes}min {seconds}s" if hours > 0 else f"{minutes}min {seconds}s" 144 | 145 | def is_valid_chapter(mindex, start_chapter, end_chapter): 146 | return start_chapter <= mindex <= end_chapter 147 | 148 | def is_valid_lecture(mindex, lindex, start_chapter, start_lecture, end_chapter, end_lecture): 149 | if mindex == start_chapter and lindex < start_lecture: 150 | return False 151 | if mindex == end_chapter and lindex > end_lecture: 152 | return False 153 | return True 154 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | import requests 5 | import argparse 6 | import subprocess 7 | from pathvalidate import sanitize_filename 8 | from rich.console import Console 9 | from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn 10 | from rich.live import Live 11 | from rich.tree import Tree 12 | from rich.text import Text 13 | from rich import print as rprint 14 | 15 | import re 16 | import http.cookiejar as cookielib 17 | from concurrent.futures import ThreadPoolExecutor, as_completed 18 | 19 | from constants import * 20 | from utils.process_m3u8 import download_and_merge_m3u8 21 | from utils.process_mpd import download_and_merge_mpd 22 | from utils.process_captions import download_captions 23 | from utils.process_assets import download_supplementary_assets 24 | from utils.process_articles import download_article 25 | from utils.process_mp4 import download_mp4 26 | 27 | console = Console() 28 | 29 | class Udemy: 30 | def __init__(self): 31 | global cookie_jar 32 | try: 33 | cookie_jar = cookielib.MozillaCookieJar(cookie_path) 34 | cookie_jar.load() 35 | except Exception as e: 36 | logger.critical(f"The provided cookie file could not be read or is incorrectly formatted. Please ensure the file is in the correct format and contains valid authentication cookies.") 37 | sys.exit(1) 38 | 39 | def request(self, url): 40 | try: 41 | response = requests.get(url, cookies=cookie_jar, stream=True) 42 | return response 43 | except Exception as e: 44 | logger.critical(f"There was a problem reaching the Udemy server. This could be due to network issues, an invalid URL, or Udemy being temporarily unavailable.") 45 | 46 | def extract_course_id(self, course_url): 47 | 48 | with Loader(f"Fetching course ID"): 49 | response = self.request(course_url) 50 | content_str = response.content.decode('utf-8') 51 | 52 | meta_match = re.search(r'3}%"), 93 | transient=True 94 | ) as progress: 95 | task = progress.add_task(description="Fetching Course Curriculum", total=total_count) 96 | 97 | while url: 98 | response = self.request(url).json() 99 | 100 | if response.get('detail') == 'You do not have permission to perform this action.': 101 | progress.console.log("[red]The course was found, but the curriculum (lectures and materials) could not be retrieved. This could be due to API issues, restrictions on the course, or a malformed course structure.[/red]") 102 | sys.exit(1) 103 | 104 | if response.get('detail') == 'Not found.': 105 | progress.console.log("[red]The course was found, but the curriculum (lectures and materials) could not be retrieved. This could be due to API issues, restrictions on the course, or a malformed course structure.[/red]") 106 | sys.exit(1) 107 | 108 | if total_count == 0: 109 | total_count = response.get('count', 0) 110 | progress.update(task, total=total_count) 111 | 112 | results = response.get('results', []) 113 | all_results.extend(results) 114 | progress.update(task, completed=len(all_results)) 115 | 116 | url = response.get('next') 117 | 118 | progress.update(task_id = task, description="Fetched Course Curriculum", total=total_count) 119 | return self.organize_curriculum(all_results) 120 | 121 | def organize_curriculum(self, results): 122 | curriculum = [] 123 | current_chapter = None 124 | 125 | total_lectures = 0 126 | 127 | for item in results: 128 | if item['_class'] == 'chapter': 129 | current_chapter = { 130 | 'id': item['id'], 131 | 'title': item['title'], 132 | 'is_published': item['is_published'], 133 | 'children': [] 134 | } 135 | curriculum.append(current_chapter) 136 | elif item['_class'] == 'lecture': 137 | if current_chapter is not None: 138 | current_chapter['children'].append(item) 139 | if item['_class'] == 'lecture': 140 | total_lectures += 1 141 | else: 142 | logger.warning("Found lecture without a parent chapter.") 143 | 144 | num_chapters = len(curriculum) 145 | 146 | logger.info(f"Discovered Chapter(s): {num_chapters}") 147 | logger.info(f"Discovered Lectures(s): {total_lectures}") 148 | 149 | return curriculum 150 | 151 | def build_curriculum_tree(self, data, tree, index=1): 152 | for i, item in enumerate(data, start=index): 153 | if 'title' in item: 154 | title = f"{i:02d}. {item['title']}" 155 | if '_class' in item and item['_class'] == 'lecture': 156 | time_estimation = item.get('asset', {}).get('time_estimation') 157 | if time_estimation: 158 | time_str = format_time(time_estimation) 159 | title += f" ({time_str})" 160 | node_text = Text(title, style="cyan") 161 | else: 162 | node_text = Text(title, style="magenta") 163 | 164 | node = tree.add(node_text) 165 | 166 | if 'children' in item: 167 | self.build_curriculum_tree(item['children'], node, index=1) 168 | 169 | def fetch_lecture_info(self, course_id, lecture_id): 170 | try: 171 | return self.request(LECTURE_URL.format(course_id=course_id, lecture_id=lecture_id)).json() 172 | except Exception as e: 173 | logger.critical(f"Failed to fetch lecture info: {e}") 174 | sys.exit(1) 175 | 176 | def create_directory(self, path): 177 | try: 178 | os.makedirs(path) 179 | except FileExistsError: 180 | pass 181 | except Exception as e: 182 | logger.error(f"Failed to create directory \"{path}\": {e}") 183 | sys.exit(1) 184 | 185 | def download_lecture(self, course_id, lecture, lect_info, temp_folder_path, lindex, folder_path, task_id, progress): 186 | if not skip_captions and len(lect_info["asset"]["captions"]) > 0: 187 | download_captions(lect_info["asset"]["captions"], folder_path, f"{lindex}. {sanitize_filename(lecture['title'])}", captions, convert_to_srt) 188 | 189 | if not skip_assets and len(lecture["supplementary_assets"]) > 0: 190 | download_supplementary_assets(self, lecture["supplementary_assets"], folder_path, course_id, lect_info["id"]) 191 | 192 | if not skip_lectures and lect_info['asset']['asset_type'] == "Video": 193 | mpd_url = next((item['src'] for item in lect_info['asset']['media_sources'] if item['type'] == "application/dash+xml"), None) 194 | mp4_url = next((item['src'] for item in lect_info['asset']['media_sources'] if item['type'] == "video/mp4"), None) 195 | m3u8_url = next((item['src'] for item in lect_info['asset']['media_sources'] if item['type'] == "application/x-mpegURL"), None) 196 | 197 | if mpd_url is None: 198 | if m3u8_url is None: 199 | if mp4_url is None: 200 | logger.error(f"This lecture appears to be served in different format. We currently do not support downloading this format. Please create an issue on GitHub if you need this feature.") 201 | else: 202 | download_mp4(mp4_url, temp_folder_path, f"{lindex}. {sanitize_filename(lecture['title'])}", task_id, progress) 203 | else: 204 | download_and_merge_m3u8(m3u8_url, temp_folder_path, f"{lindex}. {sanitize_filename(lecture['title'])}", task_id, progress) 205 | else: 206 | if key is None: 207 | logger.warning("The video appears to be DRM-protected, and it may not play without a valid Widevine decryption key.") 208 | download_and_merge_mpd(mpd_url, temp_folder_path, f"{lindex}. {sanitize_filename(lecture['title'])}", lecture['asset']['time_estimation'], key, task_id, progress) 209 | elif not skip_articles and lect_info['asset']['asset_type'] == "Article": 210 | download_article(self, lect_info['asset'], temp_folder_path, f"{lindex}. {sanitize_filename(lecture['title'])}", task_id, progress) 211 | 212 | try: 213 | progress.remove_task(task_id) 214 | except KeyError: 215 | pass 216 | 217 | def download_course(self, course_id, curriculum): 218 | progress = Progress( 219 | SpinnerColumn(), 220 | TextColumn("[progress.description]{task.description}"), 221 | BarColumn(), 222 | TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), 223 | ElapsedTimeColumn(), 224 | ) 225 | 226 | tasks = {} 227 | futures = [] 228 | 229 | with ThreadPoolExecutor(max_workers=max_concurrent_lectures) as executor, Live(progress, refresh_per_second=10): 230 | task_generator = ( 231 | (f"{mindex:02}" if mindex < 10 else f"{mindex}", 232 | chapter, 233 | f"{lindex:02}" if lindex < 10 else f"{lindex}", 234 | lecture) 235 | for mindex, chapter in enumerate(curriculum, start=1) 236 | if is_valid_chapter(mindex, start_chapter, end_chapter) 237 | for lindex, lecture in enumerate(chapter['children'], start=1) 238 | if is_valid_lecture(mindex, lindex, start_chapter, start_lecture, end_chapter, end_lecture) 239 | ) 240 | 241 | for _ in range(max_concurrent_lectures): 242 | try: 243 | mindex, chapter, lindex, lecture = next(task_generator) 244 | folder_path = os.path.join(COURSE_DIR, f"{mindex}. {remove_emojis_and_binary(sanitize_filename(chapter['title']))}") 245 | temp_folder_path = os.path.join(folder_path, str(lecture['id'])) 246 | self.create_directory(temp_folder_path) 247 | lect_info = self.fetch_lecture_info(course_id, lecture['id']) 248 | 249 | task_id = progress.add_task( 250 | f"Downloading Lecture: {lecture['title']} ({lindex}/{len(chapter['children'])})", 251 | total=100 252 | ) 253 | tasks[task_id] = (lecture, lect_info, temp_folder_path, lindex, folder_path) 254 | 255 | future = executor.submit( 256 | self.download_lecture, course_id, lecture, lect_info, temp_folder_path, lindex, folder_path, task_id, progress 257 | ) 258 | 259 | futures.append((task_id, future)) 260 | except StopIteration: 261 | break 262 | 263 | while futures: 264 | for future in as_completed(f[1] for f in futures): 265 | task_id = next(task_id for task_id, f in futures if f == future) 266 | future.result() 267 | try: 268 | progress.remove_task(task_id) 269 | except: 270 | pass 271 | futures = [f for f in futures if f[1] != future] 272 | 273 | try: 274 | mindex, chapter, lindex, lecture = next(task_generator) 275 | folder_path = os.path.join(COURSE_DIR, f"{mindex}. {sanitize_filename(chapter['title'])}") 276 | temp_folder_path = os.path.join(folder_path, str(lecture['id'])) 277 | self.create_directory(temp_folder_path) 278 | lect_info = self.fetch_lecture_info(course_id, lecture['id']) 279 | 280 | task_id = progress.add_task( 281 | f"Downloading Lecture: {lecture['title']} ({lindex}/{len(chapter['children'])})", 282 | total=100 283 | ) 284 | tasks[task_id] = (lecture, lect_info, temp_folder_path, lindex, folder_path) 285 | 286 | future = executor.submit( 287 | self.download_lecture, course_id, lecture, lect_info, temp_folder_path, lindex, folder_path, task_id, progress 288 | ) 289 | 290 | futures.append((task_id, future)) 291 | except StopIteration: 292 | break 293 | 294 | def check_prerequisites(): 295 | if not cookie_path: 296 | if not os.path.isfile(os.path.join(HOME_DIR, "cookies.txt")): 297 | logger.error(f"Please provide a valid cookie file using the '--cookie' option.") 298 | return False 299 | else: 300 | if not os.path.isfile(cookie_path): 301 | logger.error(f"The provided cookie file path does not exist.") 302 | return False 303 | 304 | try: 305 | subprocess.run(["ffmpeg", "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 306 | except: 307 | logger.error("ffmpeg is not installed or not found in the system PATH.") 308 | return False 309 | 310 | try: 311 | subprocess.run(["n_m3u8dl-re", "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 312 | except: 313 | logger.error("Make sure mp4decrypt & n_m3u8dl-re is not installed or not found in the system PATH.") 314 | return False 315 | 316 | return True 317 | 318 | def main(): 319 | 320 | try: 321 | global course_url, key, cookie_path, COURSE_DIR, captions, max_concurrent_lectures, skip_captions, skip_assets, skip_lectures, skip_articles, skip_assignments, convert_to_srt, start_chapter, end_chapter, start_lecture, end_lecture 322 | 323 | parser = argparse.ArgumentParser(description="Udemy Course Downloader") 324 | parser.add_argument("--id", "-i", type=int, required=False, help="The ID of the Udemy course to download") 325 | parser.add_argument("--url", "-u", type=str, required=False, help="The URL of the Udemy course to download") 326 | parser.add_argument("--key", "-k", type=str, help="Key to decrypt the DRM-protected videos") 327 | parser.add_argument("--cookies", "-c", type=str, default="cookies.txt", help="Path to cookies.txt file") 328 | parser.add_argument("--load", "-l", help="Load course curriculum from file", action=LoadAction, const=True, nargs='?') 329 | parser.add_argument("--save", "-s", help="Save course curriculum to a file", action=LoadAction, const=True, nargs='?') 330 | parser.add_argument("--concurrent", "-cn", type=int, default=4, help="Maximum number of concurrent downloads") 331 | 332 | # parser.add_argument("--quality", "-q", type=str, help="Specify the quality of the videos to download.") 333 | parser.add_argument("--start-chapter", type=int, help="Start the download from the specified chapter") 334 | parser.add_argument("--start-lecture", type=int, help="Start the download from the specified lecture") 335 | parser.add_argument("--end-chapter", type=int, help="End the download at the specified chapter") 336 | parser.add_argument("--end-lecture", type=int, help="End the download at the specified lecture") 337 | parser.add_argument("--captions", type=str, help="Specify what captions to download. Separate multiple captions with commas") 338 | parser.add_argument("--srt", help="Convert the captions to srt format", action=LoadAction, const=True, nargs='?') 339 | 340 | parser.add_argument("--tree", help="Create a tree view of the course curriculum", action=LoadAction, nargs='?') 341 | 342 | parser.add_argument("--skip-captions", type=bool, default=False, help="Skip downloading captions", action=LoadAction, nargs='?') 343 | parser.add_argument("--skip-assets", type=bool, default=False, help="Skip downloading assets", action=LoadAction, nargs='?') 344 | parser.add_argument("--skip-lectures", type=bool, default=False, help="Skip downloading lectures", action=LoadAction, nargs='?') 345 | parser.add_argument("--skip-articles", type=bool, default=False, help="Skip downloading articles", action=LoadAction, nargs='?') 346 | parser.add_argument("--skip-assignments", type=bool, default=False, help="Skip downloading assignments", action=LoadAction, nargs='?') 347 | 348 | args = parser.parse_args() 349 | 350 | if len(sys.argv) == 1: 351 | print(parser.format_help()) 352 | sys.exit(0) 353 | course_url = args.url 354 | 355 | key = args.key 356 | 357 | if args.concurrent > 25: 358 | logger.warning("The maximum number of concurrent downloads is 25. The provided number of concurrent downloads will be capped to 25.") 359 | max_concurrent_lectures = 25 360 | elif args.concurrent < 1: 361 | logger.warning("The minimum number of concurrent downloads is 1. The provided number of concurrent downloads will be capped to 1.") 362 | max_concurrent_lectures = 1 363 | else: 364 | max_concurrent_lectures = args.concurrent 365 | 366 | if not course_url and not args.id: 367 | logger.error("You must provide either the course ID with '--id' or the course URL with '--url' to proceed.") 368 | return 369 | elif course_url and args.id: 370 | logger.warning("Both course ID and URL provided. Prioritizing course ID over URL.") 371 | 372 | if key is not None and not ":" in key: 373 | logger.error("The provided Widevine key is either malformed or incorrect. Please check the key and try again.") 374 | return 375 | 376 | if args.cookies: 377 | cookie_path = args.cookies 378 | 379 | if not check_prerequisites(): 380 | return 381 | 382 | udemy = Udemy() 383 | 384 | if args.id: 385 | course_id = args.id 386 | else: 387 | course_id = udemy.extract_course_id(course_url) 388 | 389 | if args.captions: 390 | try: 391 | captions = args.captions.split(",") 392 | except: 393 | logger.error("Invalid captions provided. Captions should be separated by commas.") 394 | else: 395 | captions = ["en_US"] 396 | 397 | skip_captions = args.skip_captions 398 | skip_assets = args.skip_assets 399 | skip_lectures = args.skip_lectures 400 | skip_articles = args.skip_articles 401 | skip_assignments = args.skip_assignments 402 | 403 | course_info = udemy.fetch_course(course_id) 404 | COURSE_DIR = os.path.join(DOWNLOAD_DIR, remove_emojis_and_binary(sanitize_filename(course_info['title']))) 405 | 406 | logger.info(f"Course Title: {course_info['title']}") 407 | 408 | udemy.create_directory(os.path.join(COURSE_DIR)) 409 | 410 | if args.load: 411 | if args.load is True and os.path.isfile(os.path.join(HOME_DIR, "course.json")): 412 | try: 413 | course_curriculum = json.load(open(os.path.join(HOME_DIR, "course.json"), "r")) 414 | logger.info(f"The course curriculum is successfully loaded from course.json") 415 | except json.JSONDecodeError: 416 | logger.error("The course curriculum file provided is either malformed or corrupted.") 417 | sys.exit(1) 418 | elif args.load: 419 | if os.path.isfile(args.load): 420 | try: 421 | course_curriculum = json.load(open(args.load, "r")) 422 | logger.info(f"The course curriculum is successfully loaded from {args.load}") 423 | except json.JSONDecodeError: 424 | logger.error("The course curriculum file provided is either malformed or corrupted.") 425 | sys.exit(1) 426 | else: 427 | logger.error("The course curriculum file could not be located. Please verify the file path and ensure that the file exists.") 428 | sys.exit(1) 429 | else: 430 | logger.error("Please provide the path to the course curriculum file.") 431 | sys.exit(1) 432 | else: 433 | try: 434 | course_curriculum = udemy.fetch_course_curriculum(course_id) 435 | except Exception as e: 436 | logger.critical(f"Unable to retrieve the course curriculum. {e}") 437 | sys.exit(1) 438 | 439 | if args.save: 440 | if args.save is True: 441 | if (os.path.isfile(os.path.join(HOME_DIR, "course.json"))): 442 | logger.warning("Course curriculum file already exists. Overwriting the existing file.") 443 | with open(os.path.join(HOME_DIR, "course.json"), "w") as f: 444 | json.dump(course_curriculum, f, indent=4) 445 | logger.info(f"The course curriculum has been successfully saved to course.json") 446 | elif args.save: 447 | if (os.path.isfile(args.save)): 448 | logger.warning("Course curriculum file already exists. Overwriting the existing file.") 449 | with open(args.save, "w") as f: 450 | json.dump(course_curriculum, f, indent=4) 451 | logger.info(f"The course curriculum has been successfully saved to {args.save}") 452 | 453 | if args.tree: 454 | root_tree = Tree(course_info['title'], style="green") 455 | udemy.build_curriculum_tree(course_curriculum, root_tree) 456 | rprint(root_tree) 457 | if args.tree is True: 458 | pass 459 | elif args.tree: 460 | if (os.path.isfile(args.tree)): 461 | logger.warning("Course Curriculum Tree file already exists. Overwriting the existing file.") 462 | with open(args.tree, "w") as f: 463 | rprint(root_tree, file=f) 464 | logger.info(f"The course curriculum tree has been successfully saved to {args.tree}") 465 | 466 | if args.srt: 467 | convert_to_srt = True 468 | else: 469 | convert_to_srt = False 470 | 471 | if args.start_lecture: 472 | if args.start_chapter: 473 | start_chapter = args.start_chapter 474 | start_lecture = args.start_lecture 475 | else: 476 | logger.error("When using --start-lecture please provide --start-chapter") 477 | sys.exit(1) 478 | elif args.start_chapter: 479 | start_chapter = args.start_chapter 480 | start_lecture = 0 481 | else: 482 | start_chapter = 0 483 | start_lecture = 0 484 | 485 | if args.end_lecture: 486 | if args.end_chapter: 487 | end_chapter = args.end_chapter 488 | end_lecture = args.end_lecture 489 | elif args.end_chapter: 490 | logger.error("When using --end-lecture please provide --end-chapter") 491 | sys.exit(1) 492 | elif args.end_chapter: 493 | end_chapter = args.end_chapter 494 | end_lecture = 1000 495 | else: 496 | end_chapter = len(course_curriculum) 497 | end_lecture = 1000 498 | 499 | logger.info("The course download is starting. Please wait while the materials are being downloaded.") 500 | 501 | start_time = time.time() 502 | udemy.download_course(course_id, course_curriculum) 503 | end_time = time.time() 504 | 505 | elapsed_time = end_time - start_time 506 | 507 | logger.info(f"Download finished in {format_time(elapsed_time)}") 508 | 509 | logger.info("All course materials have been successfully downloaded.") 510 | logger.info("Download Complete.") 511 | except KeyboardInterrupt: 512 | logger.warning("Process interrupted. Exiting") 513 | sys.exit(1) 514 | 515 | if __name__ == "__main__": 516 | main() --------------------------------------------------------------------------------