├── images ├── img1.png ├── img2.png ├── preview.gif ├── downloading.gif ├── placeholder.png ├── icon_bilibili.ico └── icon_bilibili.png ├── README.md ├── LICENSE ├── .gitignore ├── app.css └── app.py /images/img1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taseikyo/bili-favorite-downloader/HEAD/images/img1.png -------------------------------------------------------------------------------- /images/img2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taseikyo/bili-favorite-downloader/HEAD/images/img2.png -------------------------------------------------------------------------------- /images/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taseikyo/bili-favorite-downloader/HEAD/images/preview.gif -------------------------------------------------------------------------------- /images/downloading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taseikyo/bili-favorite-downloader/HEAD/images/downloading.gif -------------------------------------------------------------------------------- /images/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taseikyo/bili-favorite-downloader/HEAD/images/placeholder.png -------------------------------------------------------------------------------- /images/icon_bilibili.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taseikyo/bili-favorite-downloader/HEAD/images/icon_bilibili.ico -------------------------------------------------------------------------------- /images/icon_bilibili.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taseikyo/bili-favorite-downloader/HEAD/images/icon_bilibili.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bilibili 收藏夹下载器 2 | 3 | 基于 [annie](https://github.com/iawia002/annie) 和 PyQt6 实现批量下载 Bilibili 指定收藏夹下的视频 4 | 5 | 界面~~参考~~(抄) [amarjeetmalpotra/yt-downloader](https://github.com/amarjeetmalpotra/yt-downloader) 的代码 6 | 7 | ## Preview 8 | 9 | | 描述 | 图片 | 10 | |----------|-------------------------| 11 | | 初始界面 | ![](images/img1.png) | 12 | | 搜索过程 | ![](images/preview.gif) | 13 | | 搜索结果 | ![](images/img2.png) | 14 | | 下载过程 | ![](images/downloading.gif) | 15 | 16 | ## Installation 17 | 18 | ```Bash 19 | git clone https://github.com/taseikyo/bili-favorite-downloader.git 20 | 21 | # install PyQt6 22 | pip install pyqt6 23 | 24 | # install annie & ffmpeg 25 | scoop install annie ffmpeg 26 | 27 | # run the app 28 | cd bili-favorite-downloader 29 | python app.py 30 | ``` 31 | 32 | 默认是多线程同时下载所有视频,可以修改为单线程下载(即下完一个再下第二个) 33 | 34 | 具体操作为将 `DownloadThread` 类的 `is_multithreads`([150 行左右](https://github.com/taseikyo/bili-favorite-downloader/blob/master/app.py#L150))改为 `False` 即可 35 | 36 | 还没有进行充分的测试,不知道会不会有什么问题() 37 | 38 | ## License 39 | 40 | Copyright (c) 2021 Lewis Tian. Licensed under the MIT license. 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Lewis Tian 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /app.css: -------------------------------------------------------------------------------- 1 | * { 2 | background-color: #fff; 3 | font-family: 'Microsoft Yahei'; 4 | } 5 | QWidget { 6 | font-size: 15px; 7 | border-radius: 4px; 8 | } 9 | QToolTip { 10 | padding: 4px; 11 | border: 1px solid #bababa; 12 | } 13 | QStatusBar { 14 | font-size: 13px; 15 | } 16 | QStatusBar QPushButton { 17 | background-color: none; 18 | font-family: 'Segoe UI Symbol'; 19 | padding: 0 40px; 20 | color: #333; 21 | } 22 | QStatusBar QPushButton:hover { 23 | background-color: none; 24 | color: #ff80ab; 25 | } 26 | QLineEdit { 27 | padding: 4px 10px; 28 | margin-right: 10px; 29 | border: 2px solid #bababa; 30 | font-size: 16px; 31 | font-family: 'Segoe UI Symbol'; 32 | selection-background-color: #ff80ab; 33 | } 34 | QLineEdit:hover { 35 | border-color: #808080; 36 | } 37 | QLineEdit:focus { 38 | border-color: #ff80ab; 39 | } 40 | QMenu { 41 | border: 1px solid #bababa; 42 | padding: 5px; 43 | } 44 | QMenu::item { 45 | padding: 3px 25px; 46 | border-radius: 4px; 47 | } 48 | QMenu::item:selected { 49 | color: #fff; 50 | background-color: #ff80ab; 51 | } 52 | QPushButton { 53 | width: 125px; 54 | padding: 7px 0; 55 | color: #fff; 56 | border: none; 57 | background-color: #ff80ab; 58 | } 59 | QPushButton:hover, QComboBox:hover { 60 | background-color: #ff70a6; 61 | } 62 | QPushButton:pressed, QComboBox:pressed { 63 | background-color: #fd649f; 64 | } 65 | QPushButton:disabled, QComboBox:disabled { 66 | background-color: #ffe0ea; 67 | } 68 | QComboBox { 69 | padding: 5.5px 30px 5.5px 45px; 70 | color: #fff; 71 | border: none; 72 | background-color: #ff80ab; 73 | } 74 | QComboBox::drop-down { 75 | border-radius: 0; 76 | } 77 | QComboBox:on { 78 | border-bottom-left-radius: 0; 79 | border-bottom-right-radius: 0; 80 | } 81 | QComboBox QAbstractItemView { 82 | border-radius: 0; 83 | outline: 0; 84 | } 85 | QComboBox QAbstractItemView::item { 86 | height: 33px; 87 | padding-left: 42px; 88 | background-color: #fff; 89 | } 90 | QComboBox QAbstractItemView::item:selected { 91 | background-color: #ff80ab; 92 | } 93 | QProgressBar { 94 | text-align: center; 95 | } 96 | QProgressBar::chunk { 97 | background: #ff80ab; 98 | border-radius: 4px; 99 | } 100 | QMessageBox QLabel { 101 | font-size: 13px; 102 | } 103 | QMessageBox QPushButton { 104 | width: 50px; 105 | padding: 6px 25px; 106 | } 107 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | # @Date : 2021-05-12 20:00:29 4 | # @Author : Lewis Tian (taseikyo@gmail.com) 5 | # @Link : github.com/taseikyo 6 | 7 | import io 8 | import json 9 | import os 10 | import subprocess 11 | import sys 12 | import time 13 | from collections import namedtuple 14 | from urllib.request import urlopen 15 | 16 | from PyQt6.QtCore import QDir, QMutex, Qt, QThread, pyqtSignal 17 | from PyQt6.QtGui import QCursor, QIcon, QPixmap 18 | from PyQt6.QtWidgets import ( 19 | QApplication, 20 | QFileDialog, 21 | QHBoxLayout, 22 | QLabel, 23 | QLineEdit, 24 | QMessageBox, 25 | QProgressBar, 26 | QPushButton, 27 | QStatusBar, 28 | QVBoxLayout, 29 | QWidget, 30 | ) 31 | 32 | WorkerRespnose = namedtuple( 33 | "WorkerRespnose", "thumb_img title author medias media_counts publish_date" 34 | ) 35 | 36 | DownloadCountsMutex = QMutex() 37 | 38 | 39 | # seperate worker thread for background processing and to avoid UI freez 40 | class WorkerThread(QThread): 41 | # setup response signal 42 | worker_response = pyqtSignal(WorkerRespnose) 43 | # setup error signal 44 | worker_err_response = pyqtSignal() 45 | # additional parameter as url 46 | 47 | def __init__(self, media_id): 48 | # invoke the __init__ of super as well 49 | super(WorkerThread, self).__init__() 50 | self.media_id = media_id 51 | 52 | def run(self): 53 | url = ( 54 | f"https://api.bilibili.com/medialist/gateway/base/spaceDetail?media_id={self.media_id}&" 55 | "pn=1&ps=20&keyword=&order=mtime&type=0&tid=0&jsonp=jsonp" 56 | ) 57 | try: 58 | data = json.loads(urlopen(url).read()) 59 | # load thumbnail image 60 | pixmap = QPixmap() 61 | pixmap.loadFromData(urlopen(str(data["data"]["info"]["cover"])).read()) 62 | # emitting the response signal 63 | # 64 | self.worker_response.emit( 65 | WorkerRespnose( 66 | pixmap, 67 | data["data"]["info"]["title"], 68 | data["data"]["info"]["upper"]["name"], 69 | data["data"]["medias"], 70 | data["data"]["info"]["media_count"], 71 | data["data"]["info"]["ctime"], 72 | ) 73 | ) 74 | except Exception as e: 75 | print(e) 76 | # emitting the error signal 77 | self.worker_err_response.emit() 78 | 79 | 80 | class _BufferedReaderForFFmpeg(io.BufferedReader): 81 | """Method `newline` overriden to *also* treat `\\r` as a line break.""" 82 | 83 | def readline(self, size=-1): 84 | if hasattr(self, "peek"): 85 | 86 | def nreadahead(): 87 | readahead = self.peek(1) 88 | if not readahead: 89 | return 1 90 | n = ( 91 | (readahead.find(b"\r") + 1) 92 | or (readahead.find(b"\n") + 1) 93 | or len(readahead) 94 | ) 95 | if size >= 0: 96 | n = min(n, size) 97 | return n 98 | 99 | else: 100 | 101 | def nreadahead(): 102 | return 1 103 | 104 | if size is None: 105 | size = -1 106 | else: 107 | try: 108 | size_index = size.__index__ 109 | except AttributeError: 110 | raise TypeError(f"{size!r} is not an integer") 111 | else: 112 | size = size_index() 113 | res = bytearray() 114 | while size < 0 or len(res) < size: 115 | b = self.read(nreadahead()) 116 | if not b: 117 | break 118 | res += b 119 | if os.linesep == "\r\n": 120 | # Windows 121 | if res.endswith(b"\r"): 122 | if self.peek(1).startswith(b"\n"): 123 | # \r\n encountered 124 | res += self.read(1) 125 | break 126 | else: 127 | # Unix 128 | if res.endswith(b"\r") or res.endswith(b"\n"): 129 | break 130 | return bytes(res) 131 | 132 | 133 | # download thread 134 | class DownloadThread(QThread): 135 | # setup download respomse signal 136 | download_response = pyqtSignal(int) 137 | # setup download complete signal 138 | download_complete = pyqtSignal(str) 139 | # setup download error signal 140 | download_err = pyqtSignal() 141 | 142 | def __init__(self, media_id, media_counts, first_page_medias, output_path): 143 | super(DownloadThread, self).__init__() 144 | self.media_id = media_id 145 | self.media_counts = media_counts 146 | self.page_medias = first_page_medias 147 | self.output_path = output_path 148 | 149 | # set multithreads to False 150 | self.is_multithreads = True 151 | self.threads = {} 152 | 153 | def run(self): 154 | if self.is_multithreads: 155 | self.multi() 156 | else: 157 | self.single() 158 | 159 | def single(self): 160 | try: 161 | counts = 0 162 | page = 2 163 | while True: 164 | # download video using annie 165 | for media in self.page_medias: 166 | per_counts = 0 167 | cmd = f"annie -o {self.output_path} https://www.bilibili.com/video/{media['bvid']}" 168 | print(cmd) 169 | # os.system(cmd) 170 | self.process = subprocess.Popen( 171 | cmd, 172 | shell=True, 173 | stdout=subprocess.PIPE, 174 | stderr=subprocess.STDOUT, 175 | ) 176 | stdout = _BufferedReaderForFFmpeg(self.process.stdout.raw) 177 | while True: 178 | line = stdout.readline() 179 | if not line: 180 | break 181 | try: 182 | line = line.decode("utf-8") 183 | except UnicodeDecodeError: 184 | line = line.decode("gbk") 185 | print(line) 186 | try: 187 | per_counts = float(line.split("%")[0].split(" ")[-1]) 188 | except: 189 | pass 190 | self.download_response.emit( 191 | int((counts + per_counts / 100) / self.media_counts * 100) 192 | ) 193 | counts += 1 194 | 195 | if counts >= self.media_counts: 196 | break 197 | url = ( 198 | f"https://api.bilibili.com/medialist/gateway/base/spaceDetail?media_id={self.media_id}&" 199 | f"pn={page}&ps=20&keyword=&order=mtime&type=0&tid=0&jsonp=jsonp" 200 | ) 201 | page += 1 202 | data = json.loads(urlopen(url).read()) 203 | self.page_medias = data["data"]["medias"] 204 | except Exception as e: 205 | print(e) 206 | # emitting the error signal 207 | self.download_err.emit() 208 | 209 | self.download_complete.emit(self.output_path) 210 | 211 | def multi(self): 212 | """ 213 | 多线程下载就不显示细节进度条了 214 | 同步太麻烦了 215 | """ 216 | # videos counts 217 | self.download_counts = 0 218 | # progress counts 219 | self.cur_progress = 0 220 | 221 | try: 222 | counts = 0 223 | page = 2 224 | while True: 225 | # download video using annie 226 | for media in self.page_medias: 227 | bvid = media["bvid"] 228 | self.threads[bvid] = Annie(bvid, self.output_path) 229 | self.threads[bvid].annie_download_complete.connect( 230 | self.download_finished_slot 231 | ) 232 | self.threads[bvid].annie_download_err.connect( 233 | self.download_err.emit 234 | ) 235 | self.threads[bvid].start() 236 | counts += 1 237 | 238 | if counts >= self.media_counts: 239 | break 240 | url = ( 241 | f"https://api.bilibili.com/medialist/gateway/base/spaceDetail?media_id={self.media_id}&" 242 | f"pn={page}&ps=20&keyword=&order=mtime&type=0&tid=0&jsonp=jsonp" 243 | ) 244 | page += 1 245 | data = json.loads(urlopen(url).read()) 246 | self.page_medias = data["data"]["medias"] 247 | except Exception as e: 248 | print(e) 249 | # emitting the error signal 250 | self.download_err.emit() 251 | 252 | while self.download_counts != self.media_counts: 253 | pass 254 | self.download_complete.emit(self.output_path) 255 | 256 | def download_finished_slot(self, bvid): 257 | # Remove threads that have completed their tasks 258 | del self.threads[bvid] 259 | # lock 260 | DownloadCountsMutex.lock() 261 | self.download_counts += 1 262 | DownloadCountsMutex.unlock() 263 | 264 | self.download_response.emit(int(self.download_counts / self.media_counts * 100)) 265 | 266 | def terminate(self): 267 | super(DownloadThread, self).terminate() 268 | 269 | if self.is_multithreads: 270 | for t in self.threads.values(): 271 | subprocess.call(f"TASKKILL /F /PID {t.process.pid} /T") 272 | else: 273 | subprocess.call(f"TASKKILL /F /PID {self.process.pid} /T") 274 | 275 | 276 | # download video using annie (for multithreads) 277 | class Annie(QThread): 278 | annie_download_complete = pyqtSignal(str) 279 | annie_download_err = pyqtSignal() 280 | 281 | def __init__(self, bvid, output_path): 282 | super(Annie, self).__init__() 283 | self.bvid = bvid 284 | self.output_path = output_path 285 | self.process = None 286 | 287 | if not os.path.exists(output_path): 288 | os.mkdir(output_path) 289 | 290 | def run(self): 291 | cmd = f"annie -o {self.output_path} https://www.bilibili.com/video/{self.bvid}" 292 | print(cmd) 293 | try: 294 | self.process = subprocess.Popen( 295 | cmd, 296 | shell=True, 297 | stdout=subprocess.PIPE, 298 | stderr=subprocess.STDOUT, 299 | ) 300 | stdout = _BufferedReaderForFFmpeg(self.process.stdout.raw) 301 | while True: 302 | line = stdout.readline() 303 | if not line: 304 | break 305 | except: 306 | self.annie_download_err.emit() 307 | 308 | self.annie_download_complete.emit(self.bvid) 309 | 310 | 311 | class B23Download(QWidget): 312 | def __init__(self): 313 | super(B23Download, self).__init__() 314 | # setup some flags 315 | self.is_fetching = False 316 | self.is_downloading = False 317 | 318 | # default output path 319 | basepath = os.path.dirname(os.path.abspath(__file__)) 320 | path = os.path.join(basepath, "videos") 321 | self.output_path = path 322 | 323 | # setup some window specific things 324 | self.setWindowTitle("Bilibili Favorite Downloader") 325 | self.setWindowIcon(QIcon("images/icon_bilibili.ico")) 326 | self.setFixedSize(705, 343) 327 | 328 | # parent layout 329 | main_layout = QVBoxLayout() 330 | main_layout.setContentsMargins(15, 15, 15, 10) 331 | self.setLayout(main_layout) 332 | 333 | # top bar layout 334 | top_layout = QHBoxLayout() 335 | 336 | # detail section 337 | mid_main_layout = QHBoxLayout() 338 | mid_right_layout = QVBoxLayout() 339 | 340 | # download section 341 | bottom_main_layout = QHBoxLayout() 342 | bottom_right_layout = QVBoxLayout() 343 | 344 | # output path link button 345 | self.output_btn = QPushButton("📂 Output Path") 346 | self.output_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) 347 | self.output_btn.setToolTip(self.output_path) 348 | self.output_btn.clicked.connect(self.set_output_path) 349 | 350 | # status bar 351 | self.status_bar = QStatusBar() 352 | 353 | # message box 354 | self.message_box = QMessageBox() 355 | 356 | # setting up widgets 357 | self.url_edit = QLineEdit() 358 | self.url_edit.setPlaceholderText("🔍 Enter or paste favorite URL...") 359 | self.get_btn = QPushButton("Get") 360 | self.get_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) 361 | self.get_btn.clicked.connect(self.get_details) 362 | 363 | # thumbnail 364 | pixmap = QPixmap("images/placeholder.png") 365 | self.thumb = QLabel() 366 | self.thumb.setFixedSize(250, 141) 367 | self.thumb.setScaledContents(True) 368 | self.thumb.setPixmap(pixmap) 369 | 370 | # detail widgets 371 | self.title = QLabel("Title: ") 372 | self.author = QLabel("Author: ") 373 | self.length = QLabel("Videos: ") 374 | self.publish_date = QLabel("Published: ") 375 | 376 | # progress bar 377 | self.progress_bar = QProgressBar() 378 | 379 | # download options 380 | self.download_btn = QPushButton(" Download Videos ") 381 | self.download_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) 382 | self.download_btn.clicked.connect(self.get_content) 383 | self.download_btn.setEnabled(False) 384 | self.download_btn.setShortcut("Ctrl+Return") 385 | self.download_btn.setMinimumWidth(200) 386 | 387 | # add widgets and layouts 388 | top_layout.addWidget(self.url_edit) 389 | top_layout.addWidget(self.get_btn) 390 | 391 | # detail section 392 | mid_right_layout.addWidget(self.title) 393 | mid_right_layout.addWidget(self.author) 394 | mid_right_layout.addWidget(self.length) 395 | mid_right_layout.addWidget(self.publish_date) 396 | mid_main_layout.addWidget(self.thumb) 397 | mid_main_layout.addSpacing(20) 398 | mid_main_layout.addLayout(mid_right_layout) 399 | 400 | # download section 401 | bottom_right_layout.addWidget(self.download_btn) 402 | bottom_main_layout.addWidget(self.progress_bar) 403 | bottom_main_layout.addSpacing(10) 404 | bottom_main_layout.addLayout(bottom_right_layout) 405 | 406 | # status bar 407 | self.status_bar.setSizeGripEnabled(False) 408 | self.status_bar.addPermanentWidget(self.output_btn) 409 | 410 | # add content to parent layout 411 | main_layout.addLayout(top_layout) 412 | main_layout.addSpacing(20) 413 | main_layout.addLayout(mid_main_layout) 414 | main_layout.addSpacing(5) 415 | main_layout.addLayout(bottom_main_layout) 416 | main_layout.addWidget(self.status_bar) 417 | 418 | # set output path slot 419 | def set_output_path(self): 420 | # update the output path 421 | path = str(QFileDialog.getExistingDirectory(self, "Select Output Directory")) 422 | if path: 423 | self.output_path = path 424 | # update tooltip 425 | self.output_btn.setToolTip(path) 426 | 427 | # get button slot 428 | def get_details(self): 429 | text = self.url_edit.text().strip() 430 | 431 | if not text: 432 | return 433 | 434 | if text.find("fid") < 0: 435 | self.message_box.warning( 436 | self, 437 | "Error", 438 | ( 439 | "Input a correct favorite URL!\n" 440 | "For example: https://space.bilibili.com/xxx/favlist?fid=xxx..." 441 | ), 442 | ) 443 | return 444 | 445 | if self.get_btn.text() == "Get": 446 | self.get_btn.setText("Stop") 447 | # indicate progress bar as busy 448 | self.progress_bar.setRange(0, 0) 449 | # set fetching flag 450 | self.is_fetching = True 451 | # setup a worker thread to keep UI responsive 452 | self.media_id = text.split("fid=")[-1].split("&")[0] 453 | self.worker = WorkerThread(self.media_id) 454 | self.worker.start() 455 | # catch the finished signal 456 | self.worker.finished.connect(self.finished_slot) 457 | # catch the response signal 458 | self.worker.worker_response.connect(self.response_slot) 459 | # catch the error signal 460 | self.worker.worker_err_response.connect(self.err_slot) 461 | elif self.get_btn.text() == "Stop": 462 | if self.is_fetching: 463 | # stop worker thread 464 | self.worker.terminate() 465 | # set back the get_btn text 466 | self.get_btn.setText("Get") 467 | elif self.is_downloading: 468 | # stop download thread 469 | self.download_thread.terminate() 470 | # show the warning message_box 471 | self.message_box.information( 472 | self, 473 | "Interrupted", 474 | "Download interrupted!\nThe process was aborted while the file was being downloaded... ", 475 | ) 476 | # reset progress bar 477 | self.progress_bar.reset() 478 | 479 | # download options slot 480 | def get_content(self): 481 | if self.is_fetching: 482 | # show the warning message 483 | self.message_box.critical( 484 | self, 485 | "Error", 486 | "Please wait!\nWait while the details are being fetched... ", 487 | ) 488 | else: 489 | # disable the download options 490 | self.download_btn.setDisabled(True) 491 | # set downloading flag 492 | self.is_downloading = True 493 | # set button to stop 494 | self.get_btn.setText("Stop") 495 | self.download_thread = DownloadThread( 496 | self.media_id, 497 | self.media_counts, 498 | self.first_page_medias, 499 | self.output_path, 500 | ) 501 | # start the thread 502 | self.download_thread.start() 503 | # catch the finished signal 504 | self.download_thread.finished.connect(self.download_finished_slot) 505 | # catch the response signal 506 | self.download_thread.download_response.connect(self.download_response_slot) 507 | # catch the complete signal 508 | self.download_thread.download_complete.connect(self.download_complete_slot) 509 | # catch the error signal 510 | self.download_thread.download_err.connect(self.download_err_slot) 511 | 512 | # handling enter key for get/stop button 513 | def keyPressEvent(self, event): 514 | self.url_edit.setFocus() 515 | if ( 516 | event.key() == Qt.Key.Key_Enter.value 517 | or event.key() == Qt.Key.Key_Return.value 518 | ): 519 | self.get_details() 520 | 521 | # finished slot 522 | def finished_slot(self): 523 | # remove progress bar busy indication 524 | self.progress_bar.setRange(0, 100) 525 | # unset fetching flag 526 | self.is_fetching = False 527 | 528 | # response slot 529 | def response_slot(self, res): 530 | # set back the button text 531 | self.get_btn.setText("Get") 532 | # set the actual thumbnail of requested video 533 | self.thumb.setPixmap(res.thumb_img) 534 | # slice the title if it is more than the limit 535 | if len(res.title) > 50: 536 | self.title.setText(f"Title: {res.title[:50]}...") 537 | else: 538 | self.title.setText(f"Title: {res.title}") 539 | # cache first page medias 540 | self.first_page_medias = res.medias 541 | self.media_counts = res.media_counts 542 | # set leftover details 543 | self.author.setText(f"Author: {res.author}") 544 | self.length.setText(f"Videos: {res.media_counts}") 545 | self.publish_date.setText( 546 | f'Published: {time.strftime("%Y-%m-%d %H:%M:%S",time.localtime(res.publish_date))}' 547 | ) 548 | self.download_btn.setDisabled(False) 549 | 550 | # error slot 551 | def err_slot(self): 552 | # show the warning message 553 | self.message_box.warning( 554 | self, 555 | "Warning", 556 | "Something went wrong!\nProbably a broken link or some restricted content... ", 557 | ) 558 | # set back the button text 559 | self.get_btn.setText("Get") 560 | 561 | # download finished slot 562 | def download_finished_slot(self): 563 | # set back the button text 564 | self.get_btn.setText("Get") 565 | # now enable the download options 566 | self.download_btn.setDisabled(False) 567 | # unset downloading flag 568 | self.is_downloading = False 569 | # reset pogress bar 570 | self.progress_bar.reset() 571 | 572 | # download response slot 573 | def download_response_slot(self, per): 574 | # update progress bar 575 | self.progress_bar.setValue(per) 576 | # adjust the font color to maintain the contrast 577 | if per > 52: 578 | self.progress_bar.setStyleSheet("QProgressBar { color: #fff }") 579 | else: 580 | self.progress_bar.setStyleSheet("QProgressBar { color: #000 }") 581 | 582 | # download complete slot 583 | def download_complete_slot(self, location): 584 | # use native separators 585 | location = QDir.toNativeSeparators(location) 586 | # show the success message 587 | if ( 588 | self.message_box.information( 589 | self, 590 | "Downloaded", 591 | f"Download complete!\nFile was successfully downloaded to :\n{location}\n\nOpen the downloaded file now ?", 592 | QMessageBox.StandardButtons.Open, 593 | QMessageBox.StandardButtons.Cancel, 594 | ) 595 | is QMessageBox.StandardButtons.Open 596 | ): 597 | subprocess.Popen(f"explorer /select,{location}") 598 | 599 | # download error slot 600 | def download_err_slot(self): 601 | # show the error message 602 | self.message_box.critical( 603 | self, 604 | "Error", 605 | "Error!\nSomething unusual happened and was unable to download...", 606 | ) 607 | 608 | 609 | if __name__ == "__main__": 610 | # instantiate the application 611 | app = QApplication(sys.argv) 612 | # setup a custom styleSheet 613 | with open(f"{os.path.dirname(os.path.abspath(__file__))}/app.css") as f: 614 | app.setStyleSheet(f.read()) 615 | window = B23Download() 616 | # show the window at last 617 | window.show() 618 | sys.exit(app.exec()) 619 | --------------------------------------------------------------------------------