├── LICENSE ├── README.md └── gcpd.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 snhplayer 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GetCoursePythonDownloader 2 | 3 | Этот скрипт предназначен для загрузки видео с платформы GetCourse и основан на [этом скрипте](https://github.com/mikhailnov/getcourse-video-downloader). Он загружает сегменты видео, объединяет их и конвертирует в формат MP4. 4 | 5 | ## Особенности 6 | 7 | - Асинхронная загрузка сегментов видео 8 | - Прогресс-бары для отслеживания загрузки 9 | - Автоматическое объединение сегментов 10 | - Конвертация в MP4 с использованием FFmpeg 11 | - Поддержка повторных попыток при ошибках загрузки и конвертации 12 | 13 | ## Требования 14 | 15 | - Python 3.7+ 16 | - FFmpeg 17 | - Библиотеки Python: aiohttp, tqdm 18 | 19 | ## Установка 20 | 21 | 1. Клонируйте репозиторий: 22 | ``` 23 | git clone https://github.com/snhplayer/GetCoursePythonDownloader.git 24 | cd GetCoursePythonDownloader 25 | ``` 26 | 27 | 2. Установите необходимые библиотеки: 28 | ``` 29 | pip install aiohttp tqdm 30 | ``` 31 | 32 | 3. Убедитесь, что FFmpeg установлен и доступен в системном PATH или находится в одной папке со скриптом. 33 | 34 | ## Установка FFmpeg 35 | 36 | Скрипту нужен установленный FFmpeg (должен быть доступен в `PATH` или лежать рядом с `gcpd.py`). Проверить установку можно командой `ffmpeg -version`. 37 | 38 | ### Windows 39 | 40 | Самый простой путь — через пакетный менеджер **winget** (Windows 10/11): 41 | 42 | ```powershell 43 | winget install --id Gyan.FFmpeg -e 44 | ``` 45 | 46 | Альтернативы: 47 | 48 | * **Chocolatey**: 49 | 50 | ```powershell 51 | choco install ffmpeg 52 | ``` 53 | * **Scoop**: 54 | 55 | ```powershell 56 | scoop install ffmpeg 57 | ``` 58 | * Ручная установка (zip-архив): скачайте сборку FFmpeg для Windows со страницы загрузок FFmpeg (раздел *Windows builds*), распакуйте и добавьте папку `bin` в переменную окружения `PATH`. 59 | 60 | Примечание: `winget` пакет **Gyan.FFmpeg** ставит официально рекомендуемую сборку; подробнее см. карточку пакета. 61 | Chocolatey и Scoop также предоставляют готовые бинарники. 62 | 63 | ### Linux 64 | 65 | #### Ubuntu / Debian 66 | 67 | ```bash 68 | sudo apt update 69 | sudo apt install ffmpeg 70 | ffmpeg -version 71 | ``` 72 | 73 | FFmpeg доступен в официальных репозиториях Ubuntu/Debian (подробности на Launchpad/Debian Packages). 74 | 75 | > Альтернатива: Snap-пакет `ffmpeg` (удобно, но может отличаться по набору кодеков и изоляции): 76 | > `sudo snap install ffmpeg` 77 | 78 | #### Fedora 79 | 80 | На Fedora «полная» сборка FFmpeg ставится из репозиториев **RPM Fusion**: 81 | 82 | 1) Подключить RPM Fusion (Free и Nonfree) 83 | ```bash 84 | sudo dnf install \ 85 | https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm \ 86 | https://download1.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm 87 | ``` 88 | 2) Установить FFmpeg 89 | ```bash 90 | sudo dnf install ffmpeg 91 | 92 | ffmpeg -version 93 | ``` 94 | 95 | Подробные инструкции по включению RPM Fusion есть в документации Fedora и на сайте RPM Fusion. Учтите, что в официальном репозитории Fedora есть урезанный пакет `ffmpeg-free`; для максимальной совместимости обычно используют пакет `ffmpeg` из RPM Fusion. 96 | 97 | #### Arch Linux 98 | 99 | ```bash 100 | sudo pacman -S ffmpeg 101 | ffmpeg -version 102 | ``` 103 | 104 | Пакет доступен в официальном репозитории (`extra`), см. Arch Wiki и карточку пакета. 105 | 106 | ### macOS 107 | 108 | Рекомендуемый способ — **Homebrew**: 109 | 110 | ```bash 111 | # если brew ещё не установлен: https://brew.sh 112 | brew install ffmpeg 113 | ffmpeg -version 114 | ``` 115 | 116 | Альтернативы: 117 | 118 | * **MacPorts**: 119 | 120 | ```bash 121 | sudo port install ffmpeg 122 | ``` 123 | 124 | * Статические сборки для macOS доступны со страницы загрузок FFmpeg (раздел *macOS*). 125 | 126 | --- 127 | 128 | ### Проверка установки 129 | 130 | После установки выполните: 131 | 132 | ```bash 133 | ffmpeg -version 134 | ffprobe -version 135 | ``` 136 | 137 | Если команды не находятся, убедитесь, что путь к каталогу с бинарниками (`ffmpeg`, `ffprobe`) добавлен в `PATH`. Ссылки на официальные способы получения готовых сборок (Windows/macOS/Linux) приведены на странице загрузок FFmpeg. 138 | 139 | ## Использование 140 | 141 | Запустите скрипт: 142 | 143 | ``` 144 | python gcpd.py 145 | ``` 146 | 147 | Следуйте инструкциям в командной строке: 148 | 149 | 1. Введите ссылку на плейлист. 150 | 2. Укажите имя выходного файла. 151 | 152 | Дополнительные опции: 153 | 154 | - `--pd`: Включить предварительную загрузку размеров файлов (по умолчанию отключена). 155 | 156 | Пример: 157 | ``` 158 | python gcpd.py --pd {url} 159 | ``` 160 | 161 | - Возможность определить количество параллельных потоков 162 | 163 | Пример: 164 | Меняя 165 | ``` 166 | MAX_PARALLEL_DOWNLOADS = 4 167 | ``` 168 | на 169 | ``` 170 | MAX_PARALLEL_DOWNLOADS = 5 171 | ``` 172 | Мы, соотвественно, меняем количество параллельных потоков закгрузки с 4 на 5. 173 | 174 | - `-f`: Указать файл где находятся ссылки плей-листов и имена выходных файлов 175 | 176 | Пример: 177 | ``` 178 | python gcpd.py -f a.txt 179 | ``` 180 | a.txt 181 | ``` 182 | https://.... 183 | foo 184 | https://.... 185 | foo2 186 | https://.... 187 | foo3 188 | ``` 189 | 190 | ## Решение проблем 191 | 192 | Если возникают проблемы с загрузкой или конвертацией, скрипт автоматически попытается повторить операцию. Если проблема сохраняется, проверьте: 193 | 194 | 1. Правильность ссылки на плейлист. 195 | 2. Наличие доступа к интернету. 196 | 3. Корректность установки FFmpeg. 197 | 4. Свободное место на диске. 198 | 199 | ## Вклад в проект 200 | 201 | Если вы обнаружили ошибку или у вас есть предложения по улучшению, пожалуйста, создайте issue или pull request в репозитории проекта. 202 | 203 | -------------------------------------------------------------------------------- /gcpd.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import tempfile 5 | import aiohttp 6 | import asyncio 7 | from tqdm import tqdm 8 | import subprocess 9 | import argparse 10 | import time 11 | 12 | MAX_PARALLEL_DOWNLOADS = 4 # Максимальное кол-во параллельных загрузок сегментов 13 | 14 | async def download_file(session, url, destination, progress_bar): 15 | async with session.get(url) as response: 16 | response.raise_for_status() 17 | total_size = int(response.headers.get('content-length', 0)) 18 | with open(destination, 'wb') as file: 19 | downloaded = 0 20 | async for chunk in response.content.iter_chunked(64*1024): 21 | file.write(chunk) 22 | downloaded += len(chunk) 23 | progress_bar.update(len(chunk)) 24 | 25 | async def download_segment(session, ts_url, tmpdir, idx, overall_progress, semaphore, count_segments=False): 26 | async with semaphore: 27 | ts_file = os.path.join(tmpdir, f'{idx:05}.ts') 28 | retry_count = 3 29 | for _ in range(retry_count): 30 | try: 31 | async with session.get(ts_url) as response: 32 | response.raise_for_status() 33 | total_size = int(response.headers.get('content-length', 0)) 34 | with tqdm(total=total_size, desc=f"Сегмент {idx+1}", unit="B", unit_scale=True, leave=False) as pbar: 35 | with open(ts_file, 'wb') as file: 36 | async for chunk in response.content.iter_chunked(64*1024): 37 | file.write(chunk) 38 | pbar.update(len(chunk)) 39 | if not count_segments: 40 | overall_progress.update(len(chunk)) 41 | if count_segments: 42 | overall_progress.update(1) 43 | return ts_file 44 | except aiohttp.ClientError: 45 | if _ == retry_count - 1: 46 | raise 47 | await asyncio.sleep(1) 48 | 49 | async def get_total_size(session, urls): 50 | total_size = 0 51 | async with session.head(urls[0]) as response: 52 | size = int(response.headers.get('content-length', 0)) 53 | if size == 0: 54 | return None 55 | for url in tqdm(urls, desc="Получение размеров файлов", unit="file"): 56 | async with session.head(url) as response: 57 | total_size += int(response.headers.get('content-length', 0)) 58 | return total_size 59 | 60 | def convert_to_mp4(result_file, max_retries=3): 61 | mp4_file = result_file + '.mp4' 62 | retry_count = 0 63 | 64 | while retry_count < max_retries: 65 | print(f"Попытка конвертации в MP4 ({retry_count + 1}/{max_retries})...") 66 | try: 67 | process = subprocess.Popen( 68 | ['ffmpeg', '-i', result_file, '-c', 'copy', mp4_file], 69 | stdout=subprocess.PIPE, 70 | stderr=subprocess.PIPE, 71 | universal_newlines=False 72 | ) 73 | 74 | while True: 75 | output = process.stderr.readline() 76 | if output == b'' and process.poll() is not None: 77 | break 78 | if output: 79 | try: 80 | line = output.decode('utf-8').strip() 81 | except UnicodeDecodeError: 82 | line = output.decode('utf-8', errors='replace').strip() 83 | if "Duration" in line or "time=" in line: 84 | print(line) 85 | 86 | if process.returncode == 0: 87 | print(f"Конвертация завершена. Результат здесь:\n{mp4_file}") 88 | os.remove(result_file) 89 | print(f"Файл {result_file} удалён.") 90 | return True 91 | else: 92 | error_output = process.stderr.read() 93 | try: 94 | error_output = error_output.decode('utf-8') 95 | except UnicodeDecodeError: 96 | error_output = error_output.decode('utf-8', errors='replace') 97 | print(f"Ошибка при конвертации файла: {error_output}") 98 | 99 | if os.path.exists(mp4_file): 100 | os.remove(mp4_file) 101 | print(f"Неполный файл {mp4_file} удалён.") 102 | 103 | retry_count += 1 104 | if retry_count < max_retries: 105 | print(f"Повторная попытка через 5 секунд...") 106 | time.sleep(5) 107 | else: 108 | print("Достигнуто максимальное количество попыток. Конвертация не удалась.") 109 | return False 110 | 111 | except Exception as e: 112 | print(f"Произошла ошибка: {str(e)}") 113 | if os.path.exists(mp4_file): 114 | os.remove(mp4_file) 115 | print(f"Неполный файл {mp4_file} удалён.") 116 | 117 | retry_count += 1 118 | if retry_count < max_retries: 119 | print(f"Повторная попытка через 5 секунд...") 120 | time.sleep(5) 121 | else: 122 | print("Достигнуто максимальное количество попыток. Конвертация не удалась.") 123 | return False 124 | 125 | return False 126 | 127 | async def main(url, result_file, no_pre_download): 128 | async with aiohttp.ClientSession() as session: 129 | with tempfile.TemporaryDirectory() as tmpdir: 130 | main_playlist = os.path.join(tmpdir, 'main_playlist.m3u8') 131 | 132 | print("Загрузка основного плейлиста...") 133 | with tqdm(total=None, desc="Основной плейлист", unit="B", unit_scale=True) as pbar: 134 | await download_file(session, url, main_playlist, pbar) 135 | 136 | with open(main_playlist, 'r', encoding='utf-8') as f: 137 | main_playlist_content = f.read() 138 | 139 | ts_or_bin_pattern = re.compile(r'^https?://.*\.(ts|bin)', re.MULTILINE) 140 | second_playlist = os.path.join(tmpdir, 'second_playlist.m3u8') 141 | 142 | if ts_or_bin_pattern.search(main_playlist_content): 143 | with open(second_playlist, 'w', encoding='utf-8') as f: 144 | f.write(main_playlist_content) 145 | else: 146 | tail = main_playlist_content.strip().split('\n')[-1] 147 | if not re.match(r'^https?://', tail): 148 | print("В содержимом заданной ссылки нет прямых ссылок на файлы *.bin (*.ts) (первый вариант),") 149 | print("также последняя строка в ней не содержит ссылки на другой плей-лист (второй вариант).") 150 | print("Либо указана неправильная ссылка, либо GetCourse изменил алгоритмы.") 151 | print("Если уверены, что дело в изменившихся алгоритмах GetCourse, опишите проблему здесь:") 152 | print("https://github.com/mikhailnov/getcourse-video-downloader/issues (на русском).") 153 | print("Если уверены, что это ошибка скрипта, то опишите проблему здесь:") 154 | print("https://github.com/snhplayer/GetCoursePythonDownloader/issues") 155 | return 156 | 157 | print("Загрузка вторичного плейлиста...") 158 | with tqdm(total=None, desc="Вторичный плейлист", unit="B", unit_scale=True) as pbar: 159 | await download_file(session, tail, second_playlist, pbar) 160 | 161 | with open(second_playlist, 'r', encoding='utf-8') as f: 162 | lines = f.readlines() 163 | ts_urls = [line.strip() for line in lines if re.match(r'^https?://', line.strip())] 164 | 165 | print(f"Число сегментов для загрузки: {len(ts_urls)}") 166 | 167 | total_size = None 168 | if not no_pre_download: 169 | total_size = await get_total_size(session, ts_urls) 170 | 171 | semaphore = asyncio.Semaphore(MAX_PARALLEL_DOWNLOADS) 172 | 173 | if no_pre_download: 174 | overall_pbar = tqdm(total=len(ts_urls), desc="Общий прогресс", unit="сегмент") 175 | else: 176 | overall_pbar = tqdm(total=total_size, desc="Общий прогресс", unit="B", unit_scale=True) 177 | 178 | tasks = [download_segment(session, ts_url, tmpdir, idx, overall_pbar, semaphore, count_segments=no_pre_download) 179 | for idx, ts_url in enumerate(ts_urls)] 180 | ts_files = [] 181 | for task in asyncio.as_completed(tasks): 182 | ts_file = await task 183 | ts_files.append(ts_file) 184 | 185 | overall_pbar.close() 186 | 187 | print("Объединение сегментов...") 188 | with open(result_file, 'wb') as result: 189 | for ts_file in tqdm(sorted(ts_files), desc="Объединение", unit="file"): 190 | with open(ts_file, 'rb') as ts: 191 | result.write(ts.read()) 192 | 193 | print(f"Скачивание завершено. Результат здесь:\n{result_file}") 194 | 195 | if convert_to_mp4(result_file): 196 | print("Конвертация успешно завершена.") 197 | else: 198 | print("Не удалось выполнить конвертацию после нескольких попыток.") 199 | 200 | if __name__ == '__main__': 201 | parser = argparse.ArgumentParser(description='Download and process video segments.') 202 | parser.add_argument('--pd', action='store_false', dest='no_pre_download', 203 | help='Включить предварительную загрузку размеров (по умолчанию отключено)') 204 | parser.add_argument('-f', type=str, dest='file', 205 | help='Указать файл где находятся ссылки плей-листов и имена выходных файлов', default=False) 206 | args = parser.parse_args() 207 | 208 | if args.file != False: 209 | url = "" 210 | result_file = "" 211 | if not os.path.exists(args.file): 212 | print("Файл для скачивания не существует") 213 | exit(-1) 214 | for i in open(args.file, encoding="utf-8"): 215 | if url != "": 216 | result_file = i.strip() 217 | print("Скачивание плей-листа: ", url) 218 | print("В файл: ", result_file) 219 | asyncio.run(main(url, result_file, args.no_pre_download)) 220 | 221 | url = "" 222 | result_file = "" 223 | else: 224 | url = i.strip() 225 | else: 226 | while True: 227 | url = input("Введите ссылку на плей-лист: ") 228 | result_file = input("Введите имя выходного файла: ") 229 | asyncio.run(main(url, result_file, args.no_pre_download)) --------------------------------------------------------------------------------