├── 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 ""
--------------------------------------------------------------------------------