├── 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()
--------------------------------------------------------------------------------