├── src ├── gui │ ├── __init__.py │ ├── window │ │ ├── __init__.py │ │ ├── settings │ │ │ ├── page.py │ │ │ └── settings_v2.py │ │ ├── graph.py │ │ ├── debug.py │ │ └── main │ │ │ └── bottom_box.py │ ├── component │ │ ├── __init__.py │ │ ├── spinctrl │ │ │ ├── spinctrl.py │ │ │ └── label_spinctrl.py │ │ ├── menu │ │ │ ├── user.py │ │ │ ├── url.py │ │ │ ├── episode_option.py │ │ │ └── episode_list.py │ │ ├── text_ctrl │ │ │ ├── search_ctrl.py │ │ │ └── time_ctrl.py │ │ ├── button │ │ │ ├── button.py │ │ │ ├── bitmap_button.py │ │ │ ├── flat_button.py │ │ │ └── large_bitmap_button.py │ │ ├── previewer │ │ │ └── danmaku.py │ │ ├── misc │ │ │ ├── tooltip.py │ │ │ ├── taskbar_icon.py │ │ │ └── ass_color_picker.py │ │ ├── label │ │ │ └── info_label.py │ │ ├── panel │ │ │ ├── panel.py │ │ │ └── scrolled_panel.py │ │ ├── window │ │ │ ├── frame.py │ │ │ └── dialog.py │ │ ├── slider │ │ │ ├── slider.py │ │ │ ├── slider_box.py │ │ │ └── label_slider.py │ │ ├── choice │ │ │ └── choice.py │ │ ├── webview.py │ │ ├── staticbox │ │ │ ├── border.py │ │ │ ├── misc_style.py │ │ │ └── font.py │ │ └── staticbitmap │ │ │ └── staticbitmap.py │ ├── dialog │ │ ├── guide │ │ │ ├── page_3.py │ │ │ ├── page_2.py │ │ │ ├── page_1.py │ │ │ ├── agree_page.py │ │ │ └── page_4.py │ │ ├── setting │ │ │ ├── ass_style │ │ │ │ ├── page.py │ │ │ │ └── custom_ass_style_v2.py │ │ │ ├── scrape_option │ │ │ │ ├── video.py │ │ │ │ ├── add_date_box.py │ │ │ │ ├── movie.py │ │ │ │ ├── lesson.py │ │ │ │ └── scrape_option.py │ │ │ ├── color_picker.py │ │ │ ├── custom_user_agent.py │ │ │ ├── edit_title.py │ │ │ ├── select_batch.py │ │ │ └── custom_subtitle_lan.py │ │ ├── misc │ │ │ ├── changelog.py │ │ │ ├── license.py │ │ │ └── processing.py │ │ ├── login │ │ │ └── captcha.py │ │ ├── confirm │ │ │ └── video_resolution.py │ │ ├── history.py │ │ ├── error.py │ │ └── download_option │ │ │ └── other.py │ └── id.py ├── static │ └── __init__.py ├── utils │ ├── __init__.py │ ├── common │ │ ├── data │ │ │ ├── badge.py │ │ │ ├── rsa_key.py │ │ │ ├── guide.py │ │ │ ├── danmaku_ass_style.py │ │ │ └── priority.py │ │ ├── compile_data.py │ │ ├── const.py │ │ ├── cache.py │ │ ├── update.py │ │ ├── thread.py │ │ ├── datetime_util.py │ │ ├── model │ │ │ ├── ffmpeg.py │ │ │ ├── data_type.py │ │ │ └── callback.py │ │ ├── style │ │ │ ├── font.py │ │ │ └── color.py │ │ ├── io │ │ │ ├── file.py │ │ │ └── directory.py │ │ ├── history.py │ │ ├── formatter │ │ │ └── strict_naming.py │ │ └── regex.py │ ├── module │ │ ├── notification.py │ │ ├── md5_verify.py │ │ ├── clipboard.py │ │ ├── ffmpeg │ │ │ ├── prop.py │ │ │ ├── env.py │ │ │ └── utils.py │ │ ├── graph.py │ │ ├── web │ │ │ ├── ping.py │ │ │ ├── cdn.py │ │ │ └── ws.py │ │ └── pic │ │ │ └── face.py │ ├── parse │ │ ├── b23.py │ │ ├── extra │ │ │ ├── nfo │ │ │ │ ├── video.py │ │ │ │ ├── lesson.py │ │ │ │ ├── movie.py │ │ │ │ └── episode.py │ │ │ ├── cover.py │ │ │ ├── file │ │ │ │ ├── metadata │ │ │ │ │ ├── utils.py │ │ │ │ │ ├── video.py │ │ │ │ │ └── lesson.py │ │ │ │ └── danamku_xml.py │ │ │ ├── extra_v3.py │ │ │ └── parser.py │ │ ├── ogv.py │ │ ├── episode │ │ │ ├── popular.py │ │ │ ├── list.py │ │ │ ├── favlist.py │ │ │ └── cheese.py │ │ ├── live_stream.py │ │ ├── festival.py │ │ ├── live.py │ │ └── popular.py │ └── auth │ │ └── wbi.py └── Locale │ └── en_US │ └── LC_MESSAGES │ └── lang.mo ├── icon.ico ├── .gitattributes ├── requirements.txt ├── _pystand_static.int ├── .github └── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug-report.yml ├── pyproject.toml ├── .gitignore ├── LICENSE ├── icon.svg └── CHANGELOG.md /src/gui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/static/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/gui/window/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/gui/component/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottSloan/Bili23-Downloader/HEAD/icon.ico -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from utils.module.ffmpeg.env import FFEnv 2 | 3 | FFEnv.detect() -------------------------------------------------------------------------------- /src/utils/common/data/badge.py: -------------------------------------------------------------------------------- 1 | badge_dict = { 2 | 0: "已失效", 3 | 3: "充电专属" 4 | } -------------------------------------------------------------------------------- /src/Locale/en_US/LC_MESSAGES/lang.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottSloan/Bili23-Downloader/HEAD/src/Locale/en_US/LC_MESSAGES/lang.mo -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | src/static/svg-pan-zoom.min.js linguist-detectable=false 2 | src/static/viz-standalone.js linguist-detectable=false 3 | src/Locale/* linguist-generated=false -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.32.5 2 | wxPython==4.2.4 3 | qrcode[pil]==7.4.2 4 | python-vlc==3.0.21203 5 | protobuf==6.33.0 6 | websockets==15.0.1 7 | pycryptodome==3.23.0 -------------------------------------------------------------------------------- /src/utils/common/compile_data.py: -------------------------------------------------------------------------------- 1 | { 2 | "ver_major": 1, 3 | "ver_minor": 70, 4 | "ver_patch": 4, 5 | "ver_build": 0, 6 | "version_code": 170400, 7 | "channel": "source_code" 8 | } -------------------------------------------------------------------------------- /_pystand_static.int: -------------------------------------------------------------------------------- 1 | import sys, os 2 | 3 | os.chdir(os.path.dirname(__file__)) 4 | 5 | sys.path.append(os.path.abspath("script")) 6 | 7 | from main import APP 8 | 9 | app = APP() 10 | app.MainLoop() -------------------------------------------------------------------------------- /src/utils/common/const.py: -------------------------------------------------------------------------------- 1 | class Const: 2 | Size_1TB = 1024 * 1024 * 1024 * 1024 3 | Size_1GB = 1024 * 1024 * 1024 4 | Size_100MB = 100 * 1024 * 1024 5 | Size_1MB = 1024 * 1024 6 | Size_1KB = 1024 -------------------------------------------------------------------------------- /src/gui/component/spinctrl/spinctrl.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | class SpinCtrl(wx.SpinCtrl): 4 | def __init__(self, parent: wx.Window, value: str = "", min: int = 0, max: int = 100, size: wx.Size = wx.DefaultSize): 5 | wx.SpinCtrl.__init__(self, parent, -1, value = value, min = min, max = max, size = size, style = 0) 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: ❓提出问题 4 | url: https://github.com/ScottSloan/Bili23-Downloader/discussions 5 | about: 提问项目相关的其他问题 6 | - name: 📄官方文档 7 | url: https://bili23.scott-sloan.cn/ 8 | about: 在创建 Issue 之前,请仔细查阅 Bili23 Downloader 使用手册以确保正确使用。 9 | -------------------------------------------------------------------------------- /src/utils/common/data/rsa_key.py: -------------------------------------------------------------------------------- 1 | correspond_path_key = '''\ 2 | -----BEGIN PUBLIC KEY----- 3 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDLgd2OAkcGVtoE3ThUREbio0Eg 4 | Uc/prcajMKXvkCKFCWhJYJcLkcM2DKKcSeFpD/j6Boy538YXnR6VhcuUJOhH2x71 5 | nzPjfdTcqMz7djHum0qSZA0AyCBDABUqCrfNgCiJ00Ra7GmRj+YCK1NJEuewlb40 6 | JNrRuoEUXpabUzGB8QIDAQAB 7 | -----END PUBLIC KEY-----''' -------------------------------------------------------------------------------- /src/gui/component/menu/user.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from gui.id import ID 5 | 6 | _ = gettext.gettext 7 | 8 | class UserMenu(wx.Menu): 9 | def __init__(self): 10 | wx.Menu.__init__(self) 11 | 12 | self.Append(ID.REFRESH_MENU, _("刷新(&R)")) 13 | self.Append(ID.LOGOUT_MENU, _("注销(&L)")) 14 | self.AppendSeparator() 15 | self.Append(ID.SETTINGS_MENU, _("设置(&S)")) -------------------------------------------------------------------------------- /src/gui/component/text_ctrl/search_ctrl.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | class SearchCtrl(wx.SearchCtrl): 4 | def __init__(self, parent, placeholder: str, size = wx.DefaultSize, search_btn: bool = False, clear_btn: bool = False): 5 | wx.SearchCtrl.__init__(self, parent, size = size, style = wx.TE_PROCESS_ENTER) 6 | 7 | self.ShowSearchButton(search_btn) 8 | self.ShowCancelButton(clear_btn) 9 | self.SetDescriptiveText(placeholder) -------------------------------------------------------------------------------- /src/gui/component/button/button.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.config import Config 4 | from utils.common.enums import Platform 5 | 6 | class Button(wx.Button): 7 | def __init__(self, parent, label: str, size: tuple, set_variant: bool = False): 8 | wx.Button.__init__(self, parent, -1, label, size = size) 9 | 10 | if Config.Sys.platform == Platform.macOS.value and set_variant: 11 | self.SetWindowVariant(wx.WINDOW_VARIANT_LARGE) 12 | -------------------------------------------------------------------------------- /src/utils/common/cache.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | class DataCache: 4 | cache_dict = {} 5 | 6 | @staticmethod 7 | def get_cache(key: str): 8 | if key in DataCache.cache_dict: 9 | return DataCache.cache_dict.get(key) 10 | 11 | @staticmethod 12 | def set_cache(key: str, value: Any): 13 | DataCache.cache_dict[key] = value 14 | 15 | @staticmethod 16 | def clear_cache(): 17 | DataCache.cache_dict.clear() -------------------------------------------------------------------------------- /src/gui/component/previewer/danmaku.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from gui.component.panel.panel import Panel 4 | 5 | class DanmakuPreviewer(Panel): 6 | def __init__(self, parent): 7 | Panel.__init__(self, parent) 8 | 9 | self.Bind(wx.EVT_PAINT, self.onPaintEVT) 10 | 11 | def onPaintEVT(self, event: wx.PaintEvent): 12 | dc = wx.PaintDC(self) 13 | self.render_danmaku(dc) 14 | 15 | def render_danmaku(self, dc: wx.PaintDC): 16 | dc.DrawText("Text", 10, 10) -------------------------------------------------------------------------------- /src/gui/component/menu/url.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from gui.id import ID 5 | 6 | _ = gettext.gettext 7 | 8 | class URLMenu(wx.Menu): 9 | def __init__(self): 10 | wx.Menu.__init__(self) 11 | 12 | self.Append(ID.SUPPORTTED_URL_MENU, _("支持解析的链接(&U)")) 13 | self.AppendSeparator() 14 | 15 | history_menu_item = wx.MenuItem(self, ID.HISTORY_MENU, "历史记录(&H)") 16 | 17 | self.Append(history_menu_item) 18 | 19 | self.Enable(ID.HISTORY_MENU, False) -------------------------------------------------------------------------------- /src/utils/module/notification.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import os 3 | import wx.adv 4 | 5 | from utils.config import Config 6 | 7 | class NotificationManager: 8 | def __init__(self, parent): 9 | self.parent = parent 10 | 11 | self.notification = wx.adv.NotificationMessage() 12 | 13 | def show_toast(self, title: str, message: str, flags: int): 14 | self.notification.SetTitle(title) 15 | self.notification.SetMessage(message) 16 | self.notification.SetFlags(flags) 17 | 18 | self.notification.Show() 19 | -------------------------------------------------------------------------------- /src/gui/dialog/guide/page_3.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.common.data.guide import guide_3_msg 5 | 6 | from gui.dialog.guide.agree_page import AgreePage 7 | 8 | _ = gettext.gettext 9 | 10 | class Page3Panel(AgreePage): 11 | def __init__(self, parent: wx.Window): 12 | AgreePage.__init__(self, parent, guide_3_msg) 13 | 14 | def onChangePage(self): 15 | self.startCountdown() 16 | 17 | return { 18 | "title": _("免责声明"), 19 | "next_btn_label": _("下一步"), 20 | "next_btn_enable": False 21 | } -------------------------------------------------------------------------------- /src/gui/dialog/guide/page_2.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.common.data.guide import guide_2_msg 5 | 6 | from gui.dialog.guide.agree_page import AgreePage 7 | 8 | _ = gettext.gettext 9 | 10 | class Page2Panel(AgreePage): 11 | def __init__(self, parent: wx.Window): 12 | AgreePage.__init__(self, parent, guide_2_msg) 13 | 14 | def onChangePage(self): 15 | self.startCountdown() 16 | 17 | return { 18 | "title": _("使用须知"), 19 | "next_btn_label": _("下一步"), 20 | "next_btn_enable": False 21 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "bili23-downloader" 3 | version = "1.66.0" 4 | description = "跨平台的 B 站视频下载工具,支持 Windows、Linux、macOS 三平台,下载 B 站视频/番剧/电影/纪录片 等资源" 5 | readme = "README.md" 6 | license = "MIT License" 7 | requires-python = ">=3.9" 8 | dependencies = [ 9 | "qrcode[pil]==7.4.2", 10 | "requests==2.32.5", 11 | "wxpython==4.2.3", 12 | "python-vlc==3.0.21203", 13 | "protobuf==6.32.0", 14 | "websockets==15.0.1", 15 | "pycryptodome==3.23.0" 16 | ] 17 | authors = [ 18 | {name = "ScottSloan", email = "scottsloan@petalmail.com"}, 19 | ] 20 | encoding = "utf-8" 21 | 22 | [project.scripts] 23 | bili23-gui = "GUI" -------------------------------------------------------------------------------- /src/gui/component/misc/tooltip.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.common.style.icon_v4 import Icon, IconID 4 | 5 | from gui.component.staticbitmap.staticbitmap import StaticBitmap 6 | 7 | class ToolTip(StaticBitmap): 8 | def __init__(self, parent: wx.Window): 9 | StaticBitmap.__init__(self, parent, size = parent.FromDIP((16, 16))) 10 | 11 | self.SetBitmap(bmp = Icon.get_icon_bitmap(IconID.Info)) 12 | 13 | def set_tooltip(self, string: str): 14 | tooltip = wx.ToolTip(string) 15 | tooltip.SetDelay(50) 16 | tooltip.SetAutoPop(30000) 17 | 18 | self.SetToolTip(tooltip) 19 | 20 | self.Refresh() -------------------------------------------------------------------------------- /src/utils/module/md5_verify.py: -------------------------------------------------------------------------------- 1 | import re 2 | import hashlib 3 | 4 | class MD5Verify: 5 | def get_md5_from_etag(etag: str) -> str | None: 6 | if etag: 7 | if len(etag) == 18 and '"' in etag: 8 | result = re.findall(r'\w+', etag) 9 | 10 | if result: 11 | return result[0] 12 | 13 | def verify_md5(md5_value: str, file_path: str): 14 | md5 = hashlib.md5() 15 | 16 | with open(file_path, 'rb') as f: 17 | for chunk in iter(lambda: f.read(4096), b""): 18 | md5.update(chunk) 19 | 20 | return md5_value.lower() == md5.hexdigest() -------------------------------------------------------------------------------- /src/gui/component/label/info_label.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.config import Config 4 | 5 | from utils.common.style.color import Color 6 | 7 | class InfoLabel(wx.StaticText): 8 | def __init__(self, parent, label: str = wx.EmptyString, size = wx.DefaultSize, color: wx.Colour = wx.Colour(108, 108, 108)): 9 | self.color = color 10 | 11 | wx.StaticText.__init__(self, parent, -1, label, size = size) 12 | 13 | self.SetForegroundColour(self.get_default_color()) 14 | 15 | def get_default_color(self): 16 | if not Config.Sys.dark_mode: 17 | return self.color 18 | else: 19 | return Color.get_text_color() -------------------------------------------------------------------------------- /src/gui/dialog/setting/ass_style/page.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from gui.component.panel.panel import Panel 4 | from gui.component.panel.scrolled_panel import ScrolledPanel 5 | 6 | class Page(Panel): 7 | def __init__(self, parent: wx.Window): 8 | Panel.__init__(self, parent) 9 | 10 | self.scrolled_panel = ScrolledPanel(self) 11 | self.panel = Panel(self.scrolled_panel) 12 | 13 | def init_UI(self): 14 | self.scrolled_panel.sizer.Add(self.panel, 0, wx.EXPAND) 15 | 16 | vbox = wx.BoxSizer(wx.VERTICAL) 17 | vbox.Add(self.scrolled_panel, 1, wx.EXPAND) 18 | 19 | self.SetSizer(vbox) 20 | 21 | self.scrolled_panel.Layout() -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | 3 | config.ini 4 | 5 | # 虚拟环境 6 | .venv 7 | 8 | # 下载目录 9 | /download 10 | 11 | # VLC 12 | VLC 13 | 14 | # 配置文件 15 | video*.json 16 | bangumi*.json 17 | live.json 18 | download*.json 19 | ffmpeg.exe 20 | build.ps1 21 | test.ps1 22 | test*.py 23 | bilidanmu.proto 24 | config.json 25 | cheese*.json 26 | choices.json 27 | node.json 28 | episode.json 29 | temp.json 30 | exclimbwuzhi*.json 31 | history.json 32 | /.config 33 | build_v2.ps1 34 | 35 | # commitizen 36 | cz.json 37 | cz.toml 38 | 39 | # IDE 40 | .vscode 41 | 42 | # uv 43 | uv.toml 44 | 45 | .DS_Store 46 | example.md 47 | 48 | # log 49 | error_log.txt 50 | 51 | # PyStand 52 | Build 53 | 54 | temp 55 | test 56 | 57 | # git hooks 58 | hooks/ -------------------------------------------------------------------------------- /src/utils/module/clipboard.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | class ClipBoard: 4 | @staticmethod 5 | def Read(): 6 | text_obj = wx.TextDataObject() 7 | 8 | if not wx.TheClipboard.IsOpened(): 9 | if wx.TheClipboard.Open(): 10 | success = wx.TheClipboard.GetData(text_obj) 11 | 12 | wx.TheClipboard.Close() 13 | 14 | if success: 15 | return text_obj.GetText() 16 | 17 | @staticmethod 18 | def Write(data: str): 19 | text_obj = wx.TextDataObject(data) 20 | 21 | if not wx.TheClipboard.IsOpened(): 22 | if wx.TheClipboard.Open(): 23 | wx.TheClipboard.SetData(text_obj) 24 | 25 | wx.TheClipboard.Close() 26 | -------------------------------------------------------------------------------- /src/utils/common/update.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from utils.config import Config 4 | 5 | from utils.common.request import RequestUtils 6 | 7 | class Update: 8 | @classmethod 9 | def get_json(cls, url: str) -> dict: 10 | req = RequestUtils.request_get(url) 11 | 12 | return json.loads(req.text) 13 | 14 | @staticmethod 15 | def get_changelog(): 16 | url = f"https://api.scott-sloan.cn/Bili23-Downloader/getChangelog?version_code={Config.APP.version_code}" 17 | 18 | return Update.get_json(url) 19 | 20 | @staticmethod 21 | def get_update_json(): 22 | url = f"https://api.scott-sloan.cn/Bili23-Downloader/getLatestVersion?ver={Config.APP.version_code}" 23 | 24 | return Update.get_json(url) -------------------------------------------------------------------------------- /src/gui/component/panel/panel.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.common.enums import Platform 4 | from utils.config import Config 5 | 6 | class Panel(wx.Panel): 7 | def __init__(self, parent: wx.Window, size: wx.Size = wx.DefaultSize, name: str = wx.PanelNameStr): 8 | wx.Panel.__init__(self, parent, -1, size = size, name = name) 9 | 10 | def get_scaled_size(self, size: tuple): 11 | match Platform(Config.Sys.platform): 12 | case Platform.Windows: 13 | return self.FromDIP(size) 14 | 15 | case Platform.Linux | Platform.macOS: 16 | return wx.DefaultSize 17 | 18 | def set_dark_mode(self): 19 | if not Config.Sys.dark_mode: 20 | self.SetBackgroundColour("white") -------------------------------------------------------------------------------- /src/gui/component/panel/scrolled_panel.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from wx.lib.scrolledpanel import ScrolledPanel as _ScrolledPanel 4 | 5 | from utils.config import Config 6 | 7 | class ScrolledPanel(_ScrolledPanel): 8 | def __init__(self, parent, size = wx.DefaultSize): 9 | _ScrolledPanel.__init__(self, parent, -1, size = size) 10 | 11 | self.sizer = wx.BoxSizer(wx.VERTICAL) 12 | 13 | self.SetSizer(self.sizer) 14 | 15 | def set_dark_mode(self): 16 | if not Config.Sys.dark_mode: 17 | self.SetBackgroundColour("white") 18 | 19 | def Layout(self, scroll_x = False, scrollToTop = False): 20 | super().Layout() 21 | 22 | self.SetupScrolling(scroll_x = scroll_x, scrollToTop = scrollToTop) 23 | 24 | -------------------------------------------------------------------------------- /src/utils/common/data/guide.py: -------------------------------------------------------------------------------- 1 | import gettext 2 | 3 | _ = gettext.gettext 4 | 5 | guide_1_msg = _("""\ 6 | 开始使用前,请先完成以下简短的引导。 7 | 8 | 点击“下一步”按钮继续。 9 | 10 | """) 11 | 12 | guide_2_msg = _("""\ 13 | 1.本项目仅支持下载 B 站视频,不支持其他视频平台。 14 | 15 | 2.本项目不提供破解大会员专享视频、付费视频、充电视频等功能,如有相关需求,请开通B站大会员服务、购买付费内容、向UP主充电解锁。 16 | 17 | 3.用户能否下载上述内容,取决于所登录账号的会员状态和相应权限,也就是说能账号看什么就能下载什么。 18 | 19 | 4.如果视频本身就有水印,那么下载下来就会带水印,与B站客户端看到的效果一致。除非在上传视频时不带水印,否则无法下载无水印的视频。 20 | 21 | 特此说明,请勿再次咨询以上相关问题。 22 | 23 | """) 24 | 25 | guide_3_msg = _("""\ 26 | 本项目仅供个人学习与研究用途,任何通过本项目下载的内容仅限于个人使用,不得用于任何形式的商业目的、公开传播或分发。 27 | 28 | 用户需自行承担使用本项目可能带来的所有风险,项目开发者不对因使用本项目所引发的任何法律纠纷、版权问题或其他损害承担责任。 29 | 30 | """) 31 | 32 | guide_4_msg = _("""\ 33 | 建议详细阅读说明文档,以便快速上手并充分了解各项功能设置。 34 | 35 | 欢迎加入社区,获取项目最新动态、问题答疑和技术交流。 36 | 37 | """) -------------------------------------------------------------------------------- /src/gui/component/window/frame.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.common.style.icon_v4 import Icon, IconID 4 | from utils.common.enums import Platform 5 | from utils.config import Config 6 | 7 | class Frame(wx.Frame): 8 | def __init__(self, parent, title, style = wx.DEFAULT_FRAME_STYLE, name = wx.FrameNameStr): 9 | wx.Frame.__init__(self, parent, -1, title, style = style, name = name) 10 | 11 | self.SetIcon(wx.Icon(Icon.get_app_icon_bitmap(IconID.App_Default))) 12 | 13 | def get_scaled_size(self, size: tuple): 14 | match Platform(Config.Sys.platform): 15 | case Platform.Windows: 16 | return self.FromDIP(size) 17 | 18 | case Platform.Linux | Platform.macOS: 19 | return wx.DefaultSize 20 | -------------------------------------------------------------------------------- /src/utils/parse/b23.py: -------------------------------------------------------------------------------- 1 | from utils.common.model.callback import ParseCallback 2 | from utils.common.exception import GlobalException 3 | from utils.common.enums import StatusCode 4 | from utils.common.request import RequestUtils 5 | 6 | from utils.parse.parser import Parser 7 | 8 | class B23Parser(Parser): 9 | def __init__(self, callback: ParseCallback): 10 | super().__init__() 11 | 12 | self.callback = callback 13 | 14 | def get_redirect_url(self, url: str): 15 | req = RequestUtils.request_get(url, headers = RequestUtils.get_headers()) 16 | 17 | return req.url 18 | 19 | def parse_worker(self, url: str): 20 | new_url = self.get_redirect_url(url) 21 | 22 | raise GlobalException(code = StatusCode.Redirect.value, callback = self.callback.onJump, args = (new_url, )) -------------------------------------------------------------------------------- /src/utils/common/thread.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import threading 3 | from concurrent.futures import ThreadPoolExecutor 4 | 5 | class Thread(threading.Thread): 6 | def __init__(self, target = None, args = (), kwargs = None, name = "", daemon = True): 7 | threading.Thread.__init__(self, target = target, args = args, kwargs = kwargs, name = name, daemon = daemon) 8 | 9 | def stop(self): 10 | # 使用 Python C API 强制停止线程 11 | ctypes.pythonapi.PyThreadState_SetAsyncExc(self.ident, ctypes.py_object(SystemExit)) 12 | 13 | def start(self): 14 | threading.Thread.start(self) 15 | 16 | class DaemonThreadPoolExecutor(ThreadPoolExecutor): 17 | def __init__(self, *args, **kwargs): 18 | super().__init__(*args, **kwargs) 19 | self._thread_factory = lambda: threading.Thread(daemon = True) -------------------------------------------------------------------------------- /src/gui/component/slider/slider.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from gui.component.panel.scrolled_panel import ScrolledPanel 4 | 5 | class Slider(wx.Slider): 6 | def __init__(self, parent: wx.Window, value: int = 0, min_value: int = 0, max_value: int = 100): 7 | wx.Slider.__init__(self, parent, -1, value = value, minValue = min_value, maxValue = max_value) 8 | 9 | self.Bind_EVT() 10 | 11 | def Bind_EVT(self): 12 | self.Bind(wx.EVT_MOUSEWHEEL, self.onMouseWheelEVT) 13 | 14 | def onMouseWheelEVT(self, event: wx.MouseEvent): 15 | parent = self.GetParent() 16 | 17 | while parent is not None: 18 | if isinstance(parent, ScrolledPanel): 19 | evt = wx.MouseEvent(event) 20 | wx.PostEvent(parent.GetEventHandler(), evt) 21 | 22 | event.StopPropagation() 23 | 24 | parent = parent.GetParent() -------------------------------------------------------------------------------- /src/utils/common/datetime_util.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | class DateTime: 4 | @classmethod 5 | def time_str_from_timestamp(cls, timestamp: int, format: str = "%Y/%m/%d %H:%M:%S"): 6 | return cls.from_timestamp(timestamp).strftime(format) 7 | 8 | @classmethod 9 | def time_str(cls, format: str = "%Y/%m/%d %H:%M:%S"): 10 | return cls.now().strftime(format) 11 | 12 | @staticmethod 13 | def from_timestamp(timestamp: int): 14 | return datetime.fromtimestamp(timestamp) 15 | 16 | @staticmethod 17 | def get_timestamp(): 18 | return round(datetime.now().timestamp()) 19 | 20 | @staticmethod 21 | def now(): 22 | return datetime.now() 23 | 24 | @staticmethod 25 | def get_timedelta(datetime: datetime , delta_days: int): 26 | return round((datetime + timedelta(days = delta_days)).timestamp()) 27 | 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: ✨ 提出建议 2 | description: 此处支持提出建议、请求新功能 3 | title: "[Feature] 简要描述你的建议或请求" 4 | labels: 需求建议(enhancement) 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | > 💡 请务必遵循标题格式。例如:[Feature] 希望支持下载音频 10 | - type: checkboxes 11 | attributes: 12 | label: 议题条件 13 | description: 在你开始之前,请花几分钟时间确保你已如实完成以下工作,以便让我们更高效地沟通。 14 | options: 15 | - label: 我确认即使在最新正式版中也没有提供该功能。 16 | required: true 17 | - label: 我确认已在 [Issues](/ScottSloan/Bili23-Downloader/issues) 进行搜索并确认没有人提交过相同的请求。 18 | required: true 19 | - label: 我确认已经总结议题内容并按规范设置此Issue的标题 20 | required: true 21 | - type: textarea 22 | attributes: 23 | label: 详细描述 24 | description: 请详细描述你的建议或需求。 25 | validations: 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: 其他 30 | description: 如上述仍然无法准确地表述问题,可提供必要的截图(可直接粘贴上传) 31 | -------------------------------------------------------------------------------- /src/gui/dialog/setting/scrape_option/video.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.config import Config 4 | 5 | from gui.dialog.setting.scrape_option.add_date_box import AddDateBox 6 | 7 | from gui.component.panel.panel import Panel 8 | 9 | class VideoPage(Panel): 10 | def __init__(self, parent: wx.Window): 11 | Panel.__init__(self, parent) 12 | 13 | self.init_UI() 14 | 15 | self.init_data() 16 | 17 | def init_UI(self): 18 | self.add_date_source_box = AddDateBox(self) 19 | 20 | vbox = wx.BoxSizer(wx.VERTICAL) 21 | vbox.Add(self.add_date_source_box, 0, wx.EXPAND) 22 | 23 | self.SetSizerAndFit(vbox) 24 | 25 | def init_data(self): 26 | option = Config.Temp.scrape_option.get("video") 27 | 28 | self.add_date_source_box.init_data(option) 29 | 30 | def save(self): 31 | return { 32 | "video": { 33 | **self.add_date_source_box.save() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/gui/dialog/guide/page_1.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.common.data.guide import guide_1_msg 5 | 6 | from gui.component.panel.panel import Panel 7 | 8 | _ = gettext.gettext 9 | 10 | class Page1Panel(Panel): 11 | def __init__(self, parent: wx.Window): 12 | Panel.__init__(self, parent) 13 | 14 | self.init_UI() 15 | 16 | def init_UI(self): 17 | font = self.GetFont() 18 | font.SetFractionalPointSize(font.GetFractionalPointSize() + 1) 19 | 20 | self.desc_lab = wx.StaticText(self, -1, guide_1_msg) 21 | self.desc_lab.Wrap(self.FromDIP(400)) 22 | self.desc_lab.SetFont(font) 23 | 24 | vbox = wx.BoxSizer(wx.VERTICAL) 25 | vbox.Add(self.desc_lab, 0, wx.ALL, self.FromDIP(10)) 26 | 27 | self.SetSizer(vbox) 28 | 29 | def onChangePage(self): 30 | return { 31 | "title": _("欢迎使用 Bili23 Downloader"), 32 | "next_btn_label": _("下一步"), 33 | "next_btn_enable": True 34 | } -------------------------------------------------------------------------------- /src/utils/common/data/danmaku_ass_style.py: -------------------------------------------------------------------------------- 1 | import gettext 2 | 3 | _ = gettext.gettext 4 | 5 | area_data = { 6 | "label": _("显示区域"), 7 | "value": 5, 8 | "min_value": 1, 9 | "max_value": 5, 10 | "data": { 11 | 1: "20%", 12 | 2: "40%", 13 | 3: "60%", 14 | 4: "80%", 15 | 5: "100%" 16 | } 17 | } 18 | 19 | alpha_data = { 20 | "label": _("不透明度"), 21 | "value": 80, 22 | "min_value": 10, 23 | "max_value": 100, 24 | "data": {} 25 | } 26 | 27 | speed_data = { 28 | "label": _("弹幕速度"), 29 | "value": 5, 30 | "min_value": 1, 31 | "max_value": 5, 32 | "data": { 33 | 1: _("极慢"), 34 | 2: _("较慢"), 35 | 3: _("适中"), 36 | 4: _("较快"), 37 | 5: _("极快") 38 | } 39 | } 40 | 41 | density_data = { 42 | "label": _("弹幕密度"), 43 | "value": 1, 44 | "min_value": 1, 45 | "max_value": 3, 46 | "data": { 47 | 1: _("正常"), 48 | 2: _("较多"), 49 | 3: _("重叠") 50 | } 51 | } -------------------------------------------------------------------------------- /src/utils/common/model/ffmpeg.py: -------------------------------------------------------------------------------- 1 | from utils.config import Config 2 | 3 | class FFmpegCommand: 4 | def __init__(self, input_files: list[str], output: str): 5 | self.input_files = input_files 6 | self.output = output 7 | 8 | def merge(self): 9 | params = ["-acodec", "copy", "-vcodec", "copy", "-strict", "experimental"] 10 | 11 | return self.construct(params) 12 | 13 | def convert_audio(self, acodec: str): 14 | params = ["-c:a", acodec, "-q:a", "0"] 15 | 16 | return self.construct(params) 17 | 18 | def merge_flv_list(self): 19 | params = ["-f", "concat", "-safe", "0", "-c", "copy"] 20 | 21 | return self.construct(params) 22 | 23 | def construct(self, params: list[str]): 24 | command = [f'"{Config.Merge.ffmpeg_path}"', "-y"] 25 | 26 | for input_file in self.input_files: 27 | command.extend(["-i", input_file]) 28 | 29 | command.extend(params) 30 | command.append(self.output) 31 | 32 | return " ".join(command) -------------------------------------------------------------------------------- /src/utils/parse/extra/nfo/video.py: -------------------------------------------------------------------------------- 1 | from utils.common.model.download_info import DownloadTaskInfo 2 | 3 | from utils.parse.video import VideoParser 4 | from utils.parse.extra.parser import Parser 5 | from utils.parse.extra.file.metadata.video import VideoMetadataFile 6 | 7 | class VideoNFOParser(Parser): 8 | def __init__(self, task_info: DownloadTaskInfo): 9 | Parser.__init__(self) 10 | 11 | self.task_info = task_info 12 | 13 | def download_video_nfo(self): 14 | self.get_video_extra_info() 15 | 16 | file = VideoMetadataFile(self.task_info) 17 | contents = file.get_nfo_contents() 18 | 19 | self.save_file(f"{self.task_info.file_name}.nfo", contents, "w") 20 | 21 | def get_video_extra_info(self): 22 | info = VideoParser.get_video_extra_info(self.task_info.bvid) 23 | 24 | self.task_info.up_face_url = info.get("up_face") 25 | self.task_info.description = info.get("description") 26 | self.task_info.video_tags = VideoParser.get_video_tags(self.task_info.bvid) 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Scott Sloan 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 | -------------------------------------------------------------------------------- /src/gui/dialog/misc/changelog.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from gui.component.window.dialog import Dialog 5 | 6 | _ = gettext.gettext 7 | 8 | class ChangeLogDialog(Dialog): 9 | def __init__(self, parent, info: dict): 10 | self.info = info 11 | 12 | Dialog.__init__(self, parent, _("当前版本更新日志")) 13 | 14 | self.init_UI() 15 | 16 | self.CenterOnParent() 17 | 18 | wx.Bell() 19 | 20 | def init_UI(self): 21 | font: wx.Font = self.GetFont() 22 | font.SetFractionalPointSize(int(font.GetFractionalPointSize() + 1)) 23 | 24 | changelog_box = wx.TextCtrl(self, -1, self.info.get("changelog"), size = self.FromDIP((500, 250)), style = wx.TE_MULTILINE | wx.TE_READONLY) 25 | changelog_box.SetFont(font) 26 | 27 | close_btn = wx.Button(self, wx.ID_CANCEL, _("关闭"), size = self.get_scaled_size((80, 28))) 28 | 29 | dlg_vbox = wx.BoxSizer(wx.VERTICAL) 30 | dlg_vbox.Add(changelog_box, 0, wx.ALL, self.FromDIP(6)) 31 | dlg_vbox.Add(close_btn, 0, wx.ALL & (~wx.TOP) | wx.ALIGN_RIGHT, self.FromDIP(6)) 32 | 33 | self.SetSizerAndFit(dlg_vbox) -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/parse/ogv.py: -------------------------------------------------------------------------------- 1 | from utils.config import Config 2 | 3 | from utils.common.request import RequestUtils 4 | 5 | from utils.parse.parser import Parser 6 | 7 | class OGVParser(Parser): 8 | def __init__(self): 9 | super().__init__() 10 | 11 | def get_bangumi_available_media_info(self): 12 | url = f"https://api.bilibili.com/ogv/player/playview?csrf={Config.User.bili_jct}" 13 | 14 | raw_json = { 15 | "scene": "normal", 16 | "video_index": { 17 | "bvid": "BV1V6pazREaX", 18 | "cid": None, 19 | "ogv_season_id": 46244 20 | }, 21 | "video_param": { 22 | "qn": 112 23 | }, 24 | "player_param": { 25 | "fnver": 0, 26 | "fnval": 4048, 27 | "drm_tech_type": 2 28 | }, 29 | "exp_info": { 30 | "ogv_half_pay": True 31 | } 32 | } 33 | 34 | resp = self.request_post(url, headers = RequestUtils.get_headers(referer_url = self.bilibili_url, sessdata = Config.User.SESSDATA), raw_json = raw_json) 35 | 36 | print(resp) 37 | -------------------------------------------------------------------------------- /src/utils/parse/extra/cover.py: -------------------------------------------------------------------------------- 1 | from utils.config import Config 2 | from utils.common.model.task_info import DownloadTaskInfo 3 | from utils.common.enums import TemplateType 4 | 5 | from utils.module.pic.cover import Cover 6 | 7 | from utils.parse.extra.parser import Parser 8 | 9 | class CoverParser(Parser): 10 | def __init__(self, task_info: DownloadTaskInfo): 11 | Parser.__init__(self) 12 | 13 | self.task_info = task_info 14 | 15 | def parse(self): 16 | self.generate_cover() 17 | 18 | self.task_info.total_file_size += self.total_file_size 19 | 20 | def generate_cover(self): 21 | cover_type = Cover.get_cover_type(Config.Basic.cover_file_type) 22 | self.task_info.output_type = cover_type.lstrip(".") 23 | 24 | contents = Cover.download_cover(self.task_info.cover_url) 25 | 26 | if self.task_info.extra_option.get("download_metadata_file") and TemplateType.Bangumi_strict.value and self.task_info.episode_tag: 27 | file_name = f"{self.task_info.episode_tag}{cover_type}" 28 | else: 29 | file_name = f"{self.task_info.file_name}{cover_type}" 30 | 31 | self.save_file(file_name, contents, "wb") -------------------------------------------------------------------------------- /src/utils/module/ffmpeg/prop.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from utils.common.formatter.file_name_v2 import FileNameFormatter 4 | 5 | from utils.common.model.task_info import DownloadTaskInfo 6 | 7 | class FFProp: 8 | def __init__(self, task_info: DownloadTaskInfo): 9 | self.task_info = task_info 10 | 11 | def video_temp_file(self): 12 | return f"video_{self.task_info.id}.{self.task_info.video_type}" 13 | 14 | def flv_temp_file(self): 15 | return f"flv_{self.task_info.id}.flv" 16 | 17 | def audio_temp_file(self): 18 | return f"audio_{self.task_info.id}.{self.task_info.audio_type}" 19 | 20 | def output_temp_file(self, file_type: str = None): 21 | return f"output_{self.task_info.id}.{file_type if file_type else self.task_info.output_type}" 22 | 23 | def output_file_name(self, file_type: str = None): 24 | return FileNameFormatter.check_file_name_length(f"{self.task_info.file_name}.{file_type if file_type else self.task_info.output_type}") 25 | 26 | def flv_list_file(self): 27 | return f"flv_list_{self.task_info.id}.txt" 28 | 29 | def flv_list_path(self): 30 | return os.path.join(self.task_info.download_path, self.flv_list_file()) 31 | -------------------------------------------------------------------------------- /src/utils/common/data/priority.py: -------------------------------------------------------------------------------- 1 | import gettext 2 | 3 | _ = gettext.gettext 4 | 5 | video_quality_priority = { 6 | _("8K 超高清"): 127, 7 | _("杜比视界"): 126, 8 | _("HDR 真彩"): 125, 9 | _("4K 超高清"): 120, 10 | _("1080P 60帧"): 116, 11 | _("1080P 高码率"): 112, 12 | _("智能修复"): 100, 13 | _("1080P 高清"): 80, 14 | _("720P 准高清"): 64, 15 | _("480P 标清"): 32, 16 | _("360P 流畅"): 16 17 | } 18 | 19 | video_quality_priority_short = { 20 | 127: "8K", 21 | 126: "Dolby", 22 | 125: "HDR", 23 | 120: "4K", 24 | 116: "1080P60", 25 | 112: "1080P+", 26 | 100: "AI", 27 | 80: "1080P", 28 | 64: "720P", 29 | 32: "480P", 30 | 16: "360P" 31 | } 32 | 33 | audio_quality_priority = { 34 | _("Hi-Res 无损"): 30251, 35 | _("杜比全景声"): 30250, 36 | "192K": 30280, 37 | "132K": 30232, 38 | "64K": 30216 39 | } 40 | 41 | audio_quality_priority_short = { 42 | 30251: "Hi-Res", 43 | 30250: "Dolby", 44 | 30280: "192K", 45 | 30232: "132K", 46 | 30216: "64K" 47 | } 48 | 49 | video_codec_priority = { 50 | "AV1": 13, 51 | "AVC/H.264": 7, 52 | "HEVC/H.265": 12 53 | } 54 | 55 | video_codec_priority_short = { 56 | 13: "AV1", 57 | 7: "AVC", 58 | 12: "HEVC" 59 | } -------------------------------------------------------------------------------- /src/utils/auth/wbi.py: -------------------------------------------------------------------------------- 1 | import time 2 | import urllib.parse 3 | from hashlib import md5 4 | from functools import reduce 5 | 6 | from utils.config import Config 7 | 8 | mixinKeyEncTab = [ 9 | 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 10 | 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 11 | 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 12 | 36, 20, 34, 44, 52 13 | ] 14 | 15 | class WbiUtils: 16 | @staticmethod 17 | def encWbi(params: dict): 18 | def getMixinKey(orig: str): 19 | return reduce(lambda s, i: s + orig[i], mixinKeyEncTab, '')[:32] 20 | 21 | mixin_key = getMixinKey(Config.Auth.img_key + Config.Auth.sub_key) 22 | curr_time = round(time.time()) 23 | 24 | params['wts'] = curr_time 25 | params = dict(sorted(params.items())) 26 | params = { 27 | k : ''.join(filter(lambda chr: chr not in "!'()*", str(v))) 28 | for k, v 29 | in params.items() 30 | } 31 | 32 | query = urllib.parse.urlencode(params) 33 | params["w_rid"] = md5((query + mixin_key).encode()).hexdigest() 34 | 35 | return urllib.parse.urlencode(params) -------------------------------------------------------------------------------- /src/utils/common/style/font.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.config import Config 4 | 5 | from utils.common.enums import Platform 6 | 7 | class SysFont: 8 | sys_font_list: list = [] 9 | 10 | @classmethod 11 | def get_monospaced_font(cls): 12 | font_list = cls.get_sys_monospaced_font_list() 13 | 14 | for font in cls.get_preferred_font_list(): 15 | if font in font_list: 16 | return font 17 | 18 | @staticmethod 19 | def get_sys_monospaced_font_list(): 20 | return wx.FontEnumerator().GetFacenames(fixedWidthOnly = True) 21 | 22 | @staticmethod 23 | def get_preferred_font_list(): 24 | match Platform(Config.Sys.platform): 25 | case Platform.Windows: 26 | return [ 27 | "Consolas", 28 | "Cascadia Code" 29 | ] 30 | 31 | case Platform.Linux: 32 | return [ 33 | "Monospace", 34 | "DejaVu Sans Mono", 35 | "Ubuntu Mono", 36 | "Noto Mono" 37 | ] 38 | 39 | case Platform.macOS: 40 | return [ 41 | "Menlo" 42 | ] -------------------------------------------------------------------------------- /src/gui/component/slider/slider_box.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from gui.component.panel.panel import Panel 4 | from gui.component.slider.slider import Slider 5 | 6 | class SliderBox(Panel): 7 | def __init__(self, parent: wx.Window, label: str, min_value: int, max_value: int): 8 | self.label = label 9 | self.min_value = min_value 10 | self.max_value = max_value 11 | 12 | Panel.__init__(self, parent) 13 | 14 | self.init_UI() 15 | 16 | self.Bind_EVT() 17 | 18 | def init_UI(self): 19 | self.lab = wx.StaticText(self, -1, self.label) 20 | self.slider = Slider(self, 1, self.min_value, self.max_value) 21 | 22 | vbox = wx.BoxSizer(wx.VERTICAL) 23 | vbox.Add(self.lab, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 24 | vbox.Add(self.slider, 0, wx.EXPAND | wx.ALL & (~wx.LEFT) & (~wx.BOTTOM) & (~wx.TOP), self.FromDIP(6)) 25 | 26 | self.SetSizer(vbox) 27 | 28 | def Bind_EVT(self): 29 | self.slider.Bind(wx.EVT_SLIDER, self.onSliderEVT) 30 | 31 | def SetValue(self, value: int): 32 | self.slider.SetValue(value) 33 | 34 | self.onSliderEVT(0) 35 | 36 | def GetValue(self): 37 | return self.slider.GetValue() 38 | 39 | def onSliderEVT(self, event: wx.CommandEvent): 40 | self.lab.SetLabel(f"{self.label}:{self.slider.GetValue()}") 41 | -------------------------------------------------------------------------------- /src/utils/module/graph.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | class Graph: 4 | @classmethod 5 | def get_graph_json(cls, font_name: str): 6 | main_window = wx.FindWindowByName("main") 7 | 8 | node_list = main_window.parser.parser.info_json.get("node_list") 9 | 10 | return { 11 | "graph": cls.get_graph_viz_dot(font_name, node_list), 12 | "title": main_window.parser.parser.get_interact_title() 13 | } 14 | 15 | @staticmethod 16 | def get_graph_viz_dot(font_name: str, node_list: list): 17 | dot = [ 18 | "rankdir=LR;", 19 | f'node [shape = box, fontname = "{font_name}", width=1.5, height = 0.5];', 20 | f'edge [fontname="{font_name}"];' 21 | ] 22 | 23 | node_name = {} 24 | 25 | for node in node_list: 26 | node_name[node.cid] = node.title 27 | 28 | for option in node.options: 29 | if option.show: 30 | label = f' [label = "{option.name}"];' 31 | else: 32 | label = '' 33 | 34 | dot.append(f'"{node.cid}" -> "{option.target_node_cid}"{label}') 35 | 36 | result = "".join(dot) 37 | 38 | for key, value in node_name.items(): 39 | result = result.replace(str(key), value) 40 | 41 | return "digraph {" + result + "}" -------------------------------------------------------------------------------- /src/gui/window/settings/page.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from gui.component.panel.scrolled_panel import ScrolledPanel 5 | from gui.component.panel.panel import Panel 6 | 7 | _ = gettext.gettext 8 | 9 | class Page(Panel): 10 | def __init__(self, parent: wx.Window, name: str, index: int): 11 | from gui.window.main.main_v3 import MainWindow 12 | 13 | self.parent: MainWindow = parent.GetParent().GetParent() 14 | self.index = index 15 | 16 | Panel.__init__(self, parent, name = name) 17 | 18 | self.scrolled_panel = ScrolledPanel(self) 19 | self.panel = Panel(self.scrolled_panel) 20 | 21 | def init_UI(self): 22 | self.scrolled_panel.sizer.Add(self.panel, 0, wx.EXPAND) 23 | 24 | vbox = wx.BoxSizer(wx.VERTICAL) 25 | vbox.Add(self.scrolled_panel, 1, wx.EXPAND) 26 | 27 | self.SetSizer(vbox) 28 | 29 | self.scrolled_panel.Layout() 30 | 31 | def onValidate(self): 32 | pass 33 | 34 | def warn(self, message: str): 35 | wx.MessageDialog(self.GetParent(), _("保存设置失败\n\n所在页面:%s\n错误原因:%s" % (self.GetName(), message)), _("警告"), wx.ICON_WARNING).ShowModal() 36 | 37 | self.change_to_current_page() 38 | 39 | return True 40 | 41 | def change_to_current_page(self): 42 | parent: wx.Notebook = self.GetParent() 43 | 44 | parent.SetSelection(self.index) 45 | -------------------------------------------------------------------------------- /src/gui/component/choice/choice.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from gui.component.panel.scrolled_panel import ScrolledPanel 4 | 5 | class Choice(wx.Choice): 6 | def __init__(self, parent: wx.Window): 7 | wx.Choice.__init__(self, parent, -1) 8 | 9 | self.Bind_EVT() 10 | 11 | self.data: dict = {} 12 | 13 | def Bind_EVT(self): 14 | self.Bind(wx.EVT_MOUSEWHEEL, self.onMouseWheelEVT) 15 | 16 | def onMouseWheelEVT(self, event: wx.MouseEvent): 17 | parent = self.GetParent() 18 | 19 | while parent is not None: 20 | if isinstance(parent, ScrolledPanel): 21 | evt = wx.MouseEvent(event) 22 | wx.PostEvent(parent.GetEventHandler(), evt) 23 | 24 | event.StopPropagation() 25 | 26 | parent = parent.GetParent() 27 | 28 | def SetChoices(self, data: dict): 29 | self.data = data.copy() 30 | 31 | keys = list(data.keys()) 32 | 33 | self.Set(keys) 34 | 35 | for index, value in enumerate(keys): 36 | self.SetClientData(index, data.get(value)) 37 | 38 | self.SetSelection(0) 39 | 40 | def GetCurrentClientData(self): 41 | return self.GetClientData(self.GetSelection()) 42 | 43 | def SetCurrentSelection(self, client_data: int): 44 | value_list = list(self.data.values()) 45 | 46 | self.SetSelection(value_list.index(client_data)) -------------------------------------------------------------------------------- /src/utils/parse/extra/file/metadata/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import textwrap 3 | 4 | from utils.config import Config 5 | 6 | from utils.common.model.task_info import DownloadTaskInfo 7 | from utils.common.enums import TemplateType 8 | from utils.common.datetime_util import DateTime 9 | 10 | class Utils: 11 | @staticmethod 12 | def indent(text: str, prefix: str): 13 | return textwrap.indent(textwrap.dedent(text), prefix) 14 | 15 | @staticmethod 16 | def get_root_path(task_info: DownloadTaskInfo, root: bool = False): 17 | if task_info.template_type == TemplateType.Bangumi_strict.value or root: 18 | root_dir = task_info.download_path.removeprefix(task_info.download_base_path).split(os.sep)[1] 19 | 20 | return os.path.join(task_info.download_base_path, root_dir) 21 | else: 22 | return task_info.download_path 23 | 24 | @staticmethod 25 | def get_dateadded(timestamp: int): 26 | option = Config.Temp.scrape_option.get("video") 27 | 28 | if option.get("add_date") == 0: 29 | if option.get("add_date_source") == 0: 30 | date = DateTime.time_str("%Y-%m-%d %H:%M:%S") 31 | else: 32 | date = DateTime.time_str_from_timestamp(timestamp, "%Y-%m-%d %H:%M:%S") 33 | 34 | return """{date}""".format(date = date) 35 | else: 36 | return "" -------------------------------------------------------------------------------- /src/utils/common/io/file.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | from typing import List 4 | 5 | class File: 6 | MAX_REMOVE_ATTEMPS = 30 7 | 8 | @classmethod 9 | def remove_files(cls, file_path_list: List[str]): 10 | for file_path in file_path_list: 11 | cls.remove_file(file_path) 12 | 13 | @classmethod 14 | def remove_files_ex(cls, file_name_list: List[str], cwd: str): 15 | for file_name in file_name_list: 16 | cls.remove_file(os.path.join(cwd, file_name)) 17 | 18 | @staticmethod 19 | def remove_file(file_path: str): 20 | for i in range(File.MAX_REMOVE_ATTEMPS): 21 | if not os.path.exists(file_path): 22 | break 23 | 24 | try: 25 | os.remove(file_path) 26 | 27 | except: 28 | time.sleep(0.1) 29 | continue 30 | 31 | @staticmethod 32 | def rename_file(src: str, dst: str, cwd: str): 33 | os.rename(os.path.join(cwd, src), os.path.join(cwd, dst)) 34 | 35 | @staticmethod 36 | def find_duplicate_file(path: str): 37 | directory = os.path.dirname(path) 38 | file_name = os.path.basename(path) 39 | 40 | base, ext = os.path.splitext(file_name) 41 | 42 | index = 1 43 | 44 | while os.path.exists(os.path.join(directory, f"{base}_{index}{ext}")): 45 | index += 1 46 | 47 | return f"{base}_{index}{ext}" 48 | -------------------------------------------------------------------------------- /src/utils/module/web/ping.py: -------------------------------------------------------------------------------- 1 | import re 2 | import subprocess 3 | 4 | from utils.config import Config 5 | from utils.common.enums import Platform 6 | 7 | class Ping: 8 | @classmethod 9 | def get_ping_cmd(cls, cdn: str) -> str: 10 | match Platform(Config.Sys.platform): 11 | case Platform.Windows: 12 | return f"ping {cdn}" 13 | 14 | case Platform.Linux | Platform.macOS: 15 | return f"ping {cdn} -c 4" 16 | 17 | @classmethod 18 | def get_latency(cls, process): 19 | match Platform(Config.Sys.platform): 20 | case Platform.Windows: 21 | return re.findall(r"Average = ([0-9]*)", process.stdout) 22 | 23 | case Platform.Linux | Platform.macOS: 24 | _temp = re.findall(r"time=([0-9]*)", process.stdout) 25 | 26 | if _temp: 27 | return [int(sum(list(map(int, _temp))) / len(_temp))] 28 | else: 29 | return None 30 | 31 | @classmethod 32 | def run(cls, cdn: str): 33 | cmd = cls.get_ping_cmd(cdn) 34 | 35 | process = subprocess.run(cmd, stdout = subprocess.PIPE, stderr = subprocess.STDOUT, shell = True, text = True, encoding = "utf-8") 36 | 37 | latency = cls.get_latency(process) 38 | 39 | if latency: 40 | result = f"{latency[0]}ms" 41 | else: 42 | result = "请求超时" 43 | 44 | return result -------------------------------------------------------------------------------- /src/utils/parse/episode/popular.py: -------------------------------------------------------------------------------- 1 | from utils.common.enums import ParseType, TemplateType 2 | 3 | from utils.parse.episode.episode_v2 import EpisodeInfo, Filter 4 | 5 | class Popular: 6 | target_cid: int = 0 7 | 8 | @classmethod 9 | def parse_episodes(cls, info_json: dict, target_cid: int): 10 | cls.target_cid = target_cid 11 | EpisodeInfo.clear_episode_data() 12 | 13 | parent_title = info_json["config"]["label"] 14 | 15 | parent_pid = EpisodeInfo.add_item(EpisodeInfo.root_pid, EpisodeInfo.get_node_info(parent_title, label = "热榜")) 16 | 17 | for episode in info_json["list"]: 18 | episode["parent_title"] = parent_title 19 | 20 | EpisodeInfo.add_item(parent_pid, cls.get_entry_info(episode.copy())) 21 | 22 | Filter.episode_display_mode(reset = True) 23 | 24 | @classmethod 25 | def get_entry_info(cls, episode: dict): 26 | episode["pubtime"] = episode["pubdate"] 27 | episode["link"] = f"https://www/bilibili.com/video/{episode.get('bvid')}" 28 | episode["cover_url"] = episode["pic"] 29 | episode["type"] = ParseType.Video.value 30 | episode["zone"] = episode["tname"] 31 | episode["subzone"] = episode["tnamev2"] 32 | episode["up_name"] = episode["owner"]["name"] 33 | episode["up_mid"] = episode["owner"]["mid"] 34 | episode["template_type"] = TemplateType.Video_Normal.value 35 | 36 | return EpisodeInfo.get_entry_info(episode) -------------------------------------------------------------------------------- /src/gui/component/webview.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.config import Config 4 | from utils.common.enums import Platform 5 | from utils.common.exception import GlobalException, show_error_message_dialog 6 | 7 | from utils.module.web.page import WebPage 8 | 9 | class Webview: 10 | def __init__(self, parent): 11 | self.parent = parent 12 | 13 | import wx.html2 14 | 15 | self.browser = wx.html2.WebView = wx.html2.WebView.New(parent, -1, backend = WebPage.get_webview_backend()) 16 | 17 | def get_page(self, file_name: str): 18 | try: 19 | path = WebPage.get_static_file_path(file_name) 20 | 21 | match Platform(Config.Sys.platform): 22 | case Platform.Windows | Platform.Linux: 23 | self.browser.LoadURL(f"file://{path}") 24 | 25 | case Platform.macOS: 26 | self.browser.SetPage(self.osx_get_page(path), "") 27 | 28 | except FileNotFoundError: 29 | dlg = wx.MessageDialog(self.parent, f"文件不存在\n\nHTML 静态文件 ({file_name}) 不存在,无法调用 Webview 进行显示。", "警告", wx.ICON_WARNING) 30 | 31 | dlg.ShowModal() 32 | 33 | except Exception as e: 34 | raise GlobalException(callback = self.onError) from e 35 | 36 | def osx_get_page(self, path: str): 37 | with open(path, "r", encoding = "utf-8") as f: 38 | return f.read() 39 | 40 | def onError(self): 41 | show_error_message_dialog("无法读取静态文件", "在读取静态文件时出错") -------------------------------------------------------------------------------- /src/gui/id.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | class ID: 4 | REFRESH_MENU = wx.NewIdRef() 5 | LOGIN_MENU = wx.NewIdRef() 6 | LOGOUT_MENU = wx.NewIdRef() 7 | DEBUG_MENU = wx.NewIdRef() 8 | CONVERTER_MENU = wx.NewIdRef() 9 | LIVE_RECORDING_MENU = wx.NewIdRef() 10 | FORMAT_FACTORY_MENU = wx.NewIdRef() 11 | SETTINGS_MENU = wx.NewIdRef() 12 | 13 | CHECK_UPDATE_MENU = wx.NewIdRef() 14 | CHANGELOG_MENU = wx.NewIdRef() 15 | HELP_MENU = wx.NewIdRef() 16 | FEEDBACK_MENU = wx.NewIdRef() 17 | COMMUNITY_MENU = wx.NewIdRef() 18 | ABOUT_MENU = wx.NewIdRef() 19 | 20 | EPISODE_SINGLE_MENU = wx.NewIdRef() 21 | EPISODE_IN_SECTION_MENU = wx.NewIdRef() 22 | EPISODE_ALL_SECTIONS_MENU = wx.NewIdRef() 23 | EPISODE_FULL_NAME_MENU = wx.NewIdRef() 24 | 25 | EPISODE_LIST_VIEW_COVER_MENU = wx.NewIdRef() 26 | EPISODE_LIST_COPY_TITLE_MENU = wx.NewIdRef() 27 | EPISODE_LIST_COPY_URL_MENU = wx.NewIdRef() 28 | EPISODE_LIST_OPEN_IN_BROWSER_MENU = wx.NewIdRef() 29 | EPISODE_LIST_EDIT_TITLE_MENU = wx.NewIdRef() 30 | EPISODE_LIST_CHECK_MENU = wx.NewIdRef() 31 | EPISODE_LIST_COLLAPSE_MENU = wx.NewIdRef() 32 | EPISODE_LIST_SELECT_BATCH_MENU = wx.NewIdRef() 33 | EPISODE_LIST_REFRESH_MEDIA_INFO_MENU = wx.NewIdRef() 34 | 35 | SUPPORTTED_URL_MENU = wx.NewIdRef() 36 | HISTORY_MENU = wx.NewIdRef() 37 | 38 | HISTORY_EMPTY = wx.NewIdRef() 39 | HISTORY_ITEM = wx.NewIdRef() 40 | HISTORY_MORE = wx.NewIdRef() 41 | HISTORY_CLEAR = wx.NewIdRef() -------------------------------------------------------------------------------- /src/utils/parse/live_stream.py: -------------------------------------------------------------------------------- 1 | from utils.config import Config 2 | 3 | from utils.common.model.live_room_info import LiveRoomInfo 4 | from utils.common.request import RequestUtils 5 | 6 | from utils.parse.parser import Parser 7 | 8 | class LiveStream(Parser): 9 | def __init__(self, room_info: LiveRoomInfo): 10 | self.room_info = room_info 11 | 12 | def get_live_stream_url(self): 13 | params = { 14 | "room_id": self.room_info.room_id, 15 | "protocol": 0, 16 | "format": 0, 17 | "codec": "0,1", 18 | "qn": self.room_info.quality 19 | } 20 | 21 | url = f"https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo?{self.url_encode(params)}" 22 | 23 | resp = self.request_get(url, headers = RequestUtils.get_headers(referer_url = self.bilibili_url, sessdata = Config.User.SESSDATA)) 24 | 25 | data = self.json_get(resp, "data") 26 | 27 | stream_info = data["playurl_info"]["playurl"]["stream"][0]["format"][0]["codec"] 28 | 29 | for entry in stream_info: 30 | if entry["current_qn"] == self.room_info.quality and entry["codec_name"] == self.room_info.codec: 31 | for url_entry in entry["url_info"]: 32 | return url_entry["host"] + entry["base_url"] + url_entry["extra"] 33 | 34 | def get_recorder_info(self): 35 | return { 36 | "referer_url": self.bilibili_url, 37 | "stream_url": self.get_live_stream_url() 38 | } -------------------------------------------------------------------------------- /src/utils/parse/episode/list.py: -------------------------------------------------------------------------------- 1 | from utils.common.enums import ParseType, TemplateType 2 | 3 | from utils.parse.episode.episode_v2 import EpisodeInfo, Filter 4 | 5 | class List: 6 | target_bvid: str = "" 7 | 8 | @classmethod 9 | def parse_episodes(cls, info_json: dict, target_bvid: str): 10 | cls.target_bvid = target_bvid 11 | EpisodeInfo.parser = cls 12 | 13 | EpisodeInfo.clear_episode_data() 14 | 15 | for section_title, entry in info_json["archives"].items(): 16 | section_pid = EpisodeInfo.add_item(EpisodeInfo.root_pid, EpisodeInfo.get_node_info(section_title, label = "合集")) 17 | 18 | for episode in entry["episodes"]: 19 | episode["collection_title"] = section_title 20 | 21 | EpisodeInfo.add_item(section_pid, cls.get_entry_info(episode.copy())) 22 | 23 | Filter.episode_display_mode(reset = True) 24 | 25 | @classmethod 26 | def get_entry_info(cls, episode: dict): 27 | episode["pubtime"] = episode["pubdate"] 28 | episode["link"] = f"https://www.bilibili.com/video/{episode.get('bvid')}" 29 | episode["cover_url"] = episode.get("pic") 30 | episode["type"] = ParseType.Video.value 31 | episode["template_type"] = TemplateType.Video_Collection.value 32 | 33 | return EpisodeInfo.get_entry_info(episode) 34 | 35 | @classmethod 36 | def condition_single(cls, episode: dict): 37 | return episode.get("item_type") == "item" and episode.get("bvid") == cls.target_bvid 38 | -------------------------------------------------------------------------------- /src/utils/parse/extra/extra_v3.py: -------------------------------------------------------------------------------- 1 | from utils.common.model.task_info import DownloadTaskInfo 2 | from utils.common.model.callback import Callback 3 | from utils.common.exception import GlobalException 4 | from utils.common.enums import StatusCode 5 | 6 | from utils.parse.extra.danmaku import DanmakuParser 7 | from utils.parse.extra.subtitle import SubtitleParser 8 | from utils.parse.extra.cover import CoverParser 9 | from utils.parse.extra.metadata import MetadataParser 10 | 11 | class ExtraParser: 12 | @staticmethod 13 | def download(task_info: DownloadTaskInfo, callback: Callback): 14 | try: 15 | if task_info.extra_option.get("download_danmaku_file"): 16 | parser = DanmakuParser(task_info) 17 | parser.parse() 18 | 19 | if task_info.extra_option.get("download_subtitle_file"): 20 | parser = SubtitleParser(task_info) 21 | parser.parse() 22 | 23 | if task_info.extra_option.get("download_cover_file"): 24 | parser = CoverParser(task_info) 25 | parser.parse() 26 | 27 | if task_info.extra_option.get("download_metadata_file"): 28 | parser = MetadataParser(task_info) 29 | parser.parse() 30 | 31 | task_info.total_file_size = parser.total_file_size 32 | task_info.total_downloaded_size = task_info.total_file_size 33 | 34 | callback.onSuccess() 35 | 36 | except Exception as e: 37 | raise GlobalException(code = StatusCode.DownloadError.value, callback = callback.onError) from e -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 创建错误报告 2 | description: 此处只受理 Bug 报告 3 | title: "[Bug] 描述问题的标题" 4 | labels: 故障(bug) 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | > 💡 请务必遵循标题格式。例如:[Bug] 链接无法下载 10 | - type: checkboxes 11 | attributes: 12 | label: 议题条件 13 | description: 在你开始之前,请花几分钟时间确保你已如实完成以下工作,以便让我们更高效地沟通。 14 | options: 15 | - label: 我确认即使在最新正式版中存在该问题。 16 | required: true 17 | - label: 我确认已在 [Issues](/ScottSloan/Bili23-Downloader/issues) 进行搜索并确认没有人反馈过相同的Bug。 18 | required: true 19 | - label: 我确认已经总结议题内容并按规范设置此Issue的标题 20 | required: true 21 | - type: input 22 | attributes: 23 | label: 系统环境 24 | description: 在哪个平台(Windows/Linux/MacOS)上运行?如果是直接使用python运行,则尽量同时提供Python版本 25 | placeholder: 如:Windows 11 Python 3.11.9 26 | validations: 27 | required: true 28 | - type: input 29 | attributes: 30 | label: 使用版本 31 | description: 请提供您当前使用的 Bili23 Downloader 版本号。 32 | placeholder: 如:v1.60.0 33 | validations: 34 | required: true 35 | - type: textarea 36 | attributes: 37 | label: 问题描述 38 | description: 请提供详细的问题描述和操作步骤等信息,以便我们也能够更轻松地将问题复现。 39 | validations: 40 | required: true 41 | - type: textarea 42 | attributes: 43 | label: 错误日志 44 | description: 如果有错误日志,请提供以便更好地定位问题。 45 | render: auto 46 | - type: textarea 47 | attributes: 48 | label: 截图补充 49 | description: 如上述仍然无法准确地表述问题,可提供必要的截图(可直接粘贴上传) 50 | - type: input 51 | attributes: 52 | label: 链接 53 | description: 如果是解析、下载失败的问题,请附上链接。 54 | -------------------------------------------------------------------------------- /src/gui/dialog/setting/color_picker.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from gui.component.window.dialog import Dialog 4 | from gui.component.panel.panel import Panel 5 | from gui.component.spinctrl.label_spinctrl import LabelSpinCtrl 6 | 7 | class RGBStaticBox(Panel): 8 | def __init__(self, parent: wx.Window): 9 | Panel.__init__(self, parent) 10 | 11 | self.init_UI() 12 | 13 | def init_UI(self): 14 | rgb_box = wx.StaticBox(self, -1, "RGB 颜色") 15 | 16 | self.r_spinctrl = LabelSpinCtrl(rgb_box, "R", value = 0, unit = "", min = 0, max = 255) 17 | self.g_spinctrl = LabelSpinCtrl(rgb_box, "G", value = 0, unit = "", min = 0, max = 255) 18 | self.b_spinctrl = LabelSpinCtrl(rgb_box, "B", value = 0, unit = "", min = 0, max = 255) 19 | 20 | flex_grid_box = wx.FlexGridSizer(1, 3, 0, 0) 21 | flex_grid_box.Add(self.r_spinctrl, 0, wx.ALIGN_RIGHT) 22 | flex_grid_box.Add(self.g_spinctrl, 0, wx.ALIGN_RIGHT) 23 | flex_grid_box.Add(self.b_spinctrl, 0, wx.ALIGN_RIGHT) 24 | 25 | rgb_sbox = wx.StaticBoxSizer(rgb_box, wx.VERTICAL) 26 | rgb_sbox.Add(flex_grid_box, 0, wx.EXPAND) 27 | 28 | self.SetSizer(rgb_sbox) 29 | 30 | class ColorPickerDialog(Dialog): 31 | def __init__(self, parent: wx.Window): 32 | Dialog.__init__(self, parent, "选择颜色") 33 | 34 | self.init_UI() 35 | 36 | self.CenterOnParent() 37 | 38 | def init_UI(self): 39 | self.rgb_sbox = RGBStaticBox(self) 40 | 41 | vbox = wx.BoxSizer(wx.VERTICAL) 42 | vbox.Add(self.rgb_sbox, 0, wx.ALL | wx.EXPAND, self.FromDIP(6)) 43 | 44 | self.SetSizerAndFit(vbox) -------------------------------------------------------------------------------- /src/utils/parse/extra/file/danamku_xml.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | from typing import List 3 | 4 | from utils.common.formatter.formatter import FormatUtils 5 | 6 | class DanmakuXMLFile: 7 | def __init__(self, json_data: List[dict], cid: int): 8 | self.json_data = json_data 9 | self.cid = cid 10 | 11 | def get_contents(self): 12 | contents = textwrap.dedent("""\ 13 | 14 | 15 | chat.bilibili.com 16 | {cid} 17 | 0 18 | 100000 19 | 0 20 | 0 21 | k-v 22 | {d_elements} 23 | 24 | """.format(cid = self.cid, d_elements = self.get_d_elements())) 25 | 26 | return contents 27 | 28 | def get_d_elements(self): 29 | p_elements = [f"""{entry.get("content")}""" for entry in self.json_data] 30 | 31 | return "\n ".join(p_elements) 32 | 33 | def get_p_attr(self, entry: dict): 34 | return ",".join([ 35 | FormatUtils.format_xml_timestamp(entry.get("progress") / 1000), 36 | str(entry.get("mode")), 37 | str(entry.get("fontsize")), 38 | str(entry.get("color")), 39 | str(entry.get("ctime")), 40 | "0", 41 | str(entry.get("midHash")), 42 | str(entry.get("id")), 43 | str(entry.get("weight")) 44 | ]) 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.70.4 (2025-12-24) 2 | ### 优化 3 | * 移除请求超时的 CDN 节点 4 | 5 | > [!TIP] 6 | > 本次更新为热修复(hotfix),仅包含紧急问题修复。其余已知问题将在后续版本中陆续修复,请关注后续更新。 7 | 8 |
9 | 点此展开 1.70.3 到 1.70.0 版本的更新内容 10 | 11 | ## 1.70.3 (2025-12-11) 12 | ### 修复 13 | * 修复下载个人主页投稿视频时重复下载的问题 14 | * 修复将网络路径设置为下载目录时,提示合并失败的问题 15 | * 修复部分剧集 nfo 元数据字段值为 None 以及存在多余空行的问题 16 | * 修复部分情况下视频合并失败的问题 17 | 18 | ## 1.70.2 (2025-11-25) 19 | ### 新增 20 | * 新增部分设置提示 21 | * 新增 Linux 系统 deb 包构建 22 | 23 | ### 优化 24 | * 优化程序更新时的体验 25 | * 调整 Linux 默认下载目录 26 | 27 | ### 修复 28 | * 修复部分情况下缺少评分信息导致无法下载 nfo 文件的问题 29 | * 修复短信登录后程序异常崩溃的问题 30 | * 修复无音频的视频下载后提示合并失败的问题 31 | * 修复下载个人主页视频时只下载一部分的问题 32 | * 修复偶然情况下无法正确获取下载文件大小而导致下载失败或合成失败的问题 33 | 34 | ## 1.70.1 (2025-10-29) 35 | ### 新增 36 | * 支持历史记录功能 37 | * 新增在文件名前添加独立的序号快捷选项 38 | 39 | ### 修复 40 | * 修复部分情况下无法删除下载任务的问题 41 | * 修复将 m4a 转换为 mp4 功能无法关闭的问题 42 | * 修复无法下载 Hi-Res 无损音频的问题 43 | 44 | ## 1.70.0 (2025-10-21) 45 | ### 新增 46 | * 支持设置 ASS 弹幕显示区域、不透明度、弹幕速度、弹幕密度和防遮挡字幕 47 | * 支持设置 ASS 弹幕/字幕缩放、旋转和字符间距 48 | * 支持下载 NFO、JSON 格式元数据 49 | * 支持设置画质、音质、编码格式优先级 50 | * 支持按住 Shift 键批量选择剧集列表项目 51 | * 支持设置收藏夹和个人主页文件夹名称模板 52 | * 重复下载时支持跳过下载已存在的项目 53 | * 下载剧集时,支持严格规范命名,便于刮削软件识别 54 | * 下载 m4a 格式的音频时,支持转换为 mp3 文件 55 | * 添加英语语言支持 56 | 57 | ### 优化 58 | * 优化 ASS 滚动弹幕移动速度算法 59 | * 优化部分界面显示效果 60 | * 优化解析收藏夹和个人主页时处理逻辑,避免风控拦截 61 | * 优化解析速度 62 | * 优化下载大文件时的稳定性 63 | * 调整部分画质选项名称,例如 1080P+ -> 1080P 高码率、1080P60 -> 1080P 60帧 64 | 65 | ### 修复 66 | * 修复批量单独下载弹幕/字幕/封面时不受并行下载设置约束的问题 67 | * 修复 Linux 平台 ASS 字幕颜色设置显示异常问题 68 | * 修复安装程序在 Program Files 文件夹下无权访问配置文件的问题 69 | * 修复编辑文件名模板时设置不生效的问题 70 | * 修复解析剧集时出错信息显示异常的问题 71 | * 修复合并音视频选项设置不生效的问题 72 | * 修复部分情况下处理重名文件失败的问题 73 | * 修复部分情况下仅下载 flac 格式音频失败的问题 74 | * 修复 Linux 平台无法显示剧集列表右键菜单的问题 75 | 76 |
-------------------------------------------------------------------------------- /src/gui/component/menu/episode_option.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.config import Config 5 | from utils.common.enums import EpisodeDisplayType 6 | 7 | from gui.id import ID 8 | 9 | _ = gettext.gettext 10 | 11 | class EpisodeOptionMenu(wx.Menu): 12 | def __init__(self, enable_in_section_option: bool = True): 13 | wx.Menu.__init__(self) 14 | 15 | single_menuitem = wx.MenuItem(self, ID.EPISODE_SINGLE_MENU, _("显示单个视频"), kind = wx.ITEM_RADIO) 16 | in_section_menuitem = wx.MenuItem(self, ID.EPISODE_IN_SECTION_MENU, _("显示视频所在的列表"), kind = wx.ITEM_RADIO) 17 | all_section_menuitem = wx.MenuItem(self, ID.EPISODE_ALL_SECTIONS_MENU, _("显示全部相关视频"), kind = wx.ITEM_RADIO) 18 | show_episode_full_name = wx.MenuItem(self, ID.EPISODE_FULL_NAME_MENU, _("显示完整剧集名称"), kind = wx.ITEM_CHECK) 19 | 20 | self.Append(wx.NewIdRef(), _("剧集列表显示设置")) 21 | self.AppendSeparator() 22 | self.Append(single_menuitem) 23 | self.Append(in_section_menuitem) 24 | self.Append(all_section_menuitem) 25 | self.AppendSeparator() 26 | self.Append(show_episode_full_name) 27 | 28 | match EpisodeDisplayType(Config.Misc.episode_display_mode): 29 | case EpisodeDisplayType.Single: 30 | self.Check(ID.EPISODE_SINGLE_MENU, True) 31 | 32 | case EpisodeDisplayType.In_Section: 33 | self.Check(ID.EPISODE_IN_SECTION_MENU, True) 34 | 35 | case EpisodeDisplayType.All: 36 | self.Check(ID.EPISODE_ALL_SECTIONS_MENU, True) 37 | 38 | self.Check(ID.EPISODE_FULL_NAME_MENU, Config.Misc.show_episode_full_name) 39 | 40 | self.Enable(ID.EPISODE_IN_SECTION_MENU, enable_in_section_option) -------------------------------------------------------------------------------- /src/gui/dialog/setting/custom_user_agent.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.config import Config 5 | 6 | from gui.component.window.dialog import Dialog 7 | 8 | _ = gettext.gettext 9 | 10 | class CustomUADialog(Dialog): 11 | def __init__(self, parent): 12 | Dialog.__init__(self, parent, _("自定义 User-Agent")) 13 | 14 | self.init_UI() 15 | 16 | self.init_utils() 17 | 18 | self.CenterOnParent() 19 | 20 | def init_UI(self): 21 | ua_lab = wx.StaticText(self, -1, "User-Agent") 22 | 23 | self.custom_ua_box = wx.TextCtrl(self, -1, size = self.FromDIP((400, 64)), style = wx.TE_MULTILINE | wx.TE_WORDWRAP) 24 | 25 | self.ok_btn = wx.Button(self, wx.ID_OK, _("确定"), size = self.get_scaled_size((80, 30))) 26 | self.cancel_btn = wx.Button(self, wx.ID_CANCEL, _("取消"), size = self.get_scaled_size((80, 30))) 27 | 28 | bottom_hbox = wx.BoxSizer(wx.HORIZONTAL) 29 | bottom_hbox.AddStretchSpacer(1) 30 | bottom_hbox.Add(self.ok_btn, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 31 | bottom_hbox.Add(self.cancel_btn, 0, wx.ALL & (~wx.TOP) & (~wx.LEFT), self.FromDIP(6)) 32 | 33 | vbox = wx.BoxSizer(wx.VERTICAL) 34 | vbox.Add(ua_lab, 0, wx.ALL, self.FromDIP(6)) 35 | vbox.Add(self.custom_ua_box, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 36 | vbox.Add(bottom_hbox, 0, wx.EXPAND) 37 | 38 | self.SetSizerAndFit(vbox) 39 | 40 | def init_utils(self): 41 | self.custom_ua_box.SetValue(Config.Advanced.user_agent) 42 | 43 | def onOKEVT(self): 44 | if not self.custom_ua_box.GetValue(): 45 | wx.MessageDialog(self, _("User-Agent 无效\n\nUser-Agent 不能为空"), _("警告"), wx.ICON_WARNING).ShowModal() 46 | return 47 | 48 | Config.Temp.user_agent = self.custom_ua_box.GetValue() -------------------------------------------------------------------------------- /src/gui/window/graph.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.config import Config 4 | from utils.module.graph import Graph 5 | 6 | from gui.component.webview import Webview 7 | from gui.component.window.frame import Frame 8 | 9 | class GraphWindow(Frame): 10 | def __init__(self, parent): 11 | Frame.__init__(self, parent, "Graph Viewer", style = self.get_window_style()) 12 | 13 | self.init_UI() 14 | 15 | self.SetSize(self.FromDIP((960, 540))) 16 | 17 | self.init_utils() 18 | 19 | self.Bind_EVT() 20 | 21 | self.CenterOnParent() 22 | 23 | def init_UI(self): 24 | self.webview = Webview(self) 25 | 26 | self.webview.get_page("graph.html") 27 | self.webview.browser.EnableAccessToDevTools(True) 28 | 29 | vbox = wx.BoxSizer(wx.VERTICAL) 30 | vbox.Add(self.webview.browser, 1, wx.ALL | wx.EXPAND) 31 | 32 | self.SetSizerAndFit(vbox) 33 | 34 | def Bind_EVT(self): 35 | self.webview.browser.Bind(wx.html2.EVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, self.onMessageEVT) 36 | 37 | self.webview.browser.Bind(wx.html2.EVT_WEBVIEW_LOADED, self.onLoadedEVT) 38 | 39 | def init_utils(self): 40 | self.webview.browser.AddScriptMessageHandler("MainApplication") 41 | 42 | def onLoadedEVT(self, event): 43 | data = Graph.get_graph_json(self.GetFont().GetFaceName()) 44 | 45 | self.webview.browser.RunScriptAsync(f"""initGraph('{data.get("graph")}', '{data.get("title")}');""") 46 | 47 | def onMessageEVT(self, event): 48 | msg = event.GetString() 49 | 50 | if msg == "fullscreen": 51 | self.Maximize(not self.IsMaximized()) 52 | 53 | def get_window_style(self): 54 | style = wx.DEFAULT_FRAME_STYLE 55 | 56 | if Config.Basic.always_on_top: 57 | style |= wx.STAY_ON_TOP 58 | 59 | return style -------------------------------------------------------------------------------- /src/gui/component/button/bitmap_button.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from wx.lib.buttons import ThemedGenBitmapButton 3 | 4 | from utils.config import Config 5 | from utils.common.enums import Platform 6 | 7 | if Platform(Config.Sys.platform) == Platform.Windows: 8 | impl = ThemedGenBitmapButton 9 | else: 10 | impl = wx.BitmapButton 11 | 12 | class BitmapButton(impl): 13 | def __init__(self, parent: wx.Window, bitmap: wx.Bitmap, size = None, enable: bool = True, tooltip: str = ""): 14 | if not size: 15 | size = self.GetSizeEx(parent) 16 | 17 | impl.__init__(self, parent, -1, bitmap = bitmap, size = size, style = self.get_style()) 18 | 19 | self.Enable(enable) 20 | self.SetToolTip(tooltip) 21 | 22 | def Bind(self, event, handler, source = None, id = wx.ID_ANY, id2 = wx.ID_ANY): 23 | return super().Bind(event, handler, source, id, id2) 24 | 25 | def get_style(self): 26 | match Platform(Config.Sys.platform): 27 | case Platform.Windows: 28 | return 0 29 | 30 | case Platform.Linux | Platform.macOS: 31 | return wx.BORDER_NONE 32 | 33 | def SetBitmap(self, bitmap: wx.Bitmap, dir = wx.LEFT): 34 | match Platform(Config.Sys.platform): 35 | case Platform.Windows: 36 | self.SetBitmapLabel(bitmap) 37 | 38 | case Platform.Linux | Platform.macOS: 39 | return super().SetBitmap(bitmap, dir) 40 | 41 | def SetToolTip(self, tip: str): 42 | return super().SetToolTip(tip) 43 | 44 | def GetSizeEx(self, parent: wx.Window): 45 | match Platform(Config.Sys.platform): 46 | case Platform.Windows | Platform.macOS: 47 | return parent.FromDIP((24, 24)) 48 | 49 | case Platform.Linux: 50 | return parent.FromDIP((24, 24)) -------------------------------------------------------------------------------- /src/utils/common/history.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from utils.config import Config 4 | from utils.common.datetime_util import DateTime 5 | 6 | class History: 7 | def __init__(self): 8 | self.history_json: list[dict[str, str]] = [] 9 | 10 | self.load() 11 | 12 | def add(self, url: str, title: str, category: str): 13 | def find(): 14 | for entry in self.history_json: 15 | if entry.get("url") == url: 16 | entry["time"] = DateTime.get_timestamp() 17 | 18 | self.history_json.remove(entry) 19 | 20 | return entry 21 | 22 | if not Config.Basic.enable_history: 23 | return 24 | 25 | if not (entry := find()): 26 | entry = { 27 | "time": DateTime.get_timestamp(), 28 | "url": url, 29 | "title": title, 30 | "category": category 31 | } 32 | 33 | self.history_json.append(entry) 34 | 35 | self.save() 36 | 37 | def clear(self): 38 | self.history_json.clear() 39 | 40 | self.save() 41 | 42 | def get(self): 43 | return self.history_json 44 | 45 | def get_json_data(self): 46 | json_data = { 47 | "history": self.history_json 48 | } 49 | 50 | return json.dumps(json_data, ensure_ascii = False, indent = 4) 51 | 52 | def save(self): 53 | with open(Config.APP.history_file_path, "w", encoding = "utf-8") as f: 54 | f.write(self.get_json_data()) 55 | 56 | def load(self): 57 | try: 58 | with open(Config.APP.history_file_path, "r", encoding = "utf-8") as f: 59 | json_data = json.load(f) 60 | 61 | self.history_json = json_data.get("history", []) 62 | 63 | except Exception: 64 | self.save() -------------------------------------------------------------------------------- /src/gui/component/staticbox/border.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from gui.component.panel.panel import Panel 5 | 6 | from gui.component.spinctrl.label_spinctrl import LabelSpinCtrl 7 | 8 | _ = gettext.gettext 9 | 10 | class BorderStaticBox(Panel): 11 | def __init__(self, parent): 12 | Panel.__init__(self, parent) 13 | 14 | self.init_UI() 15 | 16 | def init_UI(self): 17 | border_box = wx.StaticBox(self, -1, _("边框")) 18 | 19 | self.border_box = LabelSpinCtrl(border_box, _("边框"), 2.0, "px", wx.HORIZONTAL, float = True) 20 | self.border_box.SetToolTip(_("边框宽度")) 21 | self.shadow_box = LabelSpinCtrl(border_box, _("阴影"), 2.0, "px", wx.HORIZONTAL, float = True) 22 | self.shadow_box.SetToolTip(_("阴影距离")) 23 | self.non_alpha_chk = wx.CheckBox(border_box, -1, _("不透明背景")) 24 | self.non_alpha_chk.SetToolTip(_("文字背景不透明")) 25 | 26 | vbox = wx.BoxSizer(wx.HORIZONTAL) 27 | vbox.Add(self.border_box, 0, wx.ALL & (~wx.TOP) & (~wx.BOTTOM) | wx.ALIGN_CENTER, self.FromDIP(6)) 28 | vbox.AddSpacer(self.FromDIP(10)) 29 | vbox.Add(self.shadow_box, 0, wx.ALL & (~wx.LEFT) & (~wx.TOP) & (~wx.BOTTOM) | wx.ALIGN_CENTER, self.FromDIP(6)) 30 | 31 | border_sbox = wx.StaticBoxSizer(border_box, wx.VERTICAL) 32 | border_sbox.Add(vbox, 0, wx.EXPAND) 33 | border_sbox.Add(self.non_alpha_chk, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 34 | 35 | self.SetSizer(border_sbox) 36 | 37 | def init_data(self, data: dict): 38 | self.border_box.SetValue(data.get("border")) 39 | self.shadow_box.SetValue(data.get("shadow")) 40 | self.non_alpha_chk.SetValue(data.get("non_alpha", False)) 41 | 42 | def get_option(self): 43 | return { 44 | "border": self.border_box.GetValue(), 45 | "shadow": self.shadow_box.GetValue(), 46 | "non_alpha": self.non_alpha_chk.GetValue() 47 | } -------------------------------------------------------------------------------- /src/gui/component/misc/taskbar_icon.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import sys 3 | import wx.adv 4 | 5 | from utils.common.style.icon_v4 import Icon, IconID 6 | from utils.config import Config 7 | 8 | class TaskBarIcon(wx.adv.TaskBarIcon): 9 | def __init__(self): 10 | wx.adv.TaskBarIcon.__init__(self) 11 | 12 | self.SetIcon(wx.BitmapBundle.FromBitmap(Icon.get_icon_bitmap(IconID.App_Default)), Config.APP.name) 13 | 14 | self.init_id() 15 | 16 | self.Bind_EVT() 17 | 18 | def CreatePopupMenu(self): 19 | menu = wx.Menu() 20 | 21 | main_menuitem = wx.MenuItem(menu, self.ID_MAIN_MENU, "主界面(&M)") 22 | download_menuitem = wx.MenuItem(menu, self.ID_DOWNLOAD_MENU, "下载管理(&D)") 23 | exit_menuitem = wx.MenuItem(menu, self.ID_EXIT_MENU, "退出(&X)") 24 | 25 | menu.Append(main_menuitem) 26 | menu.Append(download_menuitem) 27 | menu.AppendSeparator() 28 | menu.Append(exit_menuitem) 29 | 30 | return menu 31 | 32 | def init_id(self): 33 | self.ID_MAIN_MENU = wx.NewIdRef() 34 | self.ID_DOWNLOAD_MENU = wx.NewIdRef() 35 | self.ID_EXIT_MENU = wx.NewIdRef() 36 | 37 | def Bind_EVT(self): 38 | self.Bind(wx.EVT_MENU, self.onMenuEVT) 39 | 40 | def onMenuEVT(self, event: wx.MenuEvent): 41 | main_window = wx.FindWindowByName("main") 42 | 43 | match event.GetId(): 44 | case self.ID_MAIN_MENU: 45 | self.switch_window(main_window) 46 | 47 | case self.ID_DOWNLOAD_MENU: 48 | self.switch_window(main_window.download_window) 49 | 50 | case self.ID_EXIT_MENU: 51 | sys.exit() 52 | 53 | def switch_window(self, frame: wx.Frame): 54 | if frame.IsIconized(): 55 | frame.Iconize(False) 56 | 57 | elif not frame.IsShown(): 58 | frame.Show() 59 | frame.CenterOnParent() 60 | 61 | frame.Raise() 62 | -------------------------------------------------------------------------------- /src/gui/component/slider/label_slider.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from gui.component.panel.panel import Panel 4 | from gui.component.slider.slider import Slider 5 | 6 | class LabelSlider(Panel): 7 | def __init__(self, parent: wx.Window, data: dict): 8 | Panel.__init__(self, parent) 9 | 10 | self.label: str = data.get("label") 11 | self.value: int = data.get("value") 12 | self.min_value: int = data.get("min_value") 13 | self.max_value: int = data.get("max_value") 14 | self.data: dict[int, str] = data.get("data") 15 | 16 | self.init_UI() 17 | 18 | self.Bind_EVT() 19 | 20 | def init_UI(self): 21 | self.label = wx.StaticText(self, -1, self.label) 22 | self.slider = Slider(self, value = self.value, min_value = self.min_value, max_value = self.max_value) 23 | self.indicator_lab = wx.StaticText(self, -1, self.get_indicator(self.value)) 24 | 25 | self.slider.Bind(wx.EVT_SLIDER, self.onSliderEVT) 26 | 27 | sizer = wx.BoxSizer(wx.HORIZONTAL) 28 | sizer.Add(self.label, 0, wx.ALL & (~wx.BOTTOM) | wx.ALIGN_CENTER, self.FromDIP(5)) 29 | sizer.Add(self.slider, 1, wx.ALL & (~wx.LEFT) & (~wx.BOTTOM) | wx.ALIGN_CENTER, self.FromDIP(6)) 30 | sizer.Add(self.indicator_lab, 0, wx.ALL & (~wx.LEFT) & (~wx.BOTTOM) | wx.ALIGN_CENTER, self.FromDIP(6)) 31 | 32 | self.SetSizer(sizer) 33 | 34 | def Bind_EVT(self): 35 | self.slider.Bind(wx.EVT_SLIDER, self.onSliderEVT) 36 | 37 | def onSliderEVT(self, event: wx.CommandEvent): 38 | value = self.slider.GetValue() 39 | 40 | self.indicator_lab.SetLabel(self.get_indicator(value)) 41 | 42 | def get_indicator(self, value: int): 43 | return self.data.get(value, f"{value}%") 44 | 45 | def SetValue(self, value: int): 46 | self.slider.SetValue(value) 47 | 48 | self.onSliderEVT(0) 49 | 50 | def GetValue(self): 51 | return self.slider.GetValue() -------------------------------------------------------------------------------- /src/gui/component/button/flat_button.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.common.style.icon_v4 import Icon, IconID 4 | 5 | from gui.component.panel.panel import Panel 6 | from gui.component.staticbitmap.staticbitmap import StaticBitmap 7 | 8 | class FlatButton(Panel): 9 | def __init__(self, parent, label: str, icon_id: IconID, split: bool = False): 10 | self.label, self.icon_id, self.split = label, icon_id, split 11 | 12 | Panel.__init__(self, parent) 13 | 14 | self.init_UI() 15 | 16 | self.Bind_EVT() 17 | 18 | def init_UI(self): 19 | self.SetCursor(wx.Cursor(wx.CURSOR_HAND)) 20 | 21 | if self.split: 22 | self.split_line = wx.StaticLine(self, -1, style = wx.LI_VERTICAL) 23 | 24 | self.btn_icon = StaticBitmap(self, bmp = Icon.get_icon_bitmap(self.icon_id), size = self.FromDIP((16, 16))) 25 | self.btn_icon.SetCursor(wx.Cursor(wx.CURSOR_HAND)) 26 | 27 | self.btn_lab = wx.StaticText(self, -1, self.label) 28 | self.btn_lab.SetCursor(wx.Cursor(wx.CURSOR_HAND)) 29 | 30 | hbox = wx.BoxSizer(wx.HORIZONTAL) 31 | 32 | if self.split: 33 | hbox.Add(self.split_line, 0, wx.ALL & (~wx.LEFT) & (~wx.RIGHT) | wx.EXPAND, self.FromDIP(10)) 34 | 35 | hbox.Add(self.btn_icon, 0, wx.ALL | wx.ALIGN_CENTER, self.FromDIP(6)) 36 | hbox.Add(self.btn_lab, 0, wx.ALL & (~wx.LEFT) | wx.ALIGN_CENTER, self.FromDIP(6)) 37 | 38 | self.SetSizerAndFit(hbox) 39 | 40 | def Bind_EVT(self): 41 | self.Bind(wx.EVT_LEFT_DOWN, self.onClickEVT) 42 | self.btn_icon.Bind(wx.EVT_LEFT_DOWN, self.onClickEVT) 43 | self.btn_lab.Bind(wx.EVT_LEFT_DOWN, self.onClickEVT) 44 | 45 | def onClickEVT(self, event): 46 | self.onClickCustomEVT() 47 | 48 | def onClickCustomEVT(self): 49 | pass 50 | 51 | def setToolTip(self, tip: str): 52 | self.SetToolTip(tip) 53 | self.btn_icon.SetToolTip(tip) 54 | self.btn_lab.SetToolTip(tip) -------------------------------------------------------------------------------- /src/utils/common/model/data_type.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from utils.config import Config 4 | 5 | from utils.common.io.file import File 6 | from utils.common.enums import OverrideOption 7 | 8 | class NotificationMessage: 9 | def __init__(self): 10 | self.video_title: str = "" 11 | self.status: int = 0 12 | self.video_merge_type: int = 0 13 | 14 | class Command: 15 | def __init__(self): 16 | self.command = [] 17 | self.rename_params = [] 18 | self.remove_params = [] 19 | 20 | def add(self, command: str): 21 | self.command.append(command) 22 | 23 | def add_rename(self, src: str, dst: str, cwd: str): 24 | self.rename_params.append([src, dst, cwd]) 25 | 26 | def add_remove(self, files: list[str], cwd: str): 27 | self.remove_params = [os.path.join(cwd, file) for file in files] 28 | 29 | def format(self): 30 | return " && ".join(self.command) 31 | 32 | def rename(self): 33 | if self.rename_params: 34 | for params in self.rename_params: 35 | dst = os.path.join(params[2], params[1]) 36 | 37 | if os.path.exists(dst): 38 | match OverrideOption(Config.Merge.override_option): 39 | case OverrideOption.Rename: 40 | params[1] = File.find_duplicate_file(dst) 41 | 42 | case OverrideOption.Override: 43 | File.remove_file(dst) 44 | 45 | File.rename_file(params[0], params[1], params[2]) 46 | 47 | def remove(self): 48 | if self.remove_params: 49 | File.remove_files(self.remove_params) 50 | 51 | class Process: 52 | output: str = None 53 | return_code: int = None 54 | 55 | class CommentData: 56 | def __init__(self): 57 | self.start_time: int = 0 58 | self.end_time: int = 0 59 | self.text: str = "" 60 | self.width: int = 0 61 | self.row: int = 0 62 | -------------------------------------------------------------------------------- /src/utils/module/ffmpeg/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from utils.config import Config 4 | from utils.common.enums import Platform 5 | 6 | class FFEnv: 7 | @staticmethod 8 | def check_file(path: str): 9 | return os.path.isfile(path) and os.access(path, os.X_OK) 10 | 11 | @classmethod 12 | def get_env_path(cls): 13 | path_env = os.environ.get("PATH", "") 14 | 15 | for directory in path_env.split(os.pathsep): 16 | possible_path = os.path.join(directory, cls.ffmpeg_file()) 17 | 18 | if cls.check_file(possible_path): 19 | return possible_path 20 | 21 | @classmethod 22 | def get_cwd_path(cls): 23 | possible_path = os.path.join(os.getcwd(), cls.ffmpeg_file()) 24 | 25 | if cls.check_file(possible_path): 26 | return possible_path 27 | 28 | @classmethod 29 | def get_ffmpeg_path(cls): 30 | return { 31 | "env_path": cls.get_env_path(), 32 | "cwd_path": cls.get_cwd_path(), 33 | } 34 | 35 | @classmethod 36 | def detect(cls): 37 | ffmpeg_path = cls.get_ffmpeg_path() 38 | 39 | env_path, cwd_path = ffmpeg_path["env_path"], ffmpeg_path["cwd_path"] 40 | 41 | if not Config.Merge.ffmpeg_path: 42 | Config.Merge.ffmpeg_path = env_path if env_path else Config.Merge.ffmpeg_path 43 | Config.Merge.ffmpeg_path = cwd_path if cwd_path else Config.Merge.ffmpeg_path 44 | else: 45 | if not cls.check_file(Config.Merge.ffmpeg_path): 46 | Config.Merge.ffmpeg_path = cwd_path 47 | 48 | @staticmethod 49 | def check_availability(): 50 | return not os.path.exists(Config.Merge.ffmpeg_path) 51 | 52 | @staticmethod 53 | def ffmpeg_file(): 54 | match Platform(Config.Sys.platform): 55 | case Platform.Windows: 56 | return "ffmpeg.exe" 57 | 58 | case Platform.Linux | Platform.macOS: 59 | return "ffmpeg" -------------------------------------------------------------------------------- /src/utils/common/formatter/strict_naming.py: -------------------------------------------------------------------------------- 1 | from utils.common.map import cn_num_map 2 | from utils.common.datetime_util import DateTime 3 | from utils.common.model.task_info import DownloadTaskInfo 4 | 5 | class StrictNaming: 6 | @classmethod 7 | def check_strict_naming(cls, task_info: DownloadTaskInfo): 8 | cls.get_episode_tag(task_info) 9 | 10 | cls.get_section_title_ex(task_info) 11 | 12 | @classmethod 13 | def get_season_num_ex(cls, info_json: dict): 14 | # 获取当前番剧的季编号 15 | for index, entry in enumerate(info_json.get("seasons")): 16 | if entry["season_id"] == info_json.get("season_id"): 17 | return index + 1 18 | 19 | return 1 20 | 21 | @classmethod 22 | def get_episode_tag(cls, task_info: DownloadTaskInfo): 23 | season_string = "S{season_num:02}".format(season_num = task_info.season_num) 24 | episode_string = "E{episode_num:0>{width}}".format(episode_num = task_info.episode_num, width = len(str(task_info.total_count)) if task_info.total_count > 9 else 2) 25 | 26 | if task_info.episode_num != 0: 27 | task_info.episode_tag = "{season_string}{episode_string}".format(title = task_info.title, season_string = season_string, episode_string = episode_string) 28 | else: 29 | task_info.episode_tag = "" 30 | 31 | @classmethod 32 | def get_section_title_ex(cls, task_info: DownloadTaskInfo): 33 | if task_info.bangumi_type != "电影": 34 | section_title_ex = "Season {season_num:02}".format(season_num = task_info.season_num) 35 | else: 36 | section_title_ex = f"{task_info.series_title} ({DateTime.time_str_from_timestamp(task_info.pubtimestamp, '%Y')})" 37 | 38 | task_info.section_title_ex = section_title_ex 39 | 40 | @staticmethod 41 | def convert_cn_num_to_arabic(cn_num: str): 42 | num = 0 43 | 44 | for c in cn_num: 45 | num += cn_num_map.get(c, 0) 46 | 47 | return num if num else cn_num -------------------------------------------------------------------------------- /src/gui/dialog/login/captcha.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import json 3 | import gettext 4 | 5 | from utils.auth.login_v2 import LoginInfo 6 | 7 | from gui.component.window.dialog import Dialog 8 | from gui.component.webview import Webview 9 | 10 | _ = gettext.gettext 11 | 12 | class CaptchaDialog(Dialog): 13 | def __init__(self, parent): 14 | Dialog.__init__(self, parent, _("请完成验证")) 15 | 16 | self.init_UI() 17 | 18 | self.SetSize(self.FromDIP((400, 500))) 19 | 20 | self.Bind_EVT() 21 | 22 | self.CenterOnParent() 23 | 24 | self.init_utils() 25 | 26 | def init_UI(self): 27 | self.webview = Webview(self) 28 | 29 | self.webview.get_page("captcha.html") 30 | 31 | vbox = wx.BoxSizer(wx.VERTICAL) 32 | vbox.Add(self.webview.browser, 1, wx.ALL | wx.EXPAND) 33 | 34 | self.SetSizerAndFit(vbox) 35 | 36 | def Bind_EVT(self): 37 | import wx.html2 38 | 39 | self.webview.browser.Bind(wx.html2.EVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, self.onMessageEVT) 40 | 41 | self.webview.browser.Bind(wx.html2.EVT_WEBVIEW_LOADED, self.onLoadedEVT) 42 | 43 | self.Bind(wx.EVT_CLOSE, self.onCloseEVT) 44 | 45 | def init_utils(self): 46 | self.webview.browser.AddScriptMessageHandler("MainApplication") 47 | 48 | def onLoadedEVT(self, event): 49 | self.webview.browser.RunScriptAsync(f"receiveMessage('{LoginInfo.Captcha.gt}','{LoginInfo.Captcha.challenge}')") 50 | 51 | def onMessageEVT(self, event): 52 | message = event.GetString() 53 | 54 | data = json.loads(message) 55 | 56 | if data["msg"] == "captchaResult": 57 | LoginInfo.Captcha.validate = data["data"]["validate"] 58 | LoginInfo.Captcha.seccode = data["data"]["seccode"] 59 | 60 | event = wx.PyCommandEvent(wx.EVT_CLOSE.typeId, self.GetId()) 61 | wx.PostEvent(self.GetEventHandler(), event) 62 | 63 | def onCloseEVT(self, event): 64 | LoginInfo.Captcha.flag = False 65 | 66 | event.Skip() 67 | -------------------------------------------------------------------------------- /src/utils/parse/extra/nfo/lesson.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from utils.common.model.task_info import DownloadTaskInfo 4 | 5 | from utils.module.pic.cover import Cover, CoverType 6 | 7 | from utils.parse.extra.parser import Parser 8 | from utils.parse.cheese import CheeseParser 9 | from utils.parse.extra.file.metadata.lesson import TVShowMetaDataParser, EpisodeMetaDataParser 10 | from utils.parse.extra.file.metadata.utils import Utils 11 | 12 | class LessonNFOParser(Parser): 13 | def __init__(self, task_info: DownloadTaskInfo): 14 | Parser.__init__(self) 15 | 16 | self.task_info = task_info 17 | 18 | def download_tvshow_nfo(self): 19 | file_path = Utils.get_root_path(self.task_info, root = True) 20 | file_name = "tvshow.nfo" 21 | 22 | if self.check_file(file_path, file_name): 23 | return 24 | 25 | self.get_lesson_season_info() 26 | self.get_lesson_poster(file_path, "poster.jpg") 27 | 28 | file = TVShowMetaDataParser(self.task_info) 29 | contents = file.get_nfo_contents() 30 | 31 | self.save_file_ex(file_path, file_name, contents, "w") 32 | 33 | def download_episode_nfo(self): 34 | file = EpisodeMetaDataParser(self.task_info) 35 | contents = file.get_nfo_contents() 36 | 37 | self.save_file(f"{self.task_info.file_name}.nfo", contents, "w") 38 | 39 | def check_file(self, file_path: str, file_name: str): 40 | return os.path.exists(os.path.join(file_path, file_name)) 41 | 42 | def get_lesson_season_info(self): 43 | self.season_info = CheeseParser.get_cheese_season_info(self.task_info.season_id) 44 | 45 | self.task_info.description = self.season_info.get("description") 46 | self.task_info.poster_url = self.season_info.get("poster_url") 47 | self.task_info.bangumi_pubdate = self.season_info.get("pubdate") 48 | 49 | def get_lesson_poster(self, file_path: str, file_name: str): 50 | contents = Cover.download_cover(self.task_info.poster_url, CoverType.JPG) 51 | 52 | self.save_file_ex(file_path, file_name, contents, "wb") 53 | -------------------------------------------------------------------------------- /src/utils/common/model/callback.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from utils.common.model.data_type import Process 4 | 5 | class Callback(ABC): 6 | @staticmethod 7 | @abstractmethod 8 | def onSuccess(*process: Process): 9 | pass 10 | 11 | @staticmethod 12 | @abstractmethod 13 | def onError(*process: Process): 14 | pass 15 | 16 | class ParseCallback(ABC): 17 | @staticmethod 18 | @abstractmethod 19 | def onError(): 20 | pass 21 | 22 | @staticmethod 23 | @abstractmethod 24 | def onJump(url: str): 25 | pass 26 | 27 | @staticmethod 28 | @abstractmethod 29 | def onUpdateName(name: str): 30 | pass 31 | 32 | @staticmethod 33 | @abstractmethod 34 | def onUpdateTitle(title: str): 35 | pass 36 | 37 | @staticmethod 38 | @abstractmethod 39 | def onUpdateHistory(url: str, title: str, category: str): 40 | pass 41 | 42 | class DownloaderCallback(ABC): 43 | @staticmethod 44 | @abstractmethod 45 | def onStart(): 46 | pass 47 | 48 | @staticmethod 49 | @abstractmethod 50 | def onDownloading(speed: str): 51 | pass 52 | 53 | @staticmethod 54 | @abstractmethod 55 | def onComplete(): 56 | pass 57 | 58 | @staticmethod 59 | @abstractmethod 60 | def onError(): 61 | pass 62 | 63 | class PlayerCallback(ABC): 64 | @staticmethod 65 | @abstractmethod 66 | def onLengthChange(length: int): 67 | pass 68 | 69 | @staticmethod 70 | @abstractmethod 71 | def onReset(): 72 | pass 73 | 74 | class ConsoleCallback(ABC): 75 | @staticmethod 76 | @abstractmethod 77 | def onReadOutput(output: str): 78 | pass 79 | 80 | @staticmethod 81 | @abstractmethod 82 | def onSuccess(process): 83 | pass 84 | 85 | @staticmethod 86 | @abstractmethod 87 | def onError(process): 88 | pass 89 | 90 | class LiveRecordingCallback(ABC): 91 | @staticmethod 92 | @abstractmethod 93 | def onRecording(speed: str): 94 | pass -------------------------------------------------------------------------------- /src/gui/dialog/setting/scrape_option/add_date_box.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.common.map import nfo_add_date_map 5 | 6 | from gui.component.panel.panel import Panel 7 | from gui.component.choice.choice import Choice 8 | 9 | _ = gettext.gettext 10 | 11 | class AddDateBox(Panel): 12 | def __init__(self, parent: wx.Window): 13 | Panel.__init__(self, parent) 14 | 15 | self.init_UI() 16 | 17 | self.Bind_EVT() 18 | 19 | def init_UI(self): 20 | self.add_date_chk = wx.CheckBox(self, -1, _("将 <添加日期> 添加到 NFO")) 21 | self.add_date_source_lab = wx.StaticText(self, -1, _("<添加日期> 来源")) 22 | self.add_date_source_choice = Choice(self) 23 | self.add_date_source_choice.SetChoices(nfo_add_date_map) 24 | 25 | add_source_hbox = wx.BoxSizer(wx.HORIZONTAL) 26 | add_source_hbox.AddSpacer(self.FromDIP(20)) 27 | add_source_hbox.Add(self.add_date_source_lab, 0, wx.ALL | wx.ALIGN_CENTER, self.FromDIP(6)) 28 | add_source_hbox.Add(self.add_date_source_choice, 0, wx.ALL & (~wx.LEFT) | wx.ALIGN_CENTER, self.FromDIP(6)) 29 | 30 | add_source_vbox = wx.BoxSizer(wx.VERTICAL) 31 | add_source_vbox.Add(self.add_date_chk, 0, wx.ALL & (~wx.BOTTOM), self.FromDIP(6)) 32 | add_source_vbox.Add(add_source_hbox, 0, wx.EXPAND) 33 | 34 | self.SetSizer(add_source_vbox) 35 | 36 | def Bind_EVT(self): 37 | self.add_date_chk.Bind(wx.EVT_CHECKBOX, self.onAddDateEVT) 38 | 39 | def init_data(self, option: dict): 40 | self.add_date_chk.SetValue(option.get("add_date", True)) 41 | self.add_date_source_choice.SetSelection(option.get("add_date_source", 0)) 42 | 43 | self.onAddDateEVT(0) 44 | 45 | def save(self): 46 | return { 47 | "add_date": self.add_date_chk.GetValue(), 48 | "add_date_source": self.add_date_source_choice.GetSelection() 49 | } 50 | 51 | def onAddDateEVT(self, event: wx.CommandEvent): 52 | enable = self.add_date_chk.GetValue() 53 | 54 | self.add_date_source_lab.Enable(enable) 55 | self.add_date_source_choice.Enable(enable) 56 | 57 | -------------------------------------------------------------------------------- /src/utils/parse/extra/parser.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | from utils.config import Config 5 | from utils.common.request import RequestUtils 6 | from utils.common.model.task_info import DownloadTaskInfo 7 | from utils.common.enums import StatusCode 8 | from utils.common.exception import GlobalException 9 | 10 | class Parser: 11 | def __init__(self): 12 | self.total_file_size: int = 0 13 | self.task_info: DownloadTaskInfo = None 14 | 15 | def request_get(self, url: str, check: bool = False): 16 | req = RequestUtils.request_get(url, headers = RequestUtils.get_headers(referer_url = "https://www.bilibili.com/", sessdata = Config.User.SESSDATA)) 17 | 18 | req.raise_for_status() 19 | 20 | if check: 21 | data = json.loads(req.text) 22 | 23 | self.check_json(data) 24 | 25 | return data 26 | 27 | return req 28 | 29 | def save_file(self, file_name: str, contents: str, mode: str): 30 | file_path = os.path.join(self.task_info.download_path, file_name) 31 | 32 | encoding = "utf-8" if mode == "w" else None 33 | 34 | with open(file_path, mode, encoding = encoding) as file: 35 | file.write(contents) 36 | 37 | self.total_file_size += os.stat(file_path).st_size 38 | 39 | def save_file_ex(self, path: str, file_name: str, contents: str, mode: str): 40 | file_path = os.path.join(path, file_name) 41 | 42 | encoding = "utf-8" if mode == "w" else None 43 | 44 | with open(file_path, mode, encoding = encoding) as file: 45 | file.write(contents) 46 | 47 | self.total_file_size += os.stat(file_path).st_size 48 | 49 | def get_video_resolution(self): 50 | return { 51 | "width": self.task_info.video_width, 52 | "height": self.task_info.video_height 53 | } 54 | 55 | def check_json(self, data: dict): 56 | status_code = data.get("code", 0) 57 | message = data.get("message") 58 | 59 | if status_code != StatusCode.Success.value: 60 | raise GlobalException(code = status_code, message = message, json_data = data) -------------------------------------------------------------------------------- /src/gui/component/misc/ass_color_picker.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.common.style.color import Color 4 | 5 | from gui.dialog.setting.color_picker import ColorPickerDialog 6 | 7 | from gui.component.panel.panel import Panel 8 | 9 | class ASSColorPicker(Panel): 10 | def __init__(self, parent, label: str, orient: int): 11 | self.label = label 12 | 13 | Panel.__init__(self, parent) 14 | 15 | match orient: 16 | case wx.HORIZONTAL: 17 | self.init_horizontal_UI() 18 | 19 | case wx.VERTICAL: 20 | self.init_vertical_UI() 21 | 22 | self.Bind_EVT() 23 | 24 | def init_vertical_UI(self): 25 | self.text_lab = wx.StaticText(self, -1, self.label) 26 | 27 | lab_hbox = wx.BoxSizer(wx.HORIZONTAL) 28 | lab_hbox.AddStretchSpacer() 29 | lab_hbox.Add(self.text_lab, 0, wx.ALL, self.FromDIP(6)) 30 | lab_hbox.AddStretchSpacer() 31 | 32 | self.color_picker = wx.ColourPickerCtrl(self, -1, style = wx.CLRP_SHOW_ALPHA) 33 | 34 | vbox = wx.BoxSizer(wx.VERTICAL) 35 | vbox.Add(lab_hbox, 0, wx.EXPAND) 36 | vbox.Add(self.color_picker, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 37 | 38 | self.SetSizer(vbox) 39 | 40 | def init_horizontal_UI(self): 41 | self.text_lab = wx.StaticText(self, -1, self.label) 42 | 43 | self.color_picker = wx.ColourPickerCtrl(self, -1, style = wx.CLRP_SHOW_ALPHA) 44 | 45 | hbox = wx.BoxSizer(wx.HORIZONTAL) 46 | hbox.Add(self.text_lab, 0, wx.ALL | wx.ALIGN_CENTER, self.FromDIP(6)) 47 | hbox.Add(self.color_picker, 0, wx.ALL & (~wx.LEFT) | wx.ALIGN_CENTER, self.FromDIP(6)) 48 | 49 | self.SetSizer(hbox) 50 | 51 | def Bind_EVT(self): 52 | self.color_picker.Bind(wx.EVT_BUTTON, self.onColorChangeEVT) 53 | 54 | def onColorChangeEVT(self, event: wx.ColourPickerEvent): 55 | dlg = ColorPickerDialog(self) 56 | dlg.ShowModal() 57 | 58 | def SetColour(self, color_str: str): 59 | r, g, b, a = Color.convert_to_abgr_color(color_str) 60 | 61 | color = wx.Colour(r, g, b) 62 | self.color_picker.SetColour(color) 63 | 64 | def GetColour(self): 65 | return self.color_picker.GetColour() -------------------------------------------------------------------------------- /src/gui/dialog/setting/scrape_option/movie.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.config import Config 5 | 6 | from gui.dialog.setting.scrape_option.add_date_box import AddDateBox 7 | 8 | from gui.component.panel.panel import Panel 9 | 10 | _ = gettext.gettext 11 | 12 | class MoviePage(Panel): 13 | def __init__(self, parent: wx.Window): 14 | Panel.__init__(self, parent) 15 | 16 | self.init_UI() 17 | 18 | self.init_data() 19 | 20 | def init_UI(self): 21 | self.add_date_source_box = AddDateBox(self) 22 | 23 | nfo_file_lab = wx.StaticText(self, -1, _("创建 NFO 文件")) 24 | 25 | self.movie_nfo_chk = wx.CheckBox(self, -1, "movie.nfo") 26 | self.episode_nfo_chk = wx.CheckBox(self, -1, _("<电影文件名>.nfo")) 27 | 28 | nfo_file_grid_box = wx.FlexGridSizer(2, 1, 0, 0) 29 | nfo_file_grid_box.Add(self.movie_nfo_chk, 0, wx.ALL, self.FromDIP(6)) 30 | nfo_file_grid_box.Add(self.episode_nfo_chk, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 31 | 32 | nfo_file_hbox = wx.BoxSizer(wx.HORIZONTAL) 33 | nfo_file_hbox.AddSpacer(self.FromDIP(20)) 34 | nfo_file_hbox.Add(nfo_file_grid_box, 0, wx.EXPAND) 35 | 36 | nfo_file_vbox = wx.BoxSizer(wx.VERTICAL) 37 | nfo_file_vbox.Add(nfo_file_lab, 0, wx.ALL & (~wx.BOTTOM) & (~wx.TOP), self.FromDIP(6)) 38 | nfo_file_vbox.Add(nfo_file_hbox, 0, wx.EXPAND) 39 | 40 | vbox = wx.BoxSizer(wx.VERTICAL) 41 | vbox.Add(self.add_date_source_box, 0, wx.EXPAND) 42 | vbox.Add(nfo_file_vbox, 0, wx.EXPAND) 43 | 44 | self.SetSizerAndFit(vbox) 45 | 46 | def init_data(self): 47 | option = Config.Temp.scrape_option.get("movie") 48 | 49 | self.add_date_source_box.init_data(option) 50 | 51 | self.movie_nfo_chk.SetValue(option.get("download_movie_nfo", False)) 52 | self.episode_nfo_chk.SetValue(option.get("download_episode_nfo", False)) 53 | 54 | def save(self): 55 | return { 56 | "movie": { 57 | **self.add_date_source_box.save(), 58 | "download_movie_nfo": self.movie_nfo_chk.GetValue(), 59 | "download_episode_nfo": self.episode_nfo_chk.GetValue(), 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/gui/dialog/setting/edit_title.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from gui.component.window.dialog import Dialog 5 | from gui.component.text_ctrl.search_ctrl import SearchCtrl 6 | 7 | _ = gettext.gettext 8 | 9 | class EditTitleDialog(Dialog): 10 | def __init__(self, parent, title: str): 11 | self.title = title 12 | 13 | Dialog.__init__(self, parent, _("修改标题")) 14 | 15 | self.init_UI() 16 | 17 | self.Bind_EVT() 18 | 19 | self.CenterOnParent() 20 | 21 | def init_UI(self): 22 | title_lab = wx.StaticText(self, -1, _("新标题将作为 {title} 字段")) 23 | 24 | self.title_box = SearchCtrl(self, _("请输入新标题"), size = self.FromDIP((350, -1)), clear_btn = True) 25 | self.title_box.SetValue(self.title) 26 | 27 | self.ok_btn = wx.Button(self, wx.ID_OK, _("确定"), size = self.get_scaled_size((80, 30))) 28 | self.cancel_btn = wx.Button(self, wx.ID_CANCEL, _("取消"), size = self.get_scaled_size((80, 30))) 29 | 30 | bottom_hbox = wx.BoxSizer(wx.HORIZONTAL) 31 | bottom_hbox.AddStretchSpacer(1) 32 | bottom_hbox.Add(self.ok_btn, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 33 | bottom_hbox.Add(self.cancel_btn, 0, wx.ALL & (~wx.TOP) & (~wx.LEFT), self.FromDIP(6)) 34 | 35 | vbox = wx.BoxSizer(wx.VERTICAL) 36 | 37 | vbox.Add(title_lab, 0, wx.ALL, self.FromDIP(6)) 38 | vbox.Add(self.title_box, 0, wx.ALL & (~wx.TOP) | wx.EXPAND, self.FromDIP(6)) 39 | vbox.Add(bottom_hbox, 0, wx.EXPAND) 40 | 41 | self.SetSizerAndFit(vbox) 42 | 43 | def Bind_EVT(self): 44 | self.title_box.Bind(wx.EVT_KEY_DOWN, self.onEnterEVT) 45 | 46 | def onEnterEVT(self, event: wx.KeyEvent): 47 | keycode = event.GetKeyCode() 48 | 49 | if keycode in [wx.WXK_RETURN, wx.WXK_NUMPAD_ENTER]: 50 | ok_event = wx.PyCommandEvent(wx.EVT_BUTTON.typeId, self.ok_btn.GetId()) 51 | ok_event.SetEventObject(self.ok_btn) 52 | 53 | wx.PostEvent(self.ok_btn.GetEventHandler(), ok_event) 54 | 55 | event.Skip() 56 | 57 | def onOKEVT(self): 58 | if not self.title_box.GetValue(): 59 | wx.MessageDialog(self, "修改标题失败\n\n新标题不能为空", "警告", wx.ICON_WARNING).ShowModal() 60 | return True 61 | -------------------------------------------------------------------------------- /src/utils/module/pic/face.py: -------------------------------------------------------------------------------- 1 | import os 2 | import wx 3 | 4 | from utils.config import Config 5 | 6 | from utils.common.request import RequestUtils 7 | from utils.common.io.file import File 8 | 9 | class Face: 10 | @classmethod 11 | def get_user_face_path(cls): 12 | Config.User.face_path = os.path.join(Config.User.directory, f"face.jpg") 13 | 14 | if not os.path.exists(Config.User.face_path): 15 | # 若未缓存头像,则下载头像到本地 16 | content = RequestUtils.request_get(Config.User.face_url).content 17 | 18 | with open(Config.User.face_path, "wb") as f: 19 | f.write(content) 20 | 21 | return Config.User.face_path 22 | 23 | @classmethod 24 | def get_user_face_image(cls): 25 | return wx.Image(cls.get_user_face_path(), wx.BITMAP_TYPE_ANY) 26 | 27 | @staticmethod 28 | def crop_round_face_bmp(image: wx.Image): 29 | width, height = image.GetSize() 30 | diameter = min(width, height) 31 | 32 | center = radius = diameter / 2.0 33 | 34 | circle_image = wx.Image(diameter, diameter) 35 | circle_image.InitAlpha() 36 | 37 | feather_radius = 1.5 38 | max_alpha = 255 39 | 40 | for y in range(diameter): 41 | dy = y - center 42 | for x in range(diameter): 43 | dx = x - center 44 | dist = (dx * dx + dy * dy) ** 0.5 45 | 46 | if dist <= radius - feather_radius: 47 | alpha_val = max_alpha 48 | 49 | elif dist >= radius + feather_radius: 50 | alpha_val = 0 51 | 52 | else: 53 | ratio = (dist - (radius - feather_radius)) / (2 * feather_radius) 54 | alpha_val = int(max_alpha * (1 - ratio)) 55 | 56 | r, g, b = image.GetRed(x, y), image.GetGreen(x, y), image.GetBlue(x, y) 57 | 58 | circle_image.SetRGB(x, y, r, g, b) 59 | circle_image.SetAlpha(x, y, alpha_val) 60 | 61 | return circle_image.ConvertToBitmap() 62 | 63 | @staticmethod 64 | def remove(): 65 | File.remove_file(Config.User.face_path) 66 | -------------------------------------------------------------------------------- /src/gui/dialog/guide/agree_page.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import sys 3 | import gettext 4 | 5 | from gui.component.panel.panel import Panel 6 | 7 | _ = gettext.gettext 8 | 9 | class AgreePage(Panel): 10 | def __init__(self, parent: wx.Window, desc: str): 11 | self.desc = desc 12 | 13 | Panel.__init__(self, parent) 14 | 15 | self.init_UI() 16 | 17 | self.Bind_EVT() 18 | 19 | def init_UI(self): 20 | font = self.GetFont() 21 | font.SetFractionalPointSize(font.GetFractionalPointSize() + 1) 22 | 23 | self.desc_box = wx.TextCtrl(self, -1, self.desc, style = wx.TE_MULTILINE | wx.TE_READONLY | wx.BORDER_NONE) 24 | self.desc_box.SetFont(font) 25 | self.desc_box.SetInsertionPoint(0) 26 | 27 | self.agree_btn = wx.Button(self, -1, _("已知晓"), size = self.get_scaled_size((80, 28))) 28 | self.agree_btn.Hide() 29 | self.disagree_btn = wx.Button(self, -1, _("不理解"), size = self.get_scaled_size((80, 28))) 30 | self.disagree_btn.Hide() 31 | 32 | button_hbox = wx.BoxSizer(wx.HORIZONTAL) 33 | button_hbox.AddStretchSpacer() 34 | button_hbox.Add(self.agree_btn, 0, wx.ALL, self.FromDIP(6)) 35 | button_hbox.Add(self.disagree_btn, 0, wx.ALL & (~wx.LEFT), self.FromDIP(6)) 36 | button_hbox.AddStretchSpacer() 37 | 38 | vbox = wx.BoxSizer(wx.VERTICAL) 39 | vbox.Add(self.desc_box, 1, wx.ALL | wx.EXPAND, self.FromDIP(10)) 40 | vbox.Add(button_hbox, 0, wx.EXPAND) 41 | vbox.AddSpacer(self.FromDIP(10)) 42 | 43 | self.SetSizer(vbox) 44 | 45 | def Bind_EVT(self): 46 | self.agree_btn.Bind(wx.EVT_BUTTON, self.onAgreeEVT) 47 | self.disagree_btn.Bind(wx.EVT_BUTTON, self.onDisagreeEVT) 48 | 49 | def startCountdown(self): 50 | def worker(): 51 | self.agree_btn.Show() 52 | self.disagree_btn.Show() 53 | 54 | self.Layout() 55 | 56 | wx.CallLater(5000, worker) 57 | 58 | def onAgreeEVT(self, event: wx.CommandEvent): 59 | dlg = wx.FindWindowByName("guide") 60 | 61 | dlg.onNextPageEVT(0) 62 | 63 | self.agree_btn.Hide() 64 | self.disagree_btn.Hide() 65 | 66 | def onDisagreeEVT(self, event: wx.CommandEvent): 67 | sys.exit() -------------------------------------------------------------------------------- /src/gui/component/menu/episode_list.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from gui.id import ID 5 | 6 | _ = gettext.gettext 7 | 8 | class EpisodeListMenu(wx.Menu): 9 | def __init__(self, item_type: str, checked_state: bool, collapsed_state: bool): 10 | wx.Menu.__init__(self) 11 | 12 | view_cover_menuitem = wx.MenuItem(self, ID.EPISODE_LIST_VIEW_COVER_MENU, _("查看视频封面(&V)")) 13 | copy_title_menuitem = wx.MenuItem(self, ID.EPISODE_LIST_COPY_TITLE_MENU, _("复制标题名称(&C)")) 14 | copy_url_menuitem = wx.MenuItem(self, ID.EPISODE_LIST_COPY_URL_MENU, _("复制视频链接(&U)")) 15 | open_in_browser_menuitem = wx.MenuItem(self, ID.EPISODE_LIST_OPEN_IN_BROWSER_MENU, _("在浏览器中打开(&B)")) 16 | edit_title_menuitem = wx.MenuItem(self, ID.EPISODE_LIST_EDIT_TITLE_MENU, _("修改标题名称(&E)")) 17 | check_menuitem = wx.MenuItem(self, ID.EPISODE_LIST_CHECK_MENU, _("取消选择(&N)") if checked_state else _("选择(&S)")) 18 | collapse_menuitem = wx.MenuItem(self, ID.EPISODE_LIST_COLLAPSE_MENU, _("展开(&X)") if collapsed_state else _("折叠(&O)")) 19 | select_batch_menuitem = wx.MenuItem(self, ID.EPISODE_LIST_SELECT_BATCH_MENU, _("批量选取项目(&P)")) 20 | refresh_media_info_menuitem = wx.MenuItem(self, ID.EPISODE_LIST_REFRESH_MEDIA_INFO_MENU, _("刷新媒体信息(&R)")) 21 | 22 | self.Append(view_cover_menuitem) 23 | self.AppendSeparator() 24 | self.Append(copy_title_menuitem) 25 | self.Append(copy_url_menuitem) 26 | self.Append(open_in_browser_menuitem) 27 | self.AppendSeparator() 28 | self.Append(edit_title_menuitem) 29 | self.AppendSeparator() 30 | self.Append(check_menuitem) 31 | self.Append(collapse_menuitem) 32 | self.AppendSeparator() 33 | self.Append(select_batch_menuitem) 34 | self.Append(refresh_media_info_menuitem) 35 | 36 | if item_type == "node": 37 | self.Enable(ID.EPISODE_LIST_VIEW_COVER_MENU, False) 38 | self.Enable(ID.EPISODE_LIST_COPY_URL_MENU, False) 39 | self.Enable(ID.EPISODE_LIST_OPEN_IN_BROWSER_MENU, False) 40 | self.Enable(ID.EPISODE_LIST_EDIT_TITLE_MENU, False) 41 | self.Enable(ID.EPISODE_LIST_REFRESH_MEDIA_INFO_MENU, False) 42 | else: 43 | self.Enable(ID.EPISODE_LIST_COLLAPSE_MENU, False) 44 | -------------------------------------------------------------------------------- /src/gui/dialog/misc/license.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import base64 3 | 4 | from gui.component.window.dialog import Dialog 5 | 6 | class LicenseWindow(Dialog): 7 | def __init__(self, parent): 8 | Dialog.__init__(self, parent, "授权") 9 | 10 | self.init_UI() 11 | 12 | self.CenterOnParent() 13 | 14 | def init_UI(self): 15 | license_box = wx.TextCtrl(self, -1, base64.b64decode(self._string_base_64), size = self.FromDIP((500, 250)), style = wx.TE_MULTILINE | wx.TE_READONLY) 16 | 17 | close_btn = wx.Button(self, wx.ID_CANCEL, "关闭", size = self.get_scaled_size((80, 28))) 18 | 19 | dlg_vbox = wx.BoxSizer(wx.VERTICAL) 20 | dlg_vbox.Add(license_box, 0, wx.ALL, self.FromDIP(6)) 21 | dlg_vbox.Add(close_btn, 0, wx.ALL & (~wx.TOP) | wx.ALIGN_RIGHT, self.FromDIP(6)) 22 | 23 | self.SetSizerAndFit(dlg_vbox) 24 | 25 | @property 26 | def _string_base_64(self): 27 | return "TUlUIExpY2Vuc2UKCkNvcHlyaWdodCAoYykgMjAyMiBTY290dCBTbG9hbgoKUGVybWlzc2lvbiBpcyBoZXJlYnkgZ3JhbnRlZCwgZnJlZSBvZiBjaGFyZ2UsIHRvIGFueSBwZXJzb24gb2J0YWluaW5nIGEgY29weQpvZiB0aGlzIHNvZnR3YXJlIGFuZCBhc3NvY2lhdGVkIGRvY3VtZW50YXRpb24gZmlsZXMgKHRoZSAiU29mdHdhcmUiKSwgdG8gZGVhbAppbiB0aGUgU29mdHdhcmUgd2l0aG91dCByZXN0cmljdGlvbiwgaW5jbHVkaW5nIHdpdGhvdXQgbGltaXRhdGlvbiB0aGUgcmlnaHRzCnRvIHVzZSwgY29weSwgbW9kaWZ5LCBtZXJnZSwgcHVibGlzaCwgZGlzdHJpYnV0ZSwgc3VibGljZW5zZSwgYW5kL29yIHNlbGwKY29waWVzIG9mIHRoZSBTb2Z0d2FyZSwgYW5kIHRvIHBlcm1pdCBwZXJzb25zIHRvIHdob20gdGhlIFNvZnR3YXJlIGlzCmZ1cm5pc2hlZCB0byBkbyBzbywgc3ViamVjdCB0byB0aGUgZm9sbG93aW5nIGNvbmRpdGlvbnM6CgpUaGUgYWJvdmUgY29weXJpZ2h0IG5vdGljZSBhbmQgdGhpcyBwZXJtaXNzaW9uIG5vdGljZSBzaGFsbCBiZSBpbmNsdWRlZCBpbiBhbGwKY29waWVzIG9yIHN1YnN0YW50aWFsIHBvcnRpb25zIG9mIHRoZSBTb2Z0d2FyZS4KClRIRSBTT0ZUV0FSRSBJUyBQUk9WSURFRCAiQVMgSVMiLCBXSVRIT1VUIFdBUlJBTlRZIE9GIEFOWSBLSU5ELCBFWFBSRVNTIE9SCklNUExJRUQsIElOQ0xVRElORyBCVVQgTk9UIExJTUlURUQgVE8gVEhFIFdBUlJBTlRJRVMgT0YgTUVSQ0hBTlRBQklMSVRZLApGSVRORVNTIEZPUiBBIFBBUlRJQ1VMQVIgUFVSUE9TRSBBTkQgTk9OSU5GUklOR0VNRU5ULiBJTiBOTyBFVkVOVCBTSEFMTCBUSEUKQVVUSE9SUyBPUiBDT1BZUklHSFQgSE9MREVSUyBCRSBMSUFCTEUgRk9SIEFOWSBDTEFJTSwgREFNQUdFUyBPUiBPVEhFUgpMSUFCSUxJVFksIFdIRVRIRVIgSU4gQU4gQUNUSU9OIE9GIENPTlRSQUNULCBUT1JUIE9SIE9USEVSV0lTRSwgQVJJU0lORyBGUk9NLApPVVQgT0YgT1IgSU4gQ09OTkVDVElPTiBXSVRIIFRIRSBTT0ZUV0FSRSBPUiBUSEUgVVNFIE9SIE9USEVSIERFQUxJTkdTIElOIFRIRQpTT0ZUV0FSRS4=" 28 | -------------------------------------------------------------------------------- /src/utils/common/io/directory.py: -------------------------------------------------------------------------------- 1 | import os 2 | import ctypes 3 | import subprocess 4 | from typing import List 5 | 6 | from utils.common.enums import Platform 7 | 8 | class Directory: 9 | @classmethod 10 | def create_directories(cls, directory_list: List[str]): 11 | for directory in directory_list: 12 | cls.create_directory(directory) 13 | 14 | @staticmethod 15 | def create_directory(directory: str): 16 | if not os.path.exists(directory): 17 | os.makedirs(directory) 18 | 19 | @staticmethod 20 | def open_directory(directory: str): 21 | from utils.config import Config 22 | 23 | match Platform(Config.Sys.platform): 24 | case Platform.Windows: 25 | os.startfile(directory) 26 | 27 | case Platform.Linux: 28 | subprocess.Popen(f'xdg-open "{directory}"', shell = True) 29 | 30 | case Platform.macOS: 31 | subprocess.Popen(f'open "{directory}"', shell = True) 32 | 33 | @classmethod 34 | def open_file_location(cls, path: str): 35 | from utils.config import Config 36 | 37 | match Platform(Config.Sys.platform): 38 | case Platform.Windows: 39 | cls._msw_SHOpenFolderAndSelectItems(path) 40 | 41 | case Platform.Linux: 42 | subprocess.Popen(f'xdg-open "{os.path.dirname(path)}"', shell = True) 43 | 44 | case Platform.macOS: 45 | subprocess.Popen(f'open -R "{path}"', shell = True) 46 | 47 | @staticmethod 48 | def _msw_SHOpenFolderAndSelectItems(path: str): 49 | def get_pidl(path): 50 | pidl = ctypes.POINTER(ITEMIDLIST)() 51 | ctypes.windll.shell32.SHParseDisplayName(path, None, ctypes.byref(pidl), 0, None) 52 | 53 | return pidl 54 | 55 | class ITEMIDLIST(ctypes.Structure): 56 | _fields_ = [("mkid", ctypes.c_byte)] 57 | 58 | ctypes.windll.ole32.CoInitialize(None) 59 | 60 | try: 61 | folder_pidl = get_pidl(os.path.dirname(path)) 62 | file_pidl = get_pidl(path) 63 | 64 | ctypes.windll.shell32.SHOpenFolderAndSelectItems(folder_pidl, 1, ctypes.byref(file_pidl), 0) 65 | 66 | finally: 67 | ctypes.windll.ole32.CoUninitialize() -------------------------------------------------------------------------------- /src/gui/dialog/setting/ass_style/custom_ass_style_v2.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.config import Config 5 | from utils.common.enums import Platform 6 | 7 | from gui.dialog.setting.ass_style.danmaku import DanmakuPage 8 | from gui.dialog.setting.ass_style.subtitle import SubtitlePage 9 | 10 | from gui.component.window.dialog import Dialog 11 | 12 | _ = gettext.gettext 13 | 14 | class CustomASSStyleDialog(Dialog): 15 | def __init__(self, parent: wx.Window): 16 | Dialog.__init__(self, parent, _("自定义 ASS 样式")) 17 | 18 | self.init_UI() 19 | 20 | self.CenterOnParent() 21 | 22 | def init_UI(self): 23 | self.notebook = wx.Notebook(self, -1, size = self.get_book_size()) 24 | 25 | self.notebook.AddPage(DanmakuPage(self.notebook), _("弹幕")) 26 | self.notebook.AddPage(SubtitlePage(self.notebook), _("字幕")) 27 | 28 | self.ok_btn = wx.Button(self, wx.ID_OK, _("确定"), size = self.get_scaled_size((80, 30))) 29 | self.cancel_btn = wx.Button(self, wx.ID_CANCEL, _("取消"), size = self.get_scaled_size((80, 30))) 30 | 31 | bottom_hbox = wx.BoxSizer(wx.HORIZONTAL) 32 | bottom_hbox.AddStretchSpacer(1) 33 | bottom_hbox.Add(self.ok_btn, 0, wx.ALL, self.FromDIP(6)) 34 | bottom_hbox.Add(self.cancel_btn, 0, wx.ALL & (~wx.LEFT), self.FromDIP(6)) 35 | 36 | vbox = wx.BoxSizer(wx.VERTICAL) 37 | vbox.Add(self.notebook, 0, wx.ALL & (~wx.BOTTOM) | wx.EXPAND, self.FromDIP(6)) 38 | vbox.Add(bottom_hbox, 0, wx.EXPAND) 39 | 40 | self.SetSizerAndFit(vbox) 41 | 42 | def onOKEVT(self): 43 | for i in range(self.notebook.GetPageCount()): 44 | page, option = self.notebook.GetPage(i).get_option() 45 | 46 | Config.Temp.ass_style[page] = option 47 | 48 | def get_book_size(self): 49 | match Platform(Config.Sys.platform): 50 | case Platform.Windows: 51 | if Config.Basic.language == "zh_CN": 52 | return self.FromDIP((350, 450)) 53 | else: 54 | return self.FromDIP((450, 450)) 55 | 56 | case Platform.Linux | Platform.macOS: 57 | if Config.Basic.language == "zh_CN": 58 | return self.FromDIP((500, 570)) 59 | else: 60 | return self.FromDIP((600, 570)) -------------------------------------------------------------------------------- /src/gui/component/staticbox/misc_style.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from gui.component.panel.panel import Panel 5 | from gui.component.spinctrl.label_spinctrl import LabelSpinCtrl 6 | 7 | _ = gettext.gettext 8 | 9 | class MiscStyleStaticBox(Panel): 10 | def __init__(self, parent: wx.Window): 11 | Panel.__init__(self, parent) 12 | 13 | self.init_UI() 14 | 15 | def init_UI(self): 16 | misc_box = wx.StaticBox(self, -1, _("杂项")) 17 | 18 | self.scale_x_box = LabelSpinCtrl(misc_box, _("水平缩放"), 100, "%", wx.HORIZONTAL, float = False, max = 1000) 19 | self.scale_x_box.SetToolTip(_("水平缩放百分比")) 20 | self.scale_y_box = LabelSpinCtrl(misc_box, _("垂直缩放"), 100, "%", wx.HORIZONTAL, float = False, max = 1000) 21 | self.scale_y_box.SetToolTip(_("垂直缩放百分比")) 22 | 23 | self.angle_box = LabelSpinCtrl(misc_box, _("旋转角度"), 0, "°", wx.HORIZONTAL, float = False, max = 360, min = -360) 24 | self.angle_box.SetToolTip(_("旋转角度")) 25 | self.spacing_box = LabelSpinCtrl(misc_box, _("字符间距"), 0, "px", wx.HORIZONTAL, float = True, max = 100) 26 | self.spacing_box.SetToolTip(_("字符间距")) 27 | 28 | flex_sizer = wx.FlexGridSizer(2, 3, 0, 0) 29 | flex_sizer.Add(self.scale_x_box, 0, wx.ALL & (~wx.TOP) & (~wx.BOTTOM), self.FromDIP(6)) 30 | flex_sizer.AddSpacer(self.FromDIP(10)) 31 | flex_sizer.Add(self.scale_y_box, 0, wx.ALL & (~wx.TOP) & (~wx.BOTTOM), self.FromDIP(6)) 32 | flex_sizer.Add(self.angle_box, 0, wx.ALL & (~wx.TOP) & (~wx.BOTTOM), self.FromDIP(6)) 33 | flex_sizer.AddSpacer(self.FromDIP(10)) 34 | flex_sizer.Add(self.spacing_box, 0, wx.ALL & (~wx.TOP) & (~wx.BOTTOM), self.FromDIP(6)) 35 | 36 | misc_sbox = wx.StaticBoxSizer(misc_box, wx.VERTICAL) 37 | misc_sbox.Add(flex_sizer, 0, wx.EXPAND) 38 | 39 | self.SetSizer(misc_sbox) 40 | 41 | def init_data(self, data: dict): 42 | self.scale_x_box.SetValue(data.get("scale_x")) 43 | self.scale_y_box.SetValue(data.get("scale_y")) 44 | self.angle_box.SetValue(data.get("angle")) 45 | self.spacing_box.SetValue(data.get("spacing")) 46 | 47 | def get_option(self): 48 | return { 49 | "scale_x": self.scale_x_box.GetValue(), 50 | "scale_y": self.scale_y_box.GetValue(), 51 | "angle": self.angle_box.GetValue(), 52 | "spacing": self.spacing_box.GetValue() 53 | } -------------------------------------------------------------------------------- /src/gui/component/staticbitmap/staticbitmap.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.common.style.color import Color 4 | 5 | from gui.component.panel.panel import Panel 6 | 7 | class StaticBitmap(Panel): 8 | def __init__(self, parent: wx.Window, bmp: wx.Bitmap = None, image: wx.Image = None, size: wx.Size = None): 9 | Panel.__init__(self, parent, size = size) 10 | 11 | self.SetBackgroundColour(parent.GetBackgroundColour()) 12 | 13 | if bmp or image: 14 | self.SetBitmap(bmp = bmp, image = image) 15 | 16 | def get_bmp(self): 17 | width, height = self.GetClientSize() 18 | 19 | return self.image.Copy().Scale(width, height, wx.IMAGE_QUALITY_BICUBIC).ConvertToBitmap() 20 | 21 | def draw_bmp(self, event: wx.PaintEvent): 22 | self.SetBackgroundColour(self.GetParent().GetBackgroundColour()) 23 | 24 | dc = wx.BufferedPaintDC(self) 25 | dc.Clear() 26 | 27 | bmp = self.get_bmp() 28 | 29 | dc.DrawBitmap(bmp, (0, 0), True) 30 | 31 | def draw_text(self, event: wx.PaintEvent): 32 | self.SetBackgroundColour(self.GetParent().GetBackgroundColour()) 33 | 34 | dc = wx.BufferedPaintDC(self) 35 | dc.Clear() 36 | 37 | font: wx.Font = self.GetFont() 38 | font.SetFractionalPointSize(int(font.GetFractionalPointSize() + 2)) 39 | 40 | dc.SetFont(font) 41 | 42 | client_width, client_height = self.GetClientSize() 43 | 44 | total_text_height = sum(dc.GetTextExtent(line).height for line in self.text) + self.FromDIP(4) * (len(self.text) - 1) 45 | y_start = (client_height - total_text_height) // 2 46 | 47 | for line in self.text: 48 | text_width, text_height = dc.GetTextExtent(line) 49 | x = (self.FromDIP(150) - text_width) // 2 50 | dc.DrawText(line, x, y_start) 51 | y_start += text_height + self.FromDIP(4) 52 | 53 | dc.SetPen(wx.Pen(Color.get_border_color(), width = 1)) 54 | dc.SetBrush(wx.TRANSPARENT_BRUSH) 55 | 56 | dc.DrawRectangle(2, 2, client_width - 4, client_height - 4) 57 | 58 | def SetBitmap(self, bmp: wx.Bitmap = None, image: wx.Image = None): 59 | self.image = bmp.ConvertToImage() if bmp else image 60 | 61 | self.Bind(wx.EVT_PAINT, self.draw_bmp) 62 | 63 | self.Refresh() 64 | 65 | def SetTextTip(self, text: list): 66 | self.text = text 67 | 68 | self.Bind(wx.EVT_PAINT, self.draw_text) 69 | 70 | self.Refresh() -------------------------------------------------------------------------------- /src/gui/component/text_ctrl/time_ctrl.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from wx.lib.masked import TextCtrl 3 | 4 | from gui.component.panel.panel import Panel 5 | 6 | class TimeCtrl(Panel): 7 | def __init__(self, parent, label: str): 8 | self.label = label 9 | 10 | Panel.__init__(self, parent) 11 | 12 | self.init_UI() 13 | 14 | self.Bind_EVT() 15 | 16 | def init_UI(self): 17 | label = wx.StaticText(self, -1, self.label) 18 | 19 | label_hbox = wx.BoxSizer(wx.HORIZONTAL) 20 | label_hbox.AddStretchSpacer() 21 | label_hbox.Add(label, 0, wx.ALL & (~wx.BOTTOM), self.FromDIP(6)) 22 | label_hbox.AddStretchSpacer() 23 | 24 | self.time_ctrl = TextCtrl(self, -1, mask = "##:##:##.###", formatcodes = "F0R", defaultValue = "00:00:00.000") 25 | self.time_ctrl.SetFont(self.GetFont()) 26 | 27 | self.min_btn = wx.Button(self, -1, "-", size = self.FromDIP((24, 24))) 28 | self.plus_btn = wx.Button(self, -1, "+", size = self.FromDIP((24, 24))) 29 | 30 | adjust_hbox = wx.BoxSizer(wx.HORIZONTAL) 31 | adjust_hbox.Add(self.min_btn, 0, wx.ALL | wx.ALIGN_CENTER, self.FromDIP(6)) 32 | adjust_hbox.Add(self.time_ctrl, 0, wx.ALL & (~wx.LEFT) | wx.ALIGN_CENTER, self.FromDIP(6)) 33 | adjust_hbox.Add(self.plus_btn, 0, wx.ALL & (~wx.LEFT) | wx.ALIGN_CENTER, self.FromDIP(6)) 34 | 35 | vbox = wx.BoxSizer(wx.VERTICAL) 36 | vbox.Add(label_hbox, 0, wx.EXPAND) 37 | vbox.Add(adjust_hbox, 0, wx.EXPAND) 38 | 39 | self.SetSizer(vbox) 40 | 41 | def Bind_EVT(self): 42 | self.plus_btn.Bind(wx.EVT_BUTTON, self.onPlusEVT) 43 | self.min_btn.Bind(wx.EVT_BUTTON, self.onMinEVT) 44 | 45 | def onPlusEVT(self, event): 46 | ms = self.GetTime() 47 | self.SetTime(ms + 100) 48 | 49 | def onMinEVT(self, event): 50 | ms = self.GetTime() 51 | self.SetTime(ms - 100) 52 | 53 | def SetTime(self, ms: int): 54 | hours, ms = divmod(ms, 3600000) 55 | minutes, ms = divmod(ms, 60000) 56 | seconds, ms = divmod(ms, 1000) 57 | 58 | time = f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d}.{int(ms):03d}" 59 | 60 | self.time_ctrl.SetValue(time) 61 | 62 | def GetTime(self): 63 | h, m, s_millis = self.time_ctrl.GetValue().split(':') 64 | s, ms = s_millis.split('.') if '.' in s_millis else (s_millis, '000') 65 | 66 | return (int(h) * 3600000) + (int(m) * 60000) + (int(s) * 1000) + int(ms) 67 | -------------------------------------------------------------------------------- /src/utils/module/web/cdn.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from urllib.parse import urlparse, urlunparse 3 | 4 | from utils.config import Config 5 | from utils.common.request import RequestUtils 6 | 7 | class CDN: 8 | bilibili_url = "https://www.bilibili.com/" 9 | 10 | @staticmethod 11 | def replace_host(url: str, cdn_host: str): 12 | parsed_url = urlparse(url)._replace(netloc = cdn_host) 13 | 14 | return urlunparse(parsed_url) 15 | 16 | @staticmethod 17 | def get_cdn_host_list(): 18 | if Config.Advanced.enable_switch_cdn: 19 | return Config.Advanced.cdn_list 20 | 21 | @classmethod 22 | def get_file_size(cls, url_list: List[str]): 23 | for download_url in url_list: 24 | if cdn_host_list := cls.get_cdn_host_list(): 25 | for cdn_host in cdn_host_list: 26 | new_url = CDN.replace_host(download_url, cdn_host) 27 | 28 | file_size = cls.request_head(new_url) 29 | 30 | if file_size: 31 | return (new_url, file_size) 32 | else: 33 | file_size = cls.request_head(download_url) 34 | 35 | if file_size: 36 | return (download_url, file_size) 37 | 38 | # 未通过 CDN 获取到有效文件大小,尝试不使用 CDN 直连获取 39 | if Config.Advanced.enable_switch_cdn: 40 | for download_url in url_list: 41 | file_size = cls.request_head(download_url) 42 | 43 | if file_size: 44 | return (download_url, file_size) 45 | 46 | @classmethod 47 | def get_file_size_ex(cls, url_list: List[str]): 48 | for download_url in url_list: 49 | file_size = cls.request_head(download_url) 50 | 51 | if file_size: 52 | return (download_url, file_size) 53 | 54 | return cls.get_file_size(url_list) 55 | 56 | @staticmethod 57 | def request_head(url: str): 58 | try: 59 | req = RequestUtils.request_head(url, headers = RequestUtils.get_headers(referer_url = CDN.bilibili_url)) 60 | 61 | except Exception: 62 | return 0 63 | 64 | if req.status_code not in (200, 206): 65 | # 非成功状态码视为无效 66 | return 0 67 | 68 | length = req.headers.get("Content-Length", 0) 69 | 70 | if int(length) < 1024: 71 | # 小于 1KB 的文件大小视为无效 72 | return 0 73 | 74 | return int(length) -------------------------------------------------------------------------------- /src/gui/dialog/setting/scrape_option/lesson.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.config import Config 5 | 6 | from gui.dialog.setting.scrape_option.add_date_box import AddDateBox 7 | 8 | from gui.component.panel.panel import Panel 9 | 10 | _ = gettext.gettext 11 | 12 | class LessonPage(Panel): 13 | def __init__(self, parent: wx.Window): 14 | Panel.__init__(self, parent) 15 | 16 | self.init_UI() 17 | 18 | self.init_data() 19 | 20 | def init_UI(self): 21 | self.add_date_source_box = AddDateBox(self) 22 | 23 | nfo_file_lab = wx.StaticText(self, -1, _("创建 NFO 文件")) 24 | 25 | self.tvshow_nfo_lab = wx.StaticText(self, -1, _("课程")) 26 | self.tvshow_nfo_chk = wx.CheckBox(self, -1, "tvshow.nfo") 27 | self.episode_nfo_lab = wx.StaticText(self, -1, _("课")) 28 | self.episode_nfo_chk = wx.CheckBox(self, -1, _("<视频文件名>.nfo")) 29 | 30 | nfo_file_grid_box = wx.FlexGridSizer(3, 2, 0, 0) 31 | nfo_file_grid_box.Add(self.tvshow_nfo_lab, 0, wx.ALL, self.FromDIP(6)) 32 | nfo_file_grid_box.Add(self.tvshow_nfo_chk, 0, wx.ALL, self.FromDIP(6)) 33 | nfo_file_grid_box.Add(self.episode_nfo_lab, 0, wx.ALL & (~wx.TOP) , self.FromDIP(6)) 34 | nfo_file_grid_box.Add(self.episode_nfo_chk, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 35 | 36 | nfo_file_hbox = wx.BoxSizer(wx.HORIZONTAL) 37 | nfo_file_hbox.AddSpacer(self.FromDIP(20)) 38 | nfo_file_hbox.Add(nfo_file_grid_box, 0, wx.EXPAND) 39 | 40 | nfo_file_vbox = wx.BoxSizer(wx.VERTICAL) 41 | nfo_file_vbox.Add(nfo_file_lab, 0, wx.ALL & (~wx.BOTTOM) & (~wx.TOP), self.FromDIP(6)) 42 | nfo_file_vbox.Add(nfo_file_hbox, 0, wx.EXPAND) 43 | 44 | vbox = wx.BoxSizer(wx.VERTICAL) 45 | vbox.Add(self.add_date_source_box, 0, wx.EXPAND) 46 | vbox.Add(nfo_file_vbox, 0, wx.EXPAND) 47 | 48 | self.SetSizerAndFit(vbox) 49 | 50 | def init_data(self): 51 | option = Config.Temp.scrape_option.get("lesson") 52 | 53 | self.add_date_source_box.init_data(option) 54 | 55 | self.tvshow_nfo_chk.SetValue(option.get("download_tvshow_nfo", False)) 56 | self.episode_nfo_chk.SetValue(option.get("download_episode_nfo", False)) 57 | 58 | def save(self): 59 | return { 60 | "lesson": { 61 | **self.add_date_source_box.save(), 62 | "download_tvshow_nfo": self.tvshow_nfo_chk.GetValue(), 63 | "download_episode_nfo": self.episode_nfo_chk.GetValue(), 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/utils/parse/extra/file/metadata/video.py: -------------------------------------------------------------------------------- 1 | import math 2 | import textwrap 3 | 4 | from utils.common.model.download_info import DownloadTaskInfo 5 | from utils.common.datetime_util import DateTime 6 | 7 | from utils.parse.extra.file.metadata.utils import Utils 8 | 9 | class VideoMetadataFile: 10 | def __init__(self, task_info: DownloadTaskInfo): 11 | self.data = { 12 | "title": task_info.title, 13 | "description": task_info.description, 14 | "runtime": math.floor(task_info.duration / 60), 15 | "pubtime": DateTime.from_timestamp(task_info.pubtimestamp), 16 | "year": DateTime.from_timestamp(task_info.pubtimestamp), 17 | "up_name": task_info.up_name, 18 | "up_uid": task_info.up_uid, 19 | "up_face": task_info.up_face_url, 20 | "cover": task_info.cover_url, 21 | "cid": task_info.cid, 22 | "zone": task_info.zone, 23 | "subzone": task_info.subzone, 24 | "tags": self.get_tags(task_info.video_tags), 25 | "dateadded": Utils.get_dateadded(task_info.pubtimestamp) 26 | } 27 | 28 | def get_nfo_contents(self): 29 | return textwrap.dedent("""\ 30 | 31 | 32 | {title} 33 | {description} 34 | {runtime} 35 | {year:%Y} 36 | Bilibili 37 | 38 | {up_name} 39 | UP主 40 | https://space.bilibili.com/{up_uid} 41 | {up_face} 42 | 43 | {pubtime:%Y-%m-%d} 44 | {cover} 45 | {cid} 46 | {zone} 47 | {subzone} 48 | {tags} 49 | {dateadded}> 50 | """.format(**self.data)).replace("\n\n", "\n") 51 | 52 | def get_tags(self, tags: list[str]): 53 | tags_elements = [] 54 | 55 | for tag in tags: 56 | tag_element = """\ 57 | {tag}""".format(tag = tag) 58 | 59 | tags_elements.append(Utils.indent(tag_element, " ")) 60 | 61 | return "\n".join(tags_elements).removeprefix(" ") 62 | -------------------------------------------------------------------------------- /src/utils/parse/extra/nfo/movie.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from utils.common.model.task_info import DownloadTaskInfo 4 | from utils.common.enums import CoverType 5 | 6 | from utils.module.pic.cover import Cover 7 | 8 | from utils.parse.bangumi import BangumiParser 9 | from utils.parse.extra.parser import Parser 10 | from utils.parse.extra.file.metadata.movie import MovieMetaDataParser 11 | 12 | class MovieNFOParser(Parser): 13 | def __init__(self, task_info: DownloadTaskInfo): 14 | Parser.__init__(self) 15 | 16 | self.task_info = task_info 17 | 18 | def download_movie_nfo(self): 19 | file_path = self.task_info.download_path 20 | file_name = "movie.nfo" 21 | 22 | if self.check_file(file_path, file_name): 23 | return 24 | 25 | self.get_movie_season_info() 26 | self.get_bangumi_poster(file_path, "poster.jpg") 27 | 28 | file = MovieMetaDataParser(self.task_info) 29 | contents = file.get_nfo_contents() 30 | 31 | self.save_file_ex(file_path, file_name, contents, "w") 32 | 33 | def download_episode_nfo(self): 34 | file_path = self.task_info.download_path 35 | file_name = f"{self.task_info.file_name}.nfo" 36 | 37 | self.get_movie_season_info() 38 | 39 | file = MovieMetaDataParser(self.task_info) 40 | contents = file.get_nfo_contents() 41 | 42 | self.save_file_ex(file_path, file_name, contents, "w") 43 | 44 | def check_file(self, file_path: str, file_name: str): 45 | return os.path.exists(os.path.join(file_path, file_name)) 46 | 47 | def get_movie_season_info(self): 48 | if not hasattr(self, "season_info"): 49 | self.season_info = BangumiParser.get_bangumi_season_info(self.task_info.season_id) 50 | 51 | self.task_info.poster_url = self.season_info.get("poster_url") 52 | self.task_info.description = self.season_info.get("description") 53 | self.task_info.actors = self.season_info.get("actors") 54 | self.task_info.bangumi_tags = self.season_info.get("tags") 55 | self.task_info.bangumi_pubdate = self.season_info.get("pubdate") 56 | self.task_info.rating = self.season_info.get("rating") 57 | self.task_info.rating_count = self.season_info.get("rating_count") 58 | self.task_info.areas = self.season_info.get("areas") 59 | 60 | def get_bangumi_poster(self, file_path: str, file_name: str): 61 | contents = Cover.download_cover(self.task_info.poster_url, CoverType.JPG) 62 | 63 | self.save_file_ex(file_path, file_name, contents, "wb") 64 | -------------------------------------------------------------------------------- /src/gui/dialog/confirm/video_resolution.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.config import Config 5 | 6 | from gui.component.window.dialog import Dialog 7 | 8 | _ = gettext.gettext 9 | 10 | class RequireVideoResolutionDialog(Dialog): 11 | def __init__(self, parent: wx.Window): 12 | Dialog.__init__(self, parent, _("确认视频分辨率")) 13 | 14 | self.init_UI() 15 | 16 | self.CenterOnParent() 17 | 18 | wx.Bell() 19 | 20 | def init_UI(self): 21 | tip_lab = wx.StaticText(self, -1, _("单独下载 .ass 格式弹幕或字幕时,请手动确认视频分辨率。\n若分辨率设置与实际视频不符,可能导致文字位置偏移或显示大小异常。")) 22 | resolution_box = wx.StaticText(self, -1, _("视频分辨率(长度 x 宽度):")) 23 | 24 | self.video_width_box = wx.TextCtrl(self, -1, str(Config.Temp.video_width), size = self.FromDIP((60, -1))) 25 | self.x_lab = wx.StaticText(self, -1, "x") 26 | self.video_height_box = wx.TextCtrl(self, -1, str(Config.Temp.video_height), size = self.FromDIP((60, -1))) 27 | 28 | resolution_hbox = wx.BoxSizer(wx.HORIZONTAL) 29 | resolution_hbox.Add(self.video_width_box, 0, wx.ALL, self.FromDIP(6)) 30 | resolution_hbox.Add(self.x_lab, 0, wx.ALL & (~wx.LEFT) | wx.ALIGN_CENTER, self.FromDIP(6)) 31 | resolution_hbox.Add(self.video_height_box, 0, wx.ALL & (~wx.LEFT), self.FromDIP(6)) 32 | 33 | self.remember_resolution_chk = wx.CheckBox(self, -1, _("记住设置,在关闭程序前不再提示")) 34 | 35 | self.ok_btn = wx.Button(self, wx.ID_OK, _("确定"), size = self.get_scaled_size((80, 30))) 36 | self.cancel_btn = wx.Button(self, wx.ID_CANCEL, _("取消"), size = self.get_scaled_size((80, 30))) 37 | 38 | bottom_hbox = wx.BoxSizer(wx.HORIZONTAL) 39 | bottom_hbox.AddStretchSpacer() 40 | bottom_hbox.Add(self.ok_btn, 0, wx.ALL, self.FromDIP(6)) 41 | bottom_hbox.Add(self.cancel_btn, 0, wx.ALL & (~wx.LEFT), self.FromDIP(6)) 42 | 43 | vbox = wx.BoxSizer(wx.VERTICAL) 44 | vbox.Add(tip_lab, 0, wx.ALL, self.FromDIP(6)) 45 | vbox.Add(resolution_box, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 46 | vbox.Add(resolution_hbox, 0, wx.ALL & (~wx.TOP) & (~wx.BOTTOM), self.FromDIP(6)) 47 | vbox.Add(self.remember_resolution_chk, 0, wx.ALL & (~wx.BOTTOM), self.FromDIP(6)) 48 | vbox.Add(bottom_hbox, 0, wx.EXPAND) 49 | 50 | self.SetSizerAndFit(vbox) 51 | 52 | def onOKEVT(self): 53 | Config.Temp.video_width = int(self.video_width_box.GetValue()) 54 | Config.Temp.video_height = int(self.video_height_box.GetValue()) 55 | 56 | Config.Temp.remember_resolution_settings = self.remember_resolution_chk.GetValue() -------------------------------------------------------------------------------- /src/gui/dialog/guide/page_4.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import wx.adv 3 | import gettext 4 | 5 | from utils.config import Config 6 | from utils.common.data.guide import guide_4_msg 7 | 8 | from gui.component.panel.panel import Panel 9 | 10 | _ = gettext.gettext 11 | 12 | class Page4Panel(Panel): 13 | def __init__(self, parent: wx.Window): 14 | Panel.__init__(self, parent) 15 | 16 | self.init_UI() 17 | 18 | def init_UI(self): 19 | font = self.GetFont() 20 | font.SetFractionalPointSize(font.GetFractionalPointSize() + 1) 21 | 22 | self.desc_lab = wx.StaticText(self, -1, guide_4_msg) 23 | self.desc_lab.Wrap(self.FromDIP(400)) 24 | self.desc_lab.SetFont(font) 25 | 26 | self.enable_listen_clipboard_chk = wx.CheckBox(self, -1, _("自动监听剪切板,检测到复制视频链接时自动解析")) 27 | self.enable_listen_clipboard_chk.SetFont(font) 28 | self.enable_switch_cdn_chk = wx.CheckBox(self, -1, _("自动切换音视频流 CDN (国内用户建议开启)")) 29 | self.enable_switch_cdn_chk.SetValue(Config.Basic.language == "zh_CN") 30 | self.enable_switch_cdn_chk.SetFont(font) 31 | 32 | relative_url = wx.StaticText(self, -1, _("相关链接")) 33 | relative_url.SetFont(font) 34 | document_link = wx.adv.HyperlinkCtrl(self, -1, _("说明文档"), url = "https://bili23.scott-sloan.cn/doc/use/basic.html") 35 | document_link.SetFont(font) 36 | 37 | link_hbox = wx.BoxSizer(wx.HORIZONTAL) 38 | link_hbox.Add(relative_url, 0, wx.ALL | wx.ALIGN_CENTER, self.FromDIP(10)) 39 | link_hbox.Add(document_link, 0, wx.ALL & (~wx.LEFT) | wx.ALIGN_CENTER, self.FromDIP(10)) 40 | 41 | vbox = wx.BoxSizer(wx.VERTICAL) 42 | vbox.Add(self.desc_lab, 0, wx.ALL & (~wx.BOTTOM), self.FromDIP(10)) 43 | vbox.AddStretchSpacer() 44 | vbox.Add(self.enable_listen_clipboard_chk, 0, wx.ALL & (~wx.BOTTOM) & (~wx.TOP), self.FromDIP(10)) 45 | vbox.AddSpacer(self.FromDIP(6)) 46 | vbox.Add(self.enable_switch_cdn_chk, 0, wx.ALL & (~wx.TOP), self.FromDIP(10)) 47 | vbox.AddStretchSpacer() 48 | vbox.Add(link_hbox, 0, wx.EXPAND) 49 | 50 | self.SetSizer(vbox) 51 | 52 | def save(self): 53 | Config.Basic.is_new_user = False 54 | 55 | Config.Basic.listen_clipboard = self.enable_listen_clipboard_chk.GetValue() 56 | Config.Advanced.enable_switch_cdn = self.enable_switch_cdn_chk.GetValue() 57 | 58 | Config.save_app_config() 59 | 60 | def onChangePage(self): 61 | return { 62 | "title": _("完成"), 63 | "next_btn_label": _("完成"), 64 | "next_btn_enable": True 65 | } -------------------------------------------------------------------------------- /src/gui/component/window/dialog.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.config import Config 4 | from utils.common.enums import Platform 5 | 6 | class Dialog(wx.Dialog): 7 | def __init__(self, parent, title, style = wx.DEFAULT_DIALOG_STYLE, name = wx.DialogNameStr): 8 | wx.Dialog.__init__(self, parent, -1, title, style = style, name = name) 9 | 10 | self.Bind(wx.EVT_BUTTON, self.onCloseEVT, id = wx.ID_OK) 11 | self.Bind(wx.EVT_BUTTON, self.onCloseEVT, id = wx.ID_CANCEL) 12 | 13 | def get_scaled_size(self, size: tuple): 14 | match Platform(Config.Sys.platform): 15 | case Platform.Windows: 16 | return self.FromDIP(size) 17 | 18 | case Platform.Linux | Platform.macOS: 19 | return wx.DefaultSize 20 | 21 | def set_dark_mode(self): 22 | if not Config.Sys.dark_mode: 23 | self.SetBackgroundColour("white") 24 | 25 | def raise_top(self): 26 | self.SetWindowStyle(wx.DEFAULT_DIALOG_STYLE | wx.STAY_ON_TOP) 27 | self.SetWindowStyle(wx.DEFAULT_DIALOG_STYLE) 28 | 29 | def onCloseEVT(self, event): 30 | match event.GetId(): 31 | case wx.ID_OK: 32 | rtn = self.onOKEVT() 33 | 34 | case wx.ID_CANCEL: 35 | rtn = self.onCancelEVT() 36 | 37 | case _: 38 | rtn = False 39 | 40 | if not rtn: 41 | if Platform(Config.Sys.platform) == Platform.Windows: 42 | self.Destroy() 43 | 44 | return event.Skip() 45 | 46 | def onOKEVT(self): 47 | pass 48 | 49 | def onCancelEVT(self): 50 | pass 51 | 52 | def DWMExtendFrameIntoClientArea(self): 53 | if Platform(Config.Sys.platform) == Platform.Windows: 54 | import ctypes 55 | import ctypes.wintypes 56 | 57 | hwnd = self.GetHandle() 58 | 59 | class MARGINS(ctypes.Structure): 60 | _fields_ = [ 61 | ("cxLeftWidth", ctypes.c_int), 62 | ("cxRightWidth", ctypes.c_int), 63 | ("cyTopHeight", ctypes.c_int), 64 | ("cyBottomHeight", ctypes.c_int) 65 | ] 66 | 67 | margins = MARGINS(1, 1, 1, 1) 68 | colorref = ctypes.wintypes.RGB(255, 255, 255) 69 | 70 | ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, 35, ctypes.byref(ctypes.c_int(colorref)), ctypes.sizeof(ctypes.c_int(colorref))) 71 | ctypes.windll.dwmapi.DwmExtendFrameIntoClientArea(hwnd, ctypes.byref(margins)) 72 | 73 | return True 74 | 75 | -------------------------------------------------------------------------------- /src/utils/parse/festival.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | 4 | from utils.common.enums import StatusCode 5 | from utils.common.exception import GlobalException 6 | from utils.common.model.callback import ParseCallback 7 | from utils.common.request import RequestUtils 8 | from utils.common.regex import Regex 9 | 10 | from utils.parse.parser import Parser 11 | 12 | class FestivalInfo: 13 | url: str = "" 14 | 15 | class FestivalParser(Parser): 16 | def __init__(self, callback: ParseCallback): 17 | super().__init__() 18 | 19 | self.callback = callback 20 | 21 | def get_aid(self, initial_state: str): 22 | aid = self.re_find_str(r'"aid":([0-9]+)', initial_state) 23 | 24 | FestivalInfo.url = f"https://www.bilibili.com/video/{self.aid_to_bvid(int(aid[0]))}" 25 | 26 | def get_bvid(self, url: str): 27 | bvid = self.re_find_str(r"BV\w+", url) 28 | 29 | FestivalInfo.url = f"https://www.bilibili.com/video/{bvid[0]}" 30 | 31 | def get_initial_state(self, url: str): 32 | # 活动页链接不会包含 BV 号,ep 号等关键信息,故采用网页解析方式获取视频数据 33 | 34 | req = RequestUtils.request_get(url, headers = RequestUtils.get_headers()) 35 | 36 | if "window.__initialState" in req.text: 37 | initial_state_info = re.findall(r"window.__initialState = (.*?);", req.text) 38 | 39 | elif "window.__INITIAL_STATE__" in req.text: 40 | initial_state_info = re.findall(r"window.__INITIAL_STATE__=(.*?);", req.text) 41 | 42 | return initial_state_info[0] 43 | 44 | def get_real_url(self, initial_state): 45 | if "https://www.bilibili.com/bangumi/play/ss" in initial_state: 46 | # 直接查找跳转链接 47 | jump_url = re.findall(r"https://www.bilibili.com/bangumi/play/ss[0-9]+", initial_state) 48 | 49 | FestivalInfo.url = jump_url[0] 50 | 51 | elif "videoInfo" in initial_state: 52 | # 解析网页中的json信息 53 | info_json = json.loads(initial_state) 54 | 55 | # 找到当前视频的 bvid 56 | bvid = info_json["videoInfo"]["bvid"] 57 | 58 | FestivalInfo.url = f"https://www.bilibili.com/video/{bvid}" 59 | 60 | elif "aid" in initial_state: 61 | self.get_aid(initial_state) 62 | 63 | def parse_worker(self, url: str): 64 | match Regex.find_string(r"BV", url): 65 | case "BV": 66 | # 判断视频链接是否包含 BV 号 67 | self.get_bvid(url) 68 | 69 | case _: 70 | initial_state = self.get_initial_state(url) 71 | 72 | self.get_real_url(initial_state) 73 | 74 | raise GlobalException(code = StatusCode.Redirect.value, callback = self.callback.onJump, args = (FestivalInfo.url, )) 75 | -------------------------------------------------------------------------------- /src/utils/parse/live.py: -------------------------------------------------------------------------------- 1 | from utils.config import Config 2 | 3 | from utils.common.enums import StatusCode 4 | from utils.common.model.live_room_info import LiveRoomInfo 5 | from utils.common.model.callback import ParseCallback 6 | from utils.common.request import RequestUtils 7 | 8 | from utils.parse.parser import Parser 9 | 10 | class LiveParser(Parser): 11 | def __init__(self, callback: ParseCallback): 12 | super().__init__() 13 | 14 | self.callback = callback 15 | 16 | def get_room_id(self, url: str): 17 | room_id = self.re_find_str(r"live.bilibili.com/([0-9]+)", url) 18 | 19 | return int(room_id[0]) 20 | 21 | def get_live_room_info(self, room_id: int): 22 | # 获取直播间信息 23 | params = { 24 | "req_biz": "web_room_componet", 25 | "room_ids": room_id 26 | } 27 | 28 | url = f"https://api.live.bilibili.com/xlive/web-room/v1/index/getRoomBaseInfo?{self.url_encode(params)}" 29 | 30 | resp = self.request_get(url, headers = RequestUtils.get_headers(sessdata = Config.User.SESSDATA)) 31 | 32 | data: dict = self.json_get(resp, "data")["by_room_ids"] 33 | 34 | if json_data := data.get(room_id): 35 | self.info_json: dict = json_data 36 | 37 | elif json_data := data.get(str(room_id)): 38 | self.info_json: dict = json_data 39 | 40 | @classmethod 41 | def get_live_stream_info(cls, room_id: int): 42 | params = { 43 | "room_id": room_id, 44 | "protocol": 0, 45 | "format": 0, 46 | "codec": "0,1" 47 | } 48 | 49 | url = f"https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo?{cls.url_encode(params)}" 50 | 51 | resp = cls.request_get(url, headers = RequestUtils.get_headers(sessdata = Config.User.SESSDATA)) 52 | 53 | data = cls.json_get(resp, "data") 54 | 55 | return data["playurl_info"]["playurl"] 56 | 57 | def parse_worker(self, url: str): 58 | self.room_id = self.get_room_id(url) 59 | 60 | self.get_live_room_info(self.room_id) 61 | 62 | return StatusCode.Success.value 63 | 64 | def get_live_info(self): 65 | info = LiveRoomInfo() 66 | 67 | info.cover_url = self.info_json.get("cover") 68 | info.room_id = self.room_id 69 | info.up_name = self.info_json.get("uname") 70 | info.title = self.info_json.get("title") 71 | info.parent_area = self.info_json.get("parent_area_name") 72 | info.area = self.info_json.get("area_name") 73 | 74 | info.live_status = self.info_json.get("live_status") 75 | 76 | return info 77 | 78 | def get_parse_type_str(self): 79 | return "直播" -------------------------------------------------------------------------------- /src/utils/common/style/color.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.config import Config 4 | 5 | from utils.common.enums import Platform 6 | 7 | class Color: 8 | @staticmethod 9 | def get_panel_background_color(): 10 | match Platform(Config.Sys.platform): 11 | case Platform.Windows | Platform.Linux: 12 | return wx.SystemSettings.GetColour(wx.SYS_COLOUR_MENUBAR) 13 | 14 | case Platform.macOS: 15 | if Config.Sys.dark_mode: 16 | return wx.Colour(40, 40, 40) 17 | else: 18 | return wx.Colour(246, 246, 246) 19 | 20 | @staticmethod 21 | def get_text_color(): 22 | return wx.SystemSettings.GetColour(wx.SYS_COLOUR_MENUTEXT) 23 | 24 | @staticmethod 25 | def get_label_text_color(): 26 | return wx.Colour(64, 64, 64) 27 | 28 | @staticmethod 29 | def get_border_color(): 30 | if Config.Sys.dark_mode: 31 | return wx.Colour("white") 32 | else: 33 | return wx.Colour(227, 229, 231) 34 | 35 | @staticmethod 36 | def get_frame_text_color(): 37 | if Config.Sys.dark_mode: 38 | return wx.Colour("white") 39 | else: 40 | return wx.Colour(90, 90, 90) 41 | 42 | @staticmethod 43 | def convert_to_ass_abgr_color(hex_color: str, alpha: str = None): 44 | hex_new = hex_color.lstrip("#").upper() 45 | 46 | r, g, b, a = hex_new[0:2], hex_new[2:4], hex_new[4:6], hex_new[6:8] 47 | 48 | if alpha: 49 | a = alpha 50 | else: 51 | a = "00" if not a else a 52 | 53 | return f"&H{a}{b}{g}{r}&" 54 | 55 | @staticmethod 56 | def convert_to_ass_bgr_color(hex_color: str): 57 | hex_new = hex_color.lstrip("#").upper() 58 | 59 | r, g, b = hex_new[0:2], hex_new[2:4], hex_new[4:6] 60 | 61 | return f"&H{b}{g}{r}&" 62 | 63 | @staticmethod 64 | def convert_to_ass_a_color(alpha: int): 65 | return f"&H{Color.dec_to_hex(alpha)}" 66 | 67 | @staticmethod 68 | def convert_to_hex_color(ass_color: str): 69 | ass_new = ass_color.lstrip("&H").rstrip("&").upper() 70 | 71 | b, g, r = ass_new[0:2], ass_new[2:4], ass_new[4:6] 72 | 73 | return f"{r}{g}{b}" 74 | 75 | @staticmethod 76 | def convert_to_abgr_color(ass_color: str): 77 | ass_new = ass_color.lstrip("&H").rstrip("&").upper() 78 | 79 | a, b, g, r = ass_new[0:2], ass_new[2:4], ass_new[4:6], ass_new[6:8] 80 | 81 | return int(r, 16), int(g, 16), int(b, 16), int(a, 16) 82 | 83 | @staticmethod 84 | def dec_to_hex(dec_color: int): 85 | return hex(dec_color)[2:].upper() -------------------------------------------------------------------------------- /src/utils/common/regex.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | class Regex: 4 | @staticmethod 5 | def search(pattern: str, string: str): 6 | return re.search(pattern, string) 7 | 8 | @staticmethod 9 | def findall(pattern: str, string: str): 10 | return re.findall(pattern, string) 11 | 12 | @classmethod 13 | def re_findall_in_group(cls, pattern: str, string: str, group: int): 14 | result = re.findall(pattern, string) 15 | 16 | return cls.check_result(result[0], group) if result else cls.fill_empty(group) 17 | 18 | @classmethod 19 | def re_match_in_group(cls, pattern: str, string: str, group: int): 20 | match = re.search(pattern, string) 21 | 22 | return cls.check_result(cls.split(match.group(1)), group) if match else cls.fill_empty(group) 23 | 24 | @classmethod 25 | def find_illegal_chars(cls, string: str): 26 | return re.findall(r'[<>:"|?*\x00-\x1F]', string) 27 | 28 | @classmethod 29 | def find_illegal_chars_ex(cls, string: str): 30 | return re.findall(r'[<>:"\\|?*\x00-\x1F]', string) 31 | 32 | @classmethod 33 | def find_output_format(cls, acodec: str): 34 | return re.findall(r"\w+", acodec) 35 | 36 | @classmethod 37 | def find_string(cls, pattern: str, string: str): 38 | find = re.findall(pattern, string) 39 | 40 | if find: 41 | return find[0] 42 | else: 43 | return None 44 | 45 | @classmethod 46 | def check_result(cls, result: list, group: int): 47 | if len(result) < group: 48 | result.extend(cls.fill_empty(group - len(result))) 49 | 50 | return result 51 | 52 | @staticmethod 53 | def fill_empty(group): 54 | return ["--" for i in range(group)] 55 | 56 | @staticmethod 57 | def split(text): 58 | result = [] 59 | current = [] 60 | stack = [] 61 | 62 | for char in text: 63 | if char in '([{': 64 | stack.append(char) 65 | current.append(char) 66 | continue 67 | 68 | if char in ')]}': 69 | if stack: 70 | stack.pop() 71 | current.append(char) 72 | continue 73 | 74 | if char == ',' and not stack: 75 | result.append(''.join(current).strip()) 76 | current = [] 77 | continue 78 | 79 | current.append(char) 80 | 81 | if current: 82 | result.append(''.join(current).strip()) 83 | 84 | return result 85 | 86 | @staticmethod 87 | def sub(pattern: str, repl: str, string: str): 88 | return re.sub(pattern, repl, string) -------------------------------------------------------------------------------- /src/gui/dialog/setting/scrape_option/scrape_option.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.config import Config 5 | from utils.common.enums import Platform 6 | 7 | from gui.dialog.setting.scrape_option.video import VideoPage 8 | from gui.dialog.setting.scrape_option.episode import EpisodePage 9 | from gui.dialog.setting.scrape_option.movie import MoviePage 10 | from gui.dialog.setting.scrape_option.lesson import LessonPage 11 | 12 | from gui.component.window.dialog import Dialog 13 | 14 | _ = gettext.gettext 15 | 16 | class ScrapeOptionDialog(Dialog): 17 | def __init__(self, parent: wx.Window): 18 | Dialog.__init__(self, parent, _("刮削设置")) 19 | 20 | self.init_UI() 21 | 22 | self.CenterOnParent() 23 | 24 | def init_UI(self): 25 | self.tree_book = wx.Treebook(self, -1, size = self.get_book_size()) 26 | 27 | self.tree_book.AddPage(wx.Panel(self.tree_book), _("刮削设置 ")) 28 | self.tree_book.AddSubPage(VideoPage(self.tree_book), _("投稿视频")) 29 | self.tree_book.AddSubPage(EpisodePage(self.tree_book), _("剧集")) 30 | self.tree_book.AddSubPage(MoviePage(self.tree_book), _("电影")) 31 | self.tree_book.AddSubPage(LessonPage(self.tree_book), _("课程")) 32 | self.tree_book.ExpandNode(0, True) 33 | self.tree_book.SetSelection(1) 34 | 35 | self.ok_btn = wx.Button(self, wx.ID_OK, _("确定"), size = self.get_scaled_size((80, 30))) 36 | self.cancel_btn = wx.Button(self, wx.ID_CANCEL, _("取消"), size = self.get_scaled_size((80, 30))) 37 | 38 | bottom_hbox = wx.BoxSizer(wx.HORIZONTAL) 39 | bottom_hbox.AddStretchSpacer(1) 40 | bottom_hbox.Add(self.ok_btn, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 41 | bottom_hbox.Add(self.cancel_btn, 0, wx.ALL & (~wx.TOP) & (~wx.LEFT), self.FromDIP(6)) 42 | 43 | vbox = wx.BoxSizer(wx.VERTICAL) 44 | vbox.Add(self.tree_book, 1, wx.ALL | wx.EXPAND, self.FromDIP(6)) 45 | vbox.Add(bottom_hbox, 0, wx.EXPAND) 46 | 47 | self.SetSizerAndFit(vbox) 48 | 49 | def onOKEVT(self): 50 | scrape_option = {} 51 | 52 | for index in range(1, self.tree_book.GetPageCount()): 53 | page = self.tree_book.GetPage(index) 54 | 55 | scrape_option.update(page.save()) 56 | 57 | Config.Temp.scrape_option = scrape_option 58 | 59 | def get_book_size(self): 60 | match Platform(Config.Sys.platform): 61 | case Platform.Windows: 62 | if Config.Basic.language == "zh_CN": 63 | return self.FromDIP((400, 200)) 64 | else: 65 | return self.FromDIP((500, 200)) 66 | 67 | case Platform.Linux | Platform.macOS: 68 | if Config.Basic.language == "zh_CN": 69 | return self.FromDIP((500, 280)) 70 | else: 71 | return self.FromDIP((600, 280)) 72 | -------------------------------------------------------------------------------- /src/utils/parse/episode/favlist.py: -------------------------------------------------------------------------------- 1 | from utils.common.enums import ParseType 2 | 3 | from utils.parse.episode.episode_v2 import EpisodeInfo, Filter, Episode 4 | from utils.parse.episode.video import Video 5 | from utils.parse.episode.bangumi import Bangumi 6 | 7 | class FavList: 8 | @classmethod 9 | def parse_episodes_fast(cls, info_json: dict): 10 | EpisodeInfo.clear_episode_data() 11 | 12 | for episode in info_json.get("episodes"): 13 | if episode.get("page") != 0: 14 | if Episode.Utils.get_badge(episode["attr"]) != "已失效": 15 | EpisodeInfo.add_item(EpisodeInfo.root_pid, cls.get_entry_info_video(episode.copy())) 16 | 17 | elif episode.get("ogv"): 18 | EpisodeInfo.add_item(EpisodeInfo.root_pid, cls.get_entry_info_bangumi(episode.copy())) 19 | 20 | Filter.episode_display_mode(reset = True) 21 | 22 | @classmethod 23 | def parse_episodes_detail(cls, video_info_list: list[dict], parent_title: str): 24 | episode_info_list = [] 25 | 26 | for entry in video_info_list: 27 | if entry: 28 | match ParseType(entry["parse_type"]): 29 | case ParseType.Video: 30 | episode_info_list.extend(cls.video_parser(entry, parent_title)) 31 | 32 | case ParseType.Bangumi: 33 | episode_info_list.extend(cls.bangumi_parser(entry, parent_title)) 34 | 35 | return episode_info_list 36 | 37 | @classmethod 38 | def video_parser(cls, info_json: dict, parent_title: str): 39 | episode_info_list = [] 40 | bvid = info_json.get("bvid") 41 | 42 | if "ugc_season" in info_json: 43 | episode_info_list = Video.ugc_season_pages_parser(info_json, bvid, parent_title) 44 | 45 | else: 46 | episode_info_list = Video.pages_parser(info_json, parent_title) 47 | 48 | return episode_info_list 49 | 50 | @classmethod 51 | def bangumi_parser(cls, info_json: dict, parent_title: str): 52 | return Bangumi.episodes_single_parser(info_json, parent_title) 53 | 54 | @classmethod 55 | def get_entry_info_video(cls, episode: dict): 56 | episode["link"] = f"https://www.bilibili.com/video/{episode.get('bvid')}" 57 | episode["type"] = ParseType.Video.value 58 | 59 | return EpisodeInfo.get_entry_info(episode) 60 | 61 | @classmethod 62 | def get_entry_info_bangumi(cls, episode: dict): 63 | episode["title"] = "{} - {}".format(episode["title"], episode["intro"]) 64 | episode["badge"] = episode["ogv"]["type_name"] 65 | episode["season_id"] = episode["ogv"]["season_id"] 66 | episode["link"] = f"https://www.bilibili.com/bangumi/play/ss{episode.get('season_id')}" 67 | episode["type"] = ParseType.Bangumi.value 68 | 69 | return EpisodeInfo.get_entry_info(episode) 70 | -------------------------------------------------------------------------------- /src/utils/module/web/ws.py: -------------------------------------------------------------------------------- 1 | import json 2 | import asyncio 3 | import websockets 4 | from websockets.server import ServerProtocol 5 | 6 | from utils.config import Config 7 | 8 | from utils.auth.login_v2 import LoginInfo 9 | 10 | from utils.common.thread import Thread 11 | 12 | from utils.module.graph import Graph 13 | 14 | class WebSocketServer: 15 | def __init__(self): 16 | self.server = None 17 | self.server_task = None 18 | self.loop = asyncio.new_event_loop() 19 | self.clients = set() 20 | 21 | async def handler(self, websocket: ServerProtocol): 22 | self.clients.add(websocket) 23 | 24 | try: 25 | async for message in websocket: 26 | await self.process_message(str(message)) 27 | finally: 28 | self.clients.remove(websocket) 29 | 30 | async def process_message(self, message: str): 31 | async def queryCaptchaInfo(): 32 | data = { 33 | "msg": "queryCaptchaInfo", 34 | "data": { 35 | "gt": LoginInfo.Captcha.gt, 36 | "challenge": LoginInfo.Captcha.challenge 37 | } 38 | } 39 | 40 | await self.broadcast(data) 41 | 42 | async def captchaResult(): 43 | LoginInfo.Captcha.seccode = data["data"]["seccode"] 44 | LoginInfo.Captcha.validate = data["data"]["validate"] 45 | 46 | LoginInfo.Captcha.flag = False 47 | 48 | self.stop() 49 | 50 | async def queryGraph(): 51 | data = { 52 | "msg": "queryGraph", 53 | "data": Graph.get_graph_json(Config.Sys.default_font) 54 | } 55 | 56 | await self.broadcast(data) 57 | 58 | data = json.loads(message) 59 | 60 | match data.get("msg"): 61 | case "queryCaptchaInfo": 62 | await queryCaptchaInfo() 63 | 64 | case "captchaResult": 65 | await captchaResult() 66 | 67 | case "queryGraph": 68 | await queryGraph() 69 | 70 | async def broadcast(self, data: dict): 71 | message = json.dumps(data, ensure_ascii = False) 72 | 73 | if self.clients: 74 | await asyncio.gather( 75 | *(client.send(message) for client in self.clients) 76 | ) 77 | 78 | def start(self): 79 | async def run(): 80 | self.server = await websockets.serve(self.handler, "localhost", port = Config.Advanced.websocket_port) 81 | 82 | await self.server.wait_closed() 83 | 84 | self.server_task = self.loop.create_task(run()) 85 | 86 | Thread(target = self.loop.run_forever).start() 87 | 88 | def stop(self): 89 | if self.server: 90 | self.server.close() 91 | self.server = None 92 | 93 | @classmethod 94 | def running(cls): 95 | return cls.server is not None -------------------------------------------------------------------------------- /src/utils/parse/extra/file/metadata/lesson.py: -------------------------------------------------------------------------------- 1 | import math 2 | import textwrap 3 | 4 | from utils.common.model.task_info import DownloadTaskInfo 5 | from utils.common.datetime_util import DateTime 6 | 7 | from utils.parse.extra.file.metadata.utils import Utils 8 | 9 | class TVShowMetaDataParser: 10 | def __init__(self, task_info: DownloadTaskInfo): 11 | self.data = { 12 | "series_title": task_info.series_title, 13 | "description": task_info.description, 14 | "year": task_info.bangumi_pubdate[:4], 15 | "pubdate": task_info.bangumi_pubdate, 16 | "up_name": task_info.up_name, 17 | "poster_url": task_info.poster_url, 18 | "season_id": task_info.season_id, 19 | "dateadded": Utils.get_dateadded(task_info.pubtimestamp) 20 | } 21 | 22 | def get_nfo_contents(self): 23 | return textwrap.dedent("""\ 24 | 25 | 26 | {series_title} 27 | {description} 28 | {year} 29 | {pubdate} 30 | Bilibili 31 | 32 | {up_name} 33 | 发布者 34 | 35 | 1 36 | {poster_url} 37 | {series_title} 38 | {season_id} 39 | {dateadded} 40 | """.format(**self.data)).replace("\n\n", "\n") 41 | 42 | class EpisodeMetaDataParser: 43 | def __init__(self, task_info: DownloadTaskInfo): 44 | self.data = { 45 | "title": task_info.title, 46 | "runtime": math.floor(task_info.duration / 60), 47 | "aired": DateTime.time_str_from_timestamp(task_info.pubtimestamp, "%Y-%m-%d"), 48 | "thumb": task_info.cover_url, 49 | "cid": task_info.cid, 50 | "ep_id": task_info.ep_id, 51 | "dateadded": Utils.get_dateadded(task_info.pubtimestamp) 52 | } 53 | 54 | def get_nfo_contents(self): 55 | return textwrap.dedent("""\ 56 | 57 | 58 | {title} 59 | {title} 60 | {runtime} 61 | {aired} 62 | {aired} 63 | {thumb} 64 | {cid} 65 | {ep_id} 66 | {dateadded} 67 | """.format(**self.data)).replace("\n\n", "\n") 68 | -------------------------------------------------------------------------------- /src/gui/component/staticbox/font.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.common.style.font import SysFont 5 | 6 | from gui.component.panel.panel import Panel 7 | 8 | _ = gettext.gettext 9 | 10 | class FontStaticBox(Panel): 11 | def __init__(self, parent): 12 | Panel.__init__(self, parent) 13 | 14 | self.init_UI() 15 | 16 | def init_UI(self): 17 | font_box = wx.StaticBox(self, -1, _("字体")) 18 | 19 | self.font_name_choice = wx.ComboBox(font_box, -1, choices = SysFont.sys_font_list) 20 | 21 | self.font_size_box = wx.SpinCtrl(font_box, -1, min = 1, max = 100, initial = 0) 22 | font_size_unit_lab = wx.StaticText(font_box, -1, "pt") 23 | 24 | font_hbox = wx.BoxSizer(wx.HORIZONTAL) 25 | font_hbox.Add(self.font_name_choice, 0, wx.ALL, self.FromDIP(6)) 26 | font_hbox.Add(self.font_size_box, 0, wx.ALL & (~wx.LEFT), self.FromDIP(6)) 27 | font_hbox.Add(font_size_unit_lab, 0, wx.ALL & (~wx.LEFT) | wx.ALIGN_CENTER, self.FromDIP(6)) 28 | 29 | self.bold_chk = wx.CheckBox(font_box, -1, _("粗体")) 30 | self.italic_chk = wx.CheckBox(font_box, -1, _("斜体")) 31 | self.underline_chk = wx.CheckBox(font_box, -1, _("下划线")) 32 | self.strikeout_chk = wx.CheckBox(font_box, -1, _("删除线")) 33 | 34 | shape_hbox = wx.BoxSizer(wx.HORIZONTAL) 35 | shape_hbox.Add(self.bold_chk, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 36 | shape_hbox.Add(self.italic_chk, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 37 | shape_hbox.Add(self.underline_chk, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 38 | shape_hbox.Add(self.strikeout_chk, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 39 | 40 | font_sbox = wx.StaticBoxSizer(font_box, wx.VERTICAL) 41 | font_sbox.Add(font_hbox, 0, wx.EXPAND) 42 | font_sbox.Add(shape_hbox, 0, wx.EXPAND) 43 | 44 | self.SetSizer(font_sbox) 45 | 46 | def init_data(self, data: dict): 47 | if (font_name := data.get("font_name")) and font_name in SysFont.sys_font_list: 48 | self.font_name_choice.SetStringSelection(data.get("font_name")) 49 | else: 50 | self.font_name_choice.SetStringSelection(self.GetFont().GetFaceName()) 51 | 52 | self.font_size_box.SetValue(data.get("font_size")) 53 | self.bold_chk.SetValue(abs(data.get("bold"))) 54 | self.italic_chk.SetValue(abs(data.get("italic"))) 55 | self.underline_chk.SetValue(abs(data.get("underline"))) 56 | self.strikeout_chk.SetValue(abs(data.get("strikeout"))) 57 | 58 | def get_option(self): 59 | return { 60 | "font_name": self.font_name_choice.GetStringSelection(), 61 | "font_size": self.font_size_box.GetValue(), 62 | "bold": -int(self.bold_chk.GetValue()), 63 | "italic": -int(self.italic_chk.GetValue()), 64 | "underline": -int(self.underline_chk.GetValue()), 65 | "strikeout": -int(self.strikeout_chk.GetValue()) 66 | } -------------------------------------------------------------------------------- /src/gui/window/debug.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.module.web.ws import WebSocketServer 4 | 5 | from gui.component.window.frame import Frame 6 | 7 | class DebugWindow(Frame): 8 | def __init__(self, parent): 9 | from gui.window.main.main_v3 import MainWindow 10 | 11 | self.parent: MainWindow = parent 12 | 13 | Frame.__init__(self, parent, "Debug", style = self.get_window_style(), name = "debug") 14 | 15 | self.init_UI() 16 | 17 | self.init_utils() 18 | 19 | self.Bind_EVT() 20 | 21 | self.CenterOnParent() 22 | 23 | def init_UI(self): 24 | panel = wx.Panel(self, -1) 25 | 26 | self.enable_episode_list_chk = wx.CheckBox(panel, -1, "Enable Episode List Button") 27 | self.enable_download_option_chk = wx.CheckBox(panel, -1, "Enable Download Option Button") 28 | 29 | enable_hbox = wx.BoxSizer(wx.HORIZONTAL) 30 | enable_hbox.Add(self.enable_episode_list_chk, 0, wx.ALL, 10) 31 | enable_hbox.Add(self.enable_download_option_chk, 0, wx.ALL & (~wx.LEFT), 10) 32 | 33 | self.start_ws_btn = wx.Button(panel, -1, "Start WebSocket") 34 | self.stop_ws_btn = wx.Button(panel, -1, "Stop WebSocket") 35 | 36 | ws_hbox = wx.BoxSizer(wx.HORIZONTAL) 37 | ws_hbox.Add(self.start_ws_btn, 0, wx.ALL, self.FromDIP(6)) 38 | ws_hbox.Add(self.stop_ws_btn, 0, wx.ALL & (~wx.LEFT), self.FromDIP(6)) 39 | 40 | parse_info = wx.StaticText(panel, -1, "查看当前 ParseInfo") 41 | 42 | self.info_list = wx.ListCtrl(panel, -1, style = wx.LC_REPORT) 43 | 44 | vbox = wx.BoxSizer(wx.VERTICAL) 45 | vbox.Add(enable_hbox, 0, wx.EXPAND) 46 | vbox.Add(ws_hbox, 0, wx.EXPAND) 47 | vbox.Add(parse_info, 0, wx.ALL, 10) 48 | vbox.Add(self.info_list, 0, wx.ALL & (~wx.TOP), 10) 49 | 50 | panel.SetSizerAndFit(vbox) 51 | 52 | def init_utils(self): 53 | self.websocket_server = WebSocketServer() 54 | 55 | def Bind_EVT(self): 56 | self.enable_episode_list_chk.Bind(wx.EVT_CHECKBOX, self.onEnableEpisodeListEVT) 57 | self.enable_download_option_chk.Bind(wx.EVT_CHECKBOX, self.onEnableDownloadOptionEVT) 58 | 59 | self.start_ws_btn.Bind(wx.EVT_BUTTON, self.onStartWSEVT) 60 | self.stop_ws_btn.Bind(wx.EVT_BUTTON, self.onStopWSEVT) 61 | 62 | def onEnableEpisodeListEVT(self, event): 63 | self.parent.episode_option_btn.Enable(self.enable_episode_list_chk.GetValue()) 64 | 65 | def onEnableDownloadOptionEVT(self, event): 66 | self.parent.download_option_btn.Enable(self.enable_download_option_chk.GetValue()) 67 | 68 | def onStartWSEVT(self, event): 69 | self.websocket_server.start() 70 | 71 | def onStopWSEVT(self, event): 72 | self.websocket_server.stop() 73 | 74 | def get_window_style(self): 75 | style = wx.DEFAULT_FRAME_STYLE 76 | 77 | if self.parent.config.Basic.always_on_top: 78 | style |= wx.STAY_ON_TOP 79 | 80 | return style -------------------------------------------------------------------------------- /src/gui/window/settings/settings_v2.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.config import Config 5 | from utils.common.enums import Platform 6 | from utils.common.exception import GlobalException, show_error_message_dialog 7 | 8 | from gui.window.settings.basic import BasicPage 9 | from gui.window.settings.download import DownloadPage 10 | from gui.window.settings.advanced import AdvancedPage 11 | from gui.window.settings.ffmpeg import FFmpegPage 12 | from gui.window.settings.proxy import ProxyPage 13 | from gui.window.settings.misc import MiscPage 14 | 15 | from gui.component.window.dialog import Dialog 16 | 17 | _ = gettext.gettext 18 | 19 | class SettingWindow(Dialog): 20 | def __init__(self, parent: wx.Window): 21 | Dialog.__init__(self, parent, _("设置")) 22 | 23 | self.init_UI() 24 | 25 | self.CenterOnParent() 26 | 27 | def init_UI(self): 28 | self.note = wx.Notebook(self, -1, size = self.get_book_size()) 29 | 30 | self.note.AddPage(BasicPage(self.note), _("基本")) 31 | self.note.AddPage(DownloadPage(self.note), _("下载")) 32 | self.note.AddPage(AdvancedPage(self.note), _("高级")) 33 | self.note.AddPage(FFmpegPage(self.note), "FFmpeg") 34 | self.note.AddPage(ProxyPage(self.note), _("代理")) 35 | self.note.AddPage(MiscPage(self.note), _("其他")) 36 | 37 | self.ok_btn = wx.Button(self, wx.ID_OK, _("确定"), size = self.get_scaled_size((80, 30))) 38 | self.cancel_btn = wx.Button(self, wx.ID_CANCEL, _("取消"), size = self.get_scaled_size((80, 30))) 39 | 40 | bottom_hbox = wx.BoxSizer(wx.HORIZONTAL) 41 | bottom_hbox.AddStretchSpacer(1) 42 | bottom_hbox.Add(self.ok_btn, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 43 | bottom_hbox.Add(self.cancel_btn, 0, wx.ALL & (~wx.TOP) & (~wx.LEFT), self.FromDIP(6)) 44 | 45 | vbox = wx.BoxSizer(wx.VERTICAL) 46 | vbox.Add(self.note, 0, wx.EXPAND | wx.ALL, self.FromDIP(6)) 47 | vbox.Add(bottom_hbox, 0, wx.EXPAND) 48 | 49 | self.SetSizerAndFit(vbox) 50 | 51 | def onOKEVT(self): 52 | def on_error(): 53 | show_error_message_dialog(_("保存失败"), parent = self) 54 | 55 | try: 56 | for i in range(0, self.note.GetPageCount()): 57 | if self.note.GetPage(i).onValidate(): 58 | return True 59 | 60 | Config.save_app_config() 61 | 62 | self.GetParent().init_menubar() 63 | 64 | except Exception as e: 65 | raise GlobalException(callback = on_error) from e 66 | 67 | def get_book_size(self): 68 | match Platform(Config.Sys.platform): 69 | case Platform.Windows: 70 | if Config.Basic.language == "zh_CN": 71 | return self.FromDIP((315, 400)) 72 | else: 73 | return self.FromDIP((500, 400)) 74 | 75 | case Platform.Linux | Platform.macOS: 76 | if Config.Basic.language == "zh_CN": 77 | return self.FromDIP((360, 470)) 78 | else: 79 | return self.FromDIP((550, 470)) 80 | -------------------------------------------------------------------------------- /src/gui/component/button/large_bitmap_button.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.config import Config 4 | 5 | from utils.common.style.color import Color 6 | 7 | from gui.component.panel.panel import Panel 8 | from gui.component.staticbitmap.staticbitmap import StaticBitmap 9 | 10 | class LargeBitmapButton(Panel): 11 | def __init__(self, parent, bitmap: wx.Bitmap, label: str): 12 | Panel.__init__(self, parent) 13 | 14 | self.init_UI(bitmap, label) 15 | 16 | self.Bind_EVT() 17 | 18 | self.lab_hover = False 19 | 20 | def init_UI(self, bitmap: wx.Bitmap, label: str): 21 | self.bitmap = StaticBitmap(self, bmp = bitmap, size = self.FromDIP((48, 48))) 22 | self.label = wx.StaticText(self, -1, label) 23 | 24 | bitmap_hbox = wx.BoxSizer(wx.HORIZONTAL) 25 | bitmap_hbox.AddStretchSpacer() 26 | bitmap_hbox.Add(self.bitmap, 0, wx.ALL, self.FromDIP(6)) 27 | bitmap_hbox.AddStretchSpacer() 28 | 29 | label_hbox = wx.BoxSizer(wx.HORIZONTAL) 30 | label_hbox.AddStretchSpacer() 31 | label_hbox.Add(self.label, 0, wx.ALL, self.FromDIP(6)) 32 | label_hbox.AddStretchSpacer() 33 | 34 | vbox = wx.BoxSizer(wx.VERTICAL) 35 | vbox.Add(bitmap_hbox, 0, wx.EXPAND) 36 | vbox.Add(label_hbox, 0, wx.EXPAND) 37 | 38 | hbox = wx.BoxSizer(wx.HORIZONTAL) 39 | hbox.AddSpacer(self.FromDIP(10)) 40 | hbox.Add(vbox, 0, wx.EXPAND) 41 | hbox.AddSpacer(self.FromDIP(10)) 42 | 43 | self.SetSizer(hbox) 44 | 45 | def Bind_EVT(self): 46 | self.Bind(wx.EVT_ENTER_WINDOW, self.onHoverEVT) 47 | self.Bind(wx.EVT_LEAVE_WINDOW, self.onLeaveEVT) 48 | self.Bind(wx.EVT_LEFT_DOWN, self.onClickEVT) 49 | 50 | self.bitmap.Bind(wx.EVT_ENTER_WINDOW, self.onLabHoverEVT) 51 | self.bitmap.Bind(wx.EVT_LEAVE_WINDOW, self.onLabLeaveEVT) 52 | self.bitmap.Bind(wx.EVT_LEFT_DOWN, self.onClickEVT) 53 | 54 | self.label.Bind(wx.EVT_ENTER_WINDOW, self.onLabHoverEVT) 55 | self.label.Bind(wx.EVT_LEAVE_WINDOW, self.onLabLeaveEVT) 56 | self.label.Bind(wx.EVT_LEFT_DOWN, self.onClickEVT) 57 | 58 | def onHoverEVT(self, event): 59 | self.set_hover_bgcolor() 60 | 61 | self.Refresh() 62 | 63 | event.Skip() 64 | 65 | def onLeaveEVT(self, event): 66 | if not self.lab_hover: 67 | self.set_default_bgcolor() 68 | 69 | self.Refresh() 70 | 71 | event.Skip() 72 | 73 | def onLabHoverEVT(self, event): 74 | self.lab_hover = True 75 | 76 | event.Skip() 77 | 78 | def onLabLeaveEVT(self, event): 79 | self.lab_hover = False 80 | 81 | event.Skip() 82 | 83 | def onClickEVT(self, event): 84 | self.onClickCustomEVT() 85 | 86 | def set_hover_bgcolor(self): 87 | if Config.Sys.dark_mode: 88 | self.SetBackgroundColour(wx.Colour(60, 60, 60)) 89 | else: 90 | self.SetBackgroundColour(wx.Colour(200, 200, 200)) 91 | 92 | def set_default_bgcolor(self): 93 | self.SetBackgroundColour(Color.get_panel_background_color()) 94 | 95 | def onClickCustomEVT(self): 96 | pass -------------------------------------------------------------------------------- /src/gui/component/spinctrl/label_spinctrl.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from utils.config import Config 4 | from utils.common.enums import Platform 5 | 6 | from gui.component.panel.panel import Panel 7 | from gui.component.spinctrl.spinctrl import SpinCtrl 8 | 9 | class LabelSpinCtrl(Panel): 10 | def __init__(self, parent, label: str, value: int | float, unit: str, orient: int = wx.HORIZONTAL, float: int = False, max: int = 100, min: int = 0): 11 | self.label, self.value, self.unit, self.float, self.max, self.min = label, value, unit, float, max, min 12 | 13 | Panel.__init__(self, parent) 14 | 15 | match orient: 16 | case wx.VERTICAL: 17 | self.init_vertical_UI() 18 | 19 | case wx.HORIZONTAL: 20 | self.init_horizontal_UI() 21 | 22 | def init_vertical_UI(self): 23 | label = wx.StaticText(self, -1, self.label) 24 | 25 | lab_hbox = wx.BoxSizer(wx.HORIZONTAL) 26 | lab_hbox.AddStretchSpacer() 27 | lab_hbox.Add(label, 0, wx.ALL & (~wx.BOTTOM), self.FromDIP(6)) 28 | lab_hbox.AddStretchSpacer() 29 | 30 | self.spinctrl = self.get_spinctrl() 31 | unit_lab = wx.StaticText(self, -1, self.unit) 32 | 33 | spin_hbox = wx.BoxSizer(wx.HORIZONTAL) 34 | spin_hbox.Add(self.spinctrl, 0, wx.ALL & (~wx.LEFT) | wx.ALIGN_CENTER, self.FromDIP(6)) 35 | spin_hbox.Add(unit_lab, 0, wx.ALL & (~wx.LEFT) & (~wx.RIGHT) | wx.ALIGN_CENTER, self.FromDIP(6)) 36 | 37 | vbox = wx.BoxSizer(wx.VERTICAL) 38 | vbox.Add(lab_hbox, 0, wx.EXPAND) 39 | vbox.Add(spin_hbox, 0, wx.EXPAND) 40 | 41 | self.SetSizer(vbox) 42 | 43 | def init_horizontal_UI(self): 44 | label = wx.StaticText(self, -1, self.label) 45 | self.spinctrl = self.get_spinctrl() 46 | unit_lab = wx.StaticText(self, -1, self.unit) 47 | 48 | hbox = wx.BoxSizer(wx.HORIZONTAL) 49 | hbox.Add(label, 0, wx.ALL & (~wx.LEFT) | wx.ALIGN_CENTER, self.FromDIP(6)) 50 | hbox.Add(self.spinctrl, 0, wx.ALL & (~wx.LEFT) | wx.ALIGN_CENTER, self.FromDIP(6)) 51 | hbox.Add(unit_lab, 0, wx.ALL & (~wx.LEFT) & (~wx.RIGHT) | wx.ALIGN_CENTER, self.FromDIP(6)) 52 | 53 | self.SetSizer(hbox) 54 | 55 | def SetValue(self, value: int | float): 56 | self.spinctrl.SetValue(value) 57 | 58 | def GetValue(self): 59 | return self.spinctrl.GetValue() 60 | 61 | def SetToolTip(self, tip: str): 62 | self.spinctrl.SetToolTip(tip) 63 | 64 | def get_spinctrl(self): 65 | if self.float: 66 | spinctrl = wx.SpinCtrlDouble(self, -1, value = str(self.value), size = self.get_size(), min = self.min, max = self.max, inc = 0.1) 67 | spinctrl.SetDigits(1) 68 | 69 | else: 70 | spinctrl = SpinCtrl(self, value = str(self.value), min = self.min, max = self.max, size = self.get_size()) 71 | 72 | return spinctrl 73 | 74 | def get_size(self): 75 | match Platform(Config.Sys.platform): 76 | case Platform.Windows | Platform.macOS: 77 | return self.FromDIP((55, -1)) 78 | 79 | case Platform.Linux: 80 | return self.FromDIP((130, -1)) -------------------------------------------------------------------------------- /src/gui/dialog/history.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.common.datetime_util import DateTime 5 | 6 | from gui.component.window.dialog import Dialog 7 | 8 | _ = gettext.gettext 9 | 10 | class HistoryDialog(Dialog): 11 | def __init__(self, parent: wx.Window): 12 | Dialog.__init__(self, parent, title = _("历史记录")) 13 | 14 | self.init_UI() 15 | 16 | self.Bind_EVT() 17 | 18 | self.init_utils() 19 | 20 | self.CenterOnParent() 21 | 22 | def init_UI(self): 23 | history_lab = wx.StaticText(self, -1, _("历史记录(双击列表项目开始解析)")) 24 | 25 | self.history_list = wx.ListCtrl(self, -1, size = self.FromDIP((600, 280)), style = wx.LC_REPORT) 26 | 27 | self.clear_btn = wx.Button(self, -1, _("清除记录"), size = self.get_scaled_size((100, 28))) 28 | 29 | btn_hbox = wx.BoxSizer(wx.HORIZONTAL) 30 | btn_hbox.AddStretchSpacer() 31 | btn_hbox.Add(self.clear_btn, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 32 | 33 | vbox = wx.BoxSizer(wx.VERTICAL) 34 | vbox.Add(history_lab, 0, wx.ALL & (~wx.BOTTOM), self.FromDIP(6)) 35 | vbox.Add(self.history_list, 1, wx.ALL | wx.EXPAND, self.FromDIP(6)) 36 | vbox.Add(btn_hbox, 0, wx.EXPAND) 37 | 38 | self.SetSizerAndFit(vbox) 39 | 40 | def Bind_EVT(self): 41 | self.clear_btn.Bind(wx.EVT_BUTTON, self.onClearHistoryEVT) 42 | 43 | self.history_list.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.onActivateItemEVT) 44 | 45 | def init_utils(self): 46 | self.init_list_column() 47 | self.init_list_data() 48 | 49 | def init_list_column(self): 50 | self.history_list.AppendColumn(_("序号"), width = self.FromDIP(50)) 51 | self.history_list.AppendColumn(_("时间"), width = self.FromDIP(150)) 52 | self.history_list.AppendColumn(_("类别"), width = self.FromDIP(60)) 53 | self.history_list.AppendColumn(_("标题"), width = self.FromDIP(250)) 54 | self.history_list.AppendColumn(_("URL"), width = self.FromDIP(200)) 55 | 56 | def init_list_data(self): 57 | history_data = self.history_object.get() 58 | 59 | for index, entry in enumerate(history_data): 60 | self.history_list.Append([str(index + 1), DateTime.time_str_from_timestamp(entry.get("time", ""), "%Y-%m-%d %H:%M:%S"), entry.get("category", ""), entry.get("title", ""), entry.get("url", "")]) 61 | 62 | self.history_list.SetColumnWidth(4, self.FromDIP(-1)) 63 | 64 | def onClearHistoryEVT(self, event: wx.CommandEvent): 65 | self.history_object.clear() 66 | 67 | self.history_list.DeleteAllItems() 68 | 69 | self.main_window.utils.update_history() 70 | 71 | def onActivateItemEVT(self, event: wx.ListEvent): 72 | url = self.history_list.GetItemText(event.GetIndex(), 4) 73 | 74 | self.main_window.top_box.url_box.SetValue(url) 75 | 76 | self.Close() 77 | 78 | self.main_window.onParseEVT(0) 79 | 80 | @property 81 | def main_window(self): 82 | from gui.window.main.main_v3 import MainWindow 83 | 84 | main_window: MainWindow = wx.FindWindowByName("main") 85 | 86 | return main_window 87 | 88 | @property 89 | def history_object(self): 90 | return self.main_window.history -------------------------------------------------------------------------------- /src/gui/dialog/error.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.config import Config 5 | from utils.common.exception import GlobalExceptionInfo 6 | from utils.common.datetime_util import DateTime 7 | 8 | from gui.component.window.dialog import Dialog 9 | from gui.component.staticbitmap.staticbitmap import StaticBitmap 10 | 11 | _ = gettext.gettext 12 | 13 | class ErrorInfoDialog(Dialog): 14 | def __init__(self, parent, exception_info = GlobalExceptionInfo.info): 15 | self.exception_info: dict = exception_info 16 | 17 | Dialog.__init__(self, parent, _("错误日志")) 18 | 19 | self.init_UI() 20 | 21 | self.CenterOnParent() 22 | 23 | wx.Bell() 24 | 25 | def init_UI(self): 26 | err_icon = StaticBitmap(self, bmp = wx.ArtProvider().GetBitmap(wx.ART_ERROR, size = self.FromDIP((28, 28))), size = self.FromDIP((28, 28))) 27 | 28 | time_lab = wx.StaticText(self, -1, _("记录时间:%s") % DateTime.time_str_from_timestamp(self.exception_info.get("timestamp"))) 29 | error_type = wx.StaticText(self, -1, _("异常类型:%s") % self.exception_info.get("exception_name")) 30 | error_id_lab = wx.StaticText(self, -1, _("错误码:%s") % self.exception_info.get("code")) 31 | message_lab = wx.StaticText(self, -1, _("描述:%s") % self.exception_info.get("message"), size = self.FromDIP((300, 16)), style = wx.ST_ELLIPSIZE_END) 32 | 33 | box_sizer = wx.FlexGridSizer(2, 2, 0, 75) 34 | box_sizer.Add(time_lab, 0, wx.ALL, self.FromDIP(6)) 35 | box_sizer.Add(error_type, 0, wx.ALL, self.FromDIP(6)) 36 | box_sizer.Add(error_id_lab, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 37 | box_sizer.Add(message_lab, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 38 | 39 | top_hbox = wx.BoxSizer(wx.HORIZONTAL) 40 | top_hbox.Add(err_icon, 0, wx.ALL | wx.ALIGN_CENTER, self.FromDIP(6)) 41 | top_hbox.Add(box_sizer, 0, wx.ALL | wx.EXPAND, self.FromDIP(6)) 42 | 43 | top_border = wx.StaticLine(self, -1, style = wx.HORIZONTAL) 44 | 45 | font: wx.Font = self.GetFont() 46 | font.SetFractionalPointSize(int(font.GetFractionalPointSize() + 1)) 47 | 48 | self.log_box = wx.TextCtrl(self, -1, str(self.exception_info.get("stack_trace")), size = self.FromDIP((620, 250)), style = wx.TE_MULTILINE | wx.TE_READONLY | wx.BORDER_NONE) 49 | self.log_box.SetFont(font) 50 | 51 | self.close_btn = wx.Button(self, wx.ID_CANCEL, _("关闭"), size = self.get_scaled_size((80, 28))) 52 | 53 | bottom_border = wx.StaticLine(self, -1, style = wx.HORIZONTAL) 54 | 55 | bottom_hbox = wx.BoxSizer(wx.HORIZONTAL) 56 | bottom_hbox.AddStretchSpacer() 57 | bottom_hbox.Add(self.close_btn, 0, wx.ALL & (~wx.LEFT), self.FromDIP(6)) 58 | 59 | vbox = wx.BoxSizer(wx.VERTICAL) 60 | vbox.Add(top_hbox, 0, wx.EXPAND) 61 | vbox.Add(top_border, 0, wx.EXPAND) 62 | vbox.Add(self.log_box, 0, wx.EXPAND) 63 | vbox.Add(bottom_border, 0, wx.EXPAND) 64 | vbox.Add(bottom_hbox, 0, wx.EXPAND) 65 | 66 | self.SetSizerAndFit(vbox) 67 | 68 | self.set_dark_mode() 69 | 70 | def set_dark_mode(self): 71 | if not Config.Sys.dark_mode: 72 | self.SetBackgroundColour("white") 73 | self.log_box.SetBackgroundColour("white") -------------------------------------------------------------------------------- /src/gui/window/main/bottom_box.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import time 3 | import gettext 4 | 5 | from utils.config import Config 6 | from utils.common.thread import Thread 7 | 8 | from utils.module.pic.face import Face 9 | 10 | from gui.id import ID 11 | 12 | from gui.component.panel.panel import Panel 13 | from gui.component.button.button import Button 14 | from gui.component.menu.user import UserMenu 15 | from gui.component.staticbitmap.staticbitmap import StaticBitmap 16 | 17 | _ = gettext.gettext 18 | 19 | class BottomBox(Panel): 20 | def __init__(self, parent): 21 | Panel.__init__(self, parent) 22 | 23 | self.init_UI() 24 | 25 | self.Bind_EVT() 26 | 27 | def init_UI(self): 28 | self.face_icon = StaticBitmap(self, size = self.FromDIP((32, 32))) 29 | self.face_icon.SetCursor(wx.Cursor(wx.CURSOR_HAND)) 30 | self.face_icon.Hide() 31 | self.uname_lab = wx.StaticText(self, -1, _("未登录")) 32 | self.uname_lab.SetCursor(wx.Cursor(wx.CURSOR_HAND)) 33 | self.download_mgr_btn = Button(self, _("下载管理"), size = self.get_scaled_size((100, 30))) 34 | self.download_btn = Button(self, _("开始下载"), size = self.get_scaled_size((100, 30))) 35 | self.download_btn.Enable(False) 36 | 37 | bottom_hbox = wx.BoxSizer(wx.HORIZONTAL) 38 | bottom_hbox.Add(self.face_icon, 0, wx.ALL & (~wx.RIGHT), self.FromDIP(6)) 39 | bottom_hbox.Add(self.uname_lab, 0, wx.ALL | wx.ALIGN_CENTER, self.FromDIP(6)) 40 | bottom_hbox.AddStretchSpacer() 41 | bottom_hbox.Add(self.download_mgr_btn, 0, wx.ALL | wx.ALIGN_CENTER, self.FromDIP(6)) 42 | bottom_hbox.Add(self.download_btn, 0, wx.ALL & (~wx.LEFT) | wx.ALIGN_CENTER, self.FromDIP(6)) 43 | 44 | self.SetSizer(bottom_hbox) 45 | 46 | def Bind_EVT(self): 47 | self.face_icon.Bind(wx.EVT_LEFT_DOWN, self.onShowUserMenuEVT) 48 | self.uname_lab.Bind(wx.EVT_LEFT_DOWN, self.onShowUserMenuEVT) 49 | 50 | def onShowUserMenuEVT(self, event: wx.MouseEvent): 51 | if Config.User.login: 52 | menu = UserMenu() 53 | 54 | self.PopupMenu(menu) 55 | else: 56 | evt = wx.PyCommandEvent(wx.EVT_MENU.typeId, id = ID.LOGIN_MENU) 57 | wx.PostEvent(self.GetEventHandler(), evt) 58 | 59 | def show_user_info(self): 60 | self.face_icon.Show() 61 | self.uname_lab.Show() 62 | 63 | image = Face.get_user_face_image() 64 | 65 | self.face_icon.SetBitmap(bmp = Face.crop_round_face_bmp(image)) 66 | self.uname_lab.SetLabel(Config.User.username) 67 | 68 | self.GetSizer().Layout() 69 | 70 | def hide_user_info(self): 71 | self.face_icon.Hide() 72 | self.uname_lab.Hide() 73 | 74 | self.GetSizer().Layout() 75 | 76 | def set_not_login(self): 77 | self.face_icon.Hide() 78 | self.uname_lab.SetLabel(_("未登录")) 79 | 80 | self.GetSizer().Layout() 81 | 82 | def download_tip(self): 83 | def worker(): 84 | wx.CallAfter(self.download_btn.SetLabel, _("✔️已开始下载")) 85 | 86 | time.sleep(1) 87 | 88 | wx.CallAfter(self.download_btn.SetLabel, _("开始下载")) 89 | 90 | if not Config.Basic.auto_show_download_window: 91 | Thread(target = worker).start() -------------------------------------------------------------------------------- /src/gui/dialog/download_option/other.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.config import Config 5 | from utils.common.map import number_type_map 6 | 7 | from gui.component.panel.panel import Panel 8 | from gui.component.misc.tooltip import ToolTip 9 | 10 | _ = gettext.gettext 11 | 12 | class OtherStaticBox(Panel): 13 | def __init__(self, parent): 14 | Panel.__init__(self, parent) 15 | 16 | self.init_UI() 17 | 18 | def init_UI(self): 19 | other_box = wx.StaticBox(self, -1, _("其他选项")) 20 | 21 | self.auto_popup_chk = wx.CheckBox(other_box, -1, _("下载时自动弹出此对话框")) 22 | self.auto_show_download_window_chk = wx.CheckBox(other_box, -1, _("自动跳转下载窗口")) 23 | 24 | self.add_independent_number_chk = wx.CheckBox(other_box, -1, _("在文件名前添加独立序号")) 25 | add_independent_number_tip = ToolTip(other_box) 26 | add_independent_number_tip.set_tooltip(_("此处添加的序号与文件名模板中的序号相互独立,仅用于快捷控制是否添加序号,如果文件名模板中添加了序号字段,则不受此选项影响")) 27 | 28 | add_independent_number_hbox = wx.BoxSizer(wx.HORIZONTAL) 29 | add_independent_number_hbox.Add(self.add_independent_number_chk, 0, wx.ALL & (~wx.TOP) & (~wx.BOTTOM) | wx.ALIGN_CENTER, self.FromDIP(6)) 30 | add_independent_number_hbox.Add(add_independent_number_tip, 0, wx.ALL & (~wx.LEFT) & (~wx.TOP) & (~wx.BOTTOM) | wx.ALIGN_CENTER, self.FromDIP(6)) 31 | 32 | self.number_type_lab = wx.StaticText(other_box, -1, _("序号类型")) 33 | self.number_type_choice = wx.Choice(other_box, -1, choices = list(number_type_map.keys())) 34 | number_type_tip = ToolTip(other_box) 35 | number_type_tip.set_tooltip(_("总是从 1 开始:每次下载时,序号都从 1 开始递增\n连贯递增:每次下载时,序号都连贯递增,退出程序后重置\n使用剧集列表序号:使用在剧集列表中显示的序号")) 36 | 37 | number_type_hbox = wx.BoxSizer(wx.HORIZONTAL) 38 | number_type_hbox.Add(self.number_type_lab, 0, wx.ALL | wx.ALIGN_CENTER, self.FromDIP(6)) 39 | number_type_hbox.Add(self.number_type_choice, 0, wx.ALL & (~wx.LEFT) | wx.ALIGN_CENTER, self.FromDIP(6)) 40 | number_type_hbox.Add(number_type_tip, 0, wx.ALL & (~wx.LEFT) | wx.ALIGN_CENTER, self.FromDIP(6)) 41 | number_type_hbox.AddSpacer(self.FromDIP(30)) 42 | 43 | other_sbox = wx.StaticBoxSizer(other_box, wx.VERTICAL) 44 | other_sbox.Add(self.auto_popup_chk, 0, wx.ALL, self.FromDIP(6)) 45 | other_sbox.Add(self.auto_show_download_window_chk, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 46 | other_sbox.Add(add_independent_number_hbox, 0, wx.EXPAND) 47 | other_sbox.Add(number_type_hbox, 0, wx.EXPAND) 48 | 49 | self.SetSizer(other_sbox) 50 | 51 | def load_data(self): 52 | self.auto_popup_chk.SetValue(Config.Basic.auto_popup_option_dialog) 53 | self.auto_show_download_window_chk.SetValue(Config.Basic.auto_show_download_window) 54 | self.add_independent_number_chk.SetValue(Config.Download.add_independent_number) 55 | self.number_type_choice.SetSelection(Config.Download.number_type) 56 | 57 | def save(self): 58 | Config.Basic.auto_popup_option_dialog = self.auto_popup_chk.GetValue() 59 | Config.Basic.auto_show_download_window = self.auto_show_download_window_chk.GetValue() 60 | Config.Download.add_independent_number = self.add_independent_number_chk.GetValue() 61 | Config.Download.number_type = self.number_type_choice.GetSelection() 62 | -------------------------------------------------------------------------------- /src/utils/parse/popular.py: -------------------------------------------------------------------------------- 1 | from utils.config import Config 2 | 3 | from utils.auth.wbi import WbiUtils 4 | 5 | from utils.common.request import RequestUtils 6 | from utils.common.model.callback import ParseCallback 7 | from utils.common.enums import StatusCode 8 | from utils.common.map import rid_map 9 | from utils.common.regex import Regex 10 | from utils.common.exception import GlobalException 11 | 12 | from utils.parse.parser import Parser 13 | from utils.parse.episode.popular import Popular 14 | 15 | class PopularParser(Parser): 16 | def __init__(self, callback: ParseCallback): 17 | super().__init__() 18 | 19 | self.callback = callback 20 | 21 | def get_weekly_number(self, url: str): 22 | number = self.re_find_str(r"num=([0-9]+)", url) 23 | 24 | return number[0] 25 | 26 | def get_rid(self, url: str): 27 | for key, value in rid_map.items(): 28 | if key in url: 29 | return value.get("rid"), value.get("desc") 30 | 31 | raise GlobalException(message = "暂不支持解析此类链接", callback = self.callback.onError) 32 | 33 | def get_popular_weekly_list(self, number: int): 34 | params = { 35 | "number": number 36 | } 37 | 38 | url = f"https://api.bilibili.com/x/web-interface/popular/series/one?number={number}&{WbiUtils.encWbi(params)}" 39 | 40 | resp = self.request_get(url, headers = RequestUtils.get_headers(referer_url = self.bilibili_url, sessdata = Config.User.SESSDATA)) 41 | 42 | self.info_json: dict = self.json_get(resp, "data") 43 | 44 | def get_popular_rank_list(self, rid: int, desc: str): 45 | params = { 46 | "rid": rid, 47 | "type": "all" 48 | } 49 | 50 | url = f"https://api.bilibili.com/x/web-interface/ranking/v2?{WbiUtils.encWbi(params)}" 51 | 52 | resp = self.request_get(url, headers = RequestUtils.get_headers(referer_url = self.bilibili_url, sessdata = Config.User.SESSDATA)) 53 | 54 | self.info_json: dict = self.json_get(resp, "data") 55 | 56 | self.info_json["config"] = { 57 | "label": f"{desc}排行榜" 58 | } 59 | 60 | def get_popular_available_media_info(self): 61 | from utils.parse.video import VideoParser 62 | 63 | episode: dict = self.info_json["list"][0] 64 | 65 | bvid, self.cid = episode.get("bvid"), episode.get("cid") 66 | 67 | self.parse_episodes() 68 | 69 | VideoParser.get_video_available_media_info(bvid, self.cid) 70 | 71 | def parse_worker(self, url: str): 72 | match Regex.find_string(r"weekly|rank", url): 73 | case "weekly": 74 | number = self.get_weekly_number(url) 75 | 76 | self.get_popular_weekly_list(number) 77 | 78 | case "rank": 79 | rid, desc = self.get_rid(url) 80 | 81 | self.get_popular_rank_list(rid, desc) 82 | 83 | self.start_thread(self.get_popular_available_media_info) 84 | 85 | self.callback.onUpdateHistory(url, self.info_json["config"]["label"], self.get_parse_type_str()) 86 | 87 | return StatusCode.Success.value 88 | 89 | def parse_episodes(self): 90 | Popular.parse_episodes(self.info_json, self.cid) 91 | 92 | def get_parse_type_str(self): 93 | return "热榜" 94 | -------------------------------------------------------------------------------- /src/gui/dialog/setting/select_batch.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from gui.component.window.dialog import Dialog 5 | from gui.component.text_ctrl.search_ctrl import SearchCtrl 6 | from gui.component.misc.tooltip import ToolTip 7 | 8 | _ = gettext.gettext 9 | 10 | class SelectBatchDialog(Dialog): 11 | def __init__(self, parent: wx.Window): 12 | from gui.window.main.main_v3 import MainWindow 13 | 14 | self.parent: MainWindow = parent 15 | 16 | Dialog.__init__(self, parent, _("批量选取项目")) 17 | 18 | self.init_UI() 19 | 20 | self.Bind_EVT() 21 | 22 | self.CenterOnParent() 23 | 24 | def init_UI(self): 25 | title_lab = wx.StaticText(self, -1, _("序号区间")) 26 | tooltip = ToolTip(self) 27 | tooltip.set_tooltip(_("序号区间支持输入多个,以英文逗号分隔,如 1-3,5,7-10 表示选取第 1 至 3 项、第 5 项和第 7 至 10 项")) 28 | 29 | top_hbox = wx.BoxSizer(wx.HORIZONTAL) 30 | top_hbox.Add(title_lab, 0, wx.ALL | wx.ALIGN_CENTER, self.FromDIP(6)) 31 | top_hbox.Add(tooltip, 0, wx.ALL & (~wx.LEFT) | wx.ALIGN_CENTER, self.FromDIP(6)) 32 | 33 | self.range_box = SearchCtrl(self, _("请输入序号区间,以英文逗号分隔"), size = self.FromDIP((350, -1)), clear_btn = True) 34 | 35 | shift_tip = wx.StaticText(self, -1, _("提示:按住 Shift 键也可批量选取项目")) 36 | 37 | self.ok_btn = wx.Button(self, wx.ID_OK, _("确定"), size = self.get_scaled_size((80, 30))) 38 | self.cancel_btn = wx.Button(self, wx.ID_CANCEL, _("取消"), size = self.get_scaled_size((80, 30))) 39 | 40 | bottom_hbox = wx.BoxSizer(wx.HORIZONTAL) 41 | bottom_hbox.AddStretchSpacer(1) 42 | bottom_hbox.Add(self.ok_btn, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 43 | bottom_hbox.Add(self.cancel_btn, 0, wx.ALL & (~wx.TOP) & (~wx.LEFT), self.FromDIP(6)) 44 | 45 | vbox = wx.BoxSizer(wx.VERTICAL) 46 | 47 | vbox.Add(top_hbox, 0, wx.EXPAND) 48 | vbox.Add(self.range_box, 0, wx.ALL & (~wx.TOP) | wx.EXPAND, self.FromDIP(6)) 49 | vbox.Add(shift_tip, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 50 | vbox.Add(bottom_hbox, 0, wx.EXPAND) 51 | 52 | self.SetSizerAndFit(vbox) 53 | 54 | def Bind_EVT(self): 55 | self.range_box.Bind(wx.EVT_KEY_DOWN, self.onEnterEVT) 56 | 57 | def onEnterEVT(self, event: wx.KeyEvent): 58 | keycode = event.GetKeyCode() 59 | 60 | if keycode in [wx.WXK_RETURN, wx.WXK_NUMPAD_ENTER]: 61 | ok_event = wx.PyCommandEvent(wx.EVT_BUTTON.typeId, self.ok_btn.GetId()) 62 | ok_event.SetEventObject(self.ok_btn) 63 | 64 | wx.PostEvent(self.ok_btn.GetEventHandler(), ok_event) 65 | 66 | event.Skip() 67 | 68 | def onOKEVT(self): 69 | if not self.range_box.GetValue(): 70 | wx.MessageDialog(self, _("选取剧集失败\n\n序号区间不能为空"), _("警告"), wx.ICON_WARNING).ShowModal() 71 | return True 72 | 73 | self.parent.episode_list.UnCheckAllItems() 74 | 75 | self.parse_range() 76 | 77 | def parse_range(self): 78 | range_text = self.range_box.GetValue() 79 | 80 | for entry in range_text.split(","): 81 | if "-" in entry: 82 | start, end = entry.split("-") 83 | else: 84 | start, end = entry, entry 85 | 86 | self.parent.episode_list.CheckItemRange(int(start), int(end), uncheck_all = False) -------------------------------------------------------------------------------- /src/gui/dialog/setting/custom_subtitle_lan.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.config import Config 5 | 6 | from utils.common.enums import SubtitleLanOption 7 | from utils.common.data.subtitle_lan import subtitle_lan_list 8 | 9 | from gui.component.window.dialog import Dialog 10 | 11 | _ = gettext.gettext 12 | 13 | class CustomLanDialog(Dialog): 14 | def __init__(self, parent): 15 | Dialog.__init__(self, parent, _("自定义字幕语言")) 16 | 17 | self.init_UI() 18 | 19 | self.Bind_EVT() 20 | 21 | self.init_utils() 22 | 23 | self.CenterOnParent() 24 | 25 | def init_UI(self): 26 | lan_lab = wx.StaticText(self, -1, _("字幕语言下载选项")) 27 | self.select_all_radio = wx.RadioButton(self, -1, _("下载全部可用字幕")) 28 | self.custom_radio = wx.RadioButton(self, -1, _("手动选择")) 29 | 30 | self.lan_box = wx.CheckListBox(self, -1, size = self.FromDIP((200, 150))) 31 | 32 | self.ok_btn = wx.Button(self, wx.ID_OK, _("确定"), size = self.get_scaled_size((80, 30))) 33 | self.cancel_btn = wx.Button(self, wx.ID_CANCEL, _("取消"), size = self.get_scaled_size((80, 30))) 34 | 35 | bottom_hbox = wx.BoxSizer(wx.HORIZONTAL) 36 | bottom_hbox.AddStretchSpacer() 37 | bottom_hbox.Add(self.ok_btn, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 38 | bottom_hbox.Add(self.cancel_btn, 0, wx.ALL & (~wx.TOP) & (~wx.LEFT), self.FromDIP(6)) 39 | 40 | vbox = wx.BoxSizer(wx.VERTICAL) 41 | vbox.Add(lan_lab, 0, wx.ALL, self.FromDIP(6)) 42 | vbox.Add(self.select_all_radio, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 43 | vbox.Add(self.custom_radio, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 44 | vbox.Add(self.lan_box, 0, wx.ALL & (~wx.TOP) | wx.EXPAND, self.FromDIP(6)) 45 | vbox.Add(bottom_hbox, 0, wx.EXPAND) 46 | 47 | self.SetSizerAndFit(vbox) 48 | 49 | def init_utils(self): 50 | self.lan_list = {entry["doc_zh"]: entry["lan"] for entry in subtitle_lan_list} 51 | 52 | match SubtitleLanOption(Config.Basic.subtitle_lan_option): 53 | case SubtitleLanOption.All_Subtitles: 54 | self.select_all_radio.SetValue(True) 55 | 56 | case SubtitleLanOption.Custom: 57 | self.custom_radio.SetValue(True) 58 | 59 | self.lan_box.Set(list(self.lan_list.keys())) 60 | 61 | for lan in Config.Basic.subtitle_lan_custom_type: 62 | self.lan_box.Check(list(self.lan_list.values()).index(lan), True) 63 | 64 | self.onChangeOptionEVT(0) 65 | 66 | def Bind_EVT(self): 67 | self.select_all_radio.Bind(wx.EVT_RADIOBUTTON, self.onChangeOptionEVT) 68 | self.custom_radio.Bind(wx.EVT_RADIOBUTTON, self.onChangeOptionEVT) 69 | 70 | def onChangeOptionEVT(self, event): 71 | enable = self.custom_radio.GetValue() 72 | 73 | self.lan_box.Enable(enable) 74 | 75 | def get_selected_lan_list(self): 76 | return [list(self.lan_list.values())[index] for index in self.lan_box.GetCheckedItems()] 77 | 78 | def onOKEVT(self): 79 | if self.select_all_radio.GetValue(): 80 | Config.Basic.subtitle_lan_option = SubtitleLanOption.All_Subtitles.value 81 | 82 | else: 83 | Config.Basic.subtitle_lan_option = SubtitleLanOption.Custom.value 84 | Config.Basic.subtitle_lan_custom_type = self.get_selected_lan_list() 85 | -------------------------------------------------------------------------------- /src/utils/parse/extra/nfo/episode.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from utils.config import Config 4 | from utils.common.model.task_info import DownloadTaskInfo 5 | from utils.common.enums import CoverType 6 | 7 | from utils.module.pic.cover import Cover 8 | 9 | from utils.parse.extra.parser import Parser 10 | from utils.parse.bangumi import BangumiParser 11 | from utils.parse.extra.file.metadata.episode import TVShowMetaDataParser, SeasonMetadataParser, EpisodeMetadataParser 12 | from utils.parse.extra.file.metadata.utils import Utils 13 | 14 | class EpisodeNFOParser(Parser): 15 | def __init__(self, task_info: DownloadTaskInfo): 16 | Parser.__init__(self) 17 | 18 | self.task_info = task_info 19 | 20 | def download_tvshow_nfo(self): 21 | file_path = Utils.get_root_path(self.task_info) 22 | file_name = "tvshow.nfo" 23 | 24 | if self.check_file(file_path, file_name): 25 | return 26 | 27 | self.get_bangumi_season_info() 28 | self.get_bangumi_poster(file_path, "poster.jpg") 29 | 30 | file = TVShowMetaDataParser(self.task_info) 31 | contents = file.get_nfo_contents() 32 | 33 | self.save_file_ex(file_path, file_name, contents, "w") 34 | 35 | def download_season_nfo(self): 36 | file_path = self.task_info.download_path 37 | file_name = "season.nfo" 38 | 39 | if self.check_file(file_path, file_name): 40 | return 41 | 42 | self.get_bangumi_season_info() 43 | 44 | if Config.Download.strict_naming: 45 | file_name = f"season{self.task_info.season_num:02}-poster.jpg" 46 | else: 47 | file_name = "poster.jpg" 48 | 49 | self.get_bangumi_poster(Utils.get_root_path(self.task_info), file_name) 50 | 51 | file = SeasonMetadataParser(self.task_info) 52 | contents = file.get_nfo_contents() 53 | 54 | self.save_file_ex(file_path, file_name, contents, "w") 55 | 56 | def download_episode_nfo(self): 57 | file = EpisodeMetadataParser(self.task_info) 58 | contents = file.get_nfo_contents() 59 | 60 | self.save_file(f"{self.task_info.file_name}.nfo", contents, "w") 61 | 62 | def check_file(self, file_path: str, file_name: str): 63 | return os.path.exists(os.path.join(file_path, file_name)) 64 | 65 | def get_bangumi_season_info(self): 66 | if not hasattr(self, "season_info"): 67 | self.season_info = BangumiParser.get_bangumi_season_info(self.task_info.season_id) 68 | 69 | self.task_info.poster_url = self.season_info.get("poster_url") 70 | self.task_info.description = self.season_info.get("description") 71 | self.task_info.actors = self.season_info.get("actors") 72 | self.task_info.bangumi_tags = self.season_info.get("tags") 73 | self.task_info.bangumi_pubdate = self.season_info.get("pubdate") 74 | self.task_info.seasons = self.season_info.get("seasons") 75 | self.task_info.rating = self.season_info.get("rating") 76 | self.task_info.rating_count = self.season_info.get("rating_count") 77 | self.task_info.areas = self.season_info.get("areas") 78 | 79 | def get_bangumi_poster(self, file_path: str, file_name: str): 80 | contents = Cover.download_cover(self.task_info.poster_url, CoverType.JPG) 81 | 82 | self.save_file_ex(file_path, file_name, contents, "wb") 83 | -------------------------------------------------------------------------------- /src/utils/module/ffmpeg/utils.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | from utils.common.enums import StreamType, StatusCode 4 | from utils.common.exception import GlobalException 5 | from utils.common.thread import Thread 6 | from utils.common.io.file import File 7 | 8 | from utils.common.model.task_info import DownloadTaskInfo 9 | from utils.common.model.data_type import Process, Command 10 | from utils.common.model.callback import Callback 11 | 12 | from utils.module.ffmpeg.command import FFCommand 13 | from utils.module.ffmpeg.prop import FFProp 14 | 15 | class FFUtils: 16 | @staticmethod 17 | def run(command: Command, callback: Callback, cwd: str = None, check: bool = True): 18 | command_line = command.format() 19 | 20 | process = Process() 21 | 22 | if command_line: 23 | p = subprocess.run(command.format(), shell = True, cwd = cwd, stdout = subprocess.PIPE, stderr = subprocess.STDOUT, stdin = subprocess.PIPE, text = True, encoding = "utf-8")\ 24 | 25 | process.return_code = p.returncode 26 | process.output = p.stdout 27 | else: 28 | process.return_code = 0 29 | process.output = "" 30 | 31 | if check: 32 | if not process.return_code: 33 | command.rename() 34 | command.remove() 35 | 36 | callback.onSuccess(process) 37 | else: 38 | raise GlobalException(code = StatusCode.CallError.value, stack_trace = f"{process.output}\n\nCommand:\n{command.format()}", callback = callback.onError, args = (process,)) 39 | else: 40 | callback.onSuccess(process) 41 | 42 | @classmethod 43 | def merge(cls, task_info: DownloadTaskInfo, callback: Callback): 44 | match StreamType(task_info.stream_type): 45 | case StreamType.Dash: 46 | command = FFCommand.get_merge_dash_command(task_info) 47 | 48 | case StreamType.Flv: 49 | command = FFCommand.get_merge_flv_command(task_info) 50 | 51 | case StreamType.Mp4: 52 | command = FFCommand.get_merge_mp4_command(task_info) 53 | 54 | cls.run(command, callback, cwd = task_info.download_path) 55 | 56 | @staticmethod 57 | def clear_temp_files(task_info: DownloadTaskInfo): 58 | temp_files = [] 59 | 60 | prop = FFProp(task_info) 61 | 62 | match StreamType(task_info.stream_type): 63 | case StreamType.Dash: 64 | if "video" in task_info.download_option: 65 | temp_files.append(prop.video_temp_file()) 66 | 67 | if "audio" in task_info.download_option: 68 | temp_files.append(prop.audio_temp_file()) 69 | 70 | temp_files.append(prop.output_temp_file()) 71 | 72 | case StreamType.Flv: 73 | if task_info.flv_video_count > 1: 74 | temp_files.append(prop.flv_list_file()) 75 | temp_files.extend([f"flv_{task_info.id}_part{i + 1}.flv" for i in range(task_info.flv_video_count)]) 76 | else: 77 | temp_files.append(prop.flv_temp_file()) 78 | 79 | temp_files.append(prop.output_temp_file()) 80 | 81 | case StreamType.Mp4: 82 | temp_files.append(prop.video_temp_file()) 83 | 84 | Thread(target = File.remove_files_ex, args = (temp_files, task_info.download_path)).start() -------------------------------------------------------------------------------- /src/gui/dialog/misc/processing.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import gettext 3 | 4 | from utils.common.enums import ProcessingType 5 | 6 | from gui.component.window.dialog import Dialog 7 | 8 | _ = gettext.gettext 9 | 10 | class ProcessingWindow(Dialog): 11 | def __init__(self, parent): 12 | Dialog.__init__(self, parent, _("解析中"), style = wx.DEFAULT_DIALOG_STYLE | wx.STAY_ON_TOP, name = "processing") 13 | 14 | self.EnableCloseButton(False) 15 | 16 | self.init_UI() 17 | 18 | self.CenterOnParent() 19 | 20 | def init_UI(self): 21 | self.processing_label = wx.StaticText(self, -1, _("正在解析中,请稍候")) 22 | self.name_lab = wx.StaticText(self, -1, "") 23 | self.node_title_label = wx.StaticText(self, -1, _("节点:--")) 24 | 25 | self.cancel_btn = wx.Button(self, -1, _("取消"), size = self.get_scaled_size((60, 24))) 26 | self.cancel_btn.Enable(False) 27 | 28 | btn_hbox = wx.BoxSizer(wx.HORIZONTAL) 29 | btn_hbox.AddStretchSpacer() 30 | btn_hbox.Add(self.cancel_btn, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 31 | btn_hbox.AddStretchSpacer() 32 | 33 | vbox = wx.BoxSizer(wx.VERTICAL) 34 | vbox.Add(self.processing_label, 0, wx.ALL, self.FromDIP(6)) 35 | vbox.Add(self.name_lab, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 36 | vbox.Add(self.node_title_label, 0, wx.ALL & (~wx.TOP), self.FromDIP(6)) 37 | vbox.Add(btn_hbox, 0, wx.EXPAND) 38 | 39 | self.SetSizer(vbox) 40 | 41 | def UpdateName(self, name: str): 42 | def worker(): 43 | self.name_lab.SetLabel(name) 44 | 45 | self.Layout() 46 | 47 | wx.CallAfter(worker) 48 | 49 | def UpdateTitle(self, title: str): 50 | def worker(): 51 | self.node_title_label.SetLabel(title) 52 | 53 | self.Layout() 54 | 55 | wx.CallAfter(worker) 56 | 57 | def ShowModal(self, type: ProcessingType): 58 | self.SetType(type) 59 | 60 | self.reset() 61 | 62 | return super().ShowModal() 63 | 64 | def SetType(self, type: ProcessingType): 65 | def worker(): 66 | self.SetTitle(title) 67 | self.processing_label.SetLabel(tip) 68 | 69 | self.name_lab.Show(title_show) 70 | self.node_title_label.Show(title_show) 71 | 72 | self.Layout() 73 | 74 | match type: 75 | case ProcessingType.Process: 76 | title = _("处理中") 77 | tip = _("正在处理中,请稍候") 78 | title_show = False 79 | 80 | case ProcessingType.Parse: 81 | title = _("解析中") 82 | tip = _("正在解析中,请稍候") 83 | title_show = False 84 | 85 | case ProcessingType.Interact: 86 | title = _("互动视频") 87 | tip = _("正在探查所有节点,请稍候") 88 | title_show = True 89 | 90 | case ProcessingType.Page: 91 | title = _("解析中") 92 | tip = _("正在获取所有分页数据,请稍候") 93 | title_show = True 94 | 95 | wx.CallAfter(worker) 96 | 97 | def Layout(self): 98 | super().Layout() 99 | 100 | self.Fit() 101 | 102 | self.CenterOnParent() 103 | 104 | def reset(self): 105 | self.name_lab.SetLabel("") 106 | self.node_title_label.SetLabel(_("节点:--")) -------------------------------------------------------------------------------- /src/utils/parse/episode/cheese.py: -------------------------------------------------------------------------------- 1 | from utils.common.enums import ParseType, TemplateType 2 | from utils.common.map import cheese_status_map 3 | 4 | from utils.parse.episode.episode_v2 import EpisodeInfo, Filter 5 | 6 | class Cheese: 7 | target_section_title: str = "" 8 | target_ep_id: int = 0 9 | 10 | @classmethod 11 | def parse_episodes(cls, info_json: dict, target_ep_id: int = None): 12 | cls.target_ep_id = target_ep_id 13 | EpisodeInfo.parser = cls 14 | 15 | EpisodeInfo.clear_episode_data() 16 | 17 | cls.sections_parser(info_json) 18 | 19 | Filter.episode_display_mode() 20 | 21 | @classmethod 22 | def sections_parser(cls, info_json: dict, parent_title: str = ""): 23 | episode_info_list = [] 24 | 25 | if not parent_title: 26 | cheese_pid = EpisodeInfo.add_item(EpisodeInfo.root_pid, EpisodeInfo.get_node_info(info_json.get("title"), label = "课程")) 27 | 28 | for section in info_json["sections"]: 29 | if section["episodes"]: 30 | section_title = section["title"] 31 | 32 | if not parent_title: 33 | section_pid = EpisodeInfo.add_item(cheese_pid, EpisodeInfo.get_node_info(section_title, label = "章节")) 34 | 35 | for episode in section["episodes"]: 36 | episode["section_title"] = section_title 37 | episode["season_id"] = info_json["season_id"] 38 | 39 | entry_info = cls.get_entry_info(episode.copy(), info_json, parent_title) 40 | 41 | if parent_title: 42 | episode_info_list.append(entry_info) 43 | else: 44 | EpisodeInfo.add_item(section_pid, entry_info) 45 | 46 | cls.update_target_section_title(episode, section_title) 47 | 48 | return episode_info_list 49 | 50 | @classmethod 51 | def get_entry_info(cls, episode: dict, info_json: dict, parent_title: str): 52 | episode["pubtime"] = episode.get("release_date") 53 | episode["ep_id"] = episode.get("id") 54 | episode["badge"] = cls.get_badge(episode) 55 | episode["cover_url"] = episode.get("cover") 56 | episode["link"] = f"https://www.bilibili.com/cheese/play/ep{episode.get('id')}" 57 | episode["type"] = ParseType.Cheese.value 58 | episode["series_title"] = info_json.get("title") 59 | episode["up_name"] = info_json.get("up_info", {"uname": ""}).get("uname", "") 60 | episode["up_mid"] = info_json.get("up_info", {"mid": 0}).get("mid", 0) 61 | episode["template_type"] = TemplateType.Cheese.value 62 | episode["parent_title"] = parent_title 63 | 64 | return EpisodeInfo.get_entry_info(episode) 65 | 66 | @classmethod 67 | def update_target_section_title(cls, episode: dict, section_title: str): 68 | if episode.get("id") == cls.target_ep_id: 69 | cls.target_section_title = section_title 70 | 71 | @classmethod 72 | def condition_single(cls, episode: dict): 73 | return episode.get("item_type") == "item" and episode.get("ep_id") == cls.target_ep_id 74 | 75 | @classmethod 76 | def condition_in_section(cls, episode: dict): 77 | return episode.get("item_type") == "node" and episode.get("title") == cls.target_section_title 78 | 79 | @classmethod 80 | def get_badge(cls, episode: dict): 81 | if "label" in episode: 82 | return episode["label"] 83 | else: 84 | if episode["status"] != 1: 85 | return cheese_status_map.get(episode.get("status")) 86 | else: 87 | return "" --------------------------------------------------------------------------------