diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..6a9c06c
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,15 @@
+version: 2
+updates:
+ - package-ecosystem: "pip"
+ directory: "/"
+ schedule:
+ interval: "monthly"
+ open-pull-requests-limit: 5
+ versioning-strategy: "increase"
+ commit-message:
+ prefix: "⬆"
+ ignore:
+ - dependency-name: "streamget"
+ allow:
+ - dependency-type: "direct"
+ - dependency-type: "indirect"
\ No newline at end of file
diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml
new file mode 100644
index 0000000..ae3f48c
--- /dev/null
+++ b/.github/workflows/python-lint.yml
@@ -0,0 +1,40 @@
+name: Run Python Lint Checks
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ types:
+ - opened
+ - synchronize
+
+jobs:
+ lint-python:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Cache dependencies
+ uses: actions/cache@v3
+ with:
+ path: ~/.cache/pip
+ key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
+ restore-keys: |
+ ${{ runner.os }}-pip-
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+ pip install ruff
+
+ - name: Run ruff lint check
+ run: ruff check . --config .ruff.toml --exclude="*.md,*.txt"
+ working-directory: .
\ No newline at end of file
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..d280fab
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,23 @@
+name: Test
+
+on:
+ pull_request:
+ branches: [main]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up Python
+ uses: actions/setup-python@v2
+ with:
+ python-version: '3.10'
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+ - name: Run tests
+ run: |
+ python -m unittest discover
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 0a19790..b8cb361 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,7 +52,6 @@ coverage.xml
cover/
# Translations
-*.mo
*.pot
# Django stuff:
@@ -82,6 +81,19 @@ target/
profile_default/
ipython_config.py
+# client
+/config/accounts.json
+/config/user_settings.json
+/config/recordings.json
+/config/cookies.json
+.ruff_cache/
+logs/
+storage/
+ffmpeg/
+node/
+node-v*.zip
+ffmpeg*
+
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
@@ -94,28 +106,20 @@ ipython_config.py
# install all needed dependencies.
#Pipfile.lock
-# UV
-# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
-# This is especially recommended for binary packages to ensure reproducibility, and is more
-# commonly ignored for libraries.
-#uv.lock
-
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
-#poetry.lock
+poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
-# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
+# https://pdm.fming.dev/#use-with-ide
.pdm.toml
-.pdm-python
-.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
@@ -167,8 +171,4 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
-# Ruff stuff:
-.ruff_cache/
-
-# PyPI configuration file
-.pypirc
+backup_config/
diff --git a/.ruff.toml b/.ruff.toml
new file mode 100644
index 0000000..8534c95
--- /dev/null
+++ b/.ruff.toml
@@ -0,0 +1,78 @@
+line-length = 120
+
+[format]
+quote-style = "double"
+
+
+[lint]
+preview = false
+select = [
+ "B", # flake8-bugbear rules
+ "C4", # flake8-comprehensions
+ "E", # pycodestyle E rules
+ "F", # pyflakes rules
+ "FURB", # refurb rules
+ "I", # isort rules
+ "N", # pep8-naming
+ "PT", # flake8-pytest-style rules
+ "PLC0208", # iteration-over-set
+ "PLC0414", # useless-import-alias
+ "PLE0604", # invalid-all-object
+ "PLE0605", # invalid-all-format
+ "PLR0402", # manual-from-import
+ "PLR1711", # useless-return
+ "PLR1714", # repeated-equality-comparison
+ "RUF013", # implicit-optional
+ "RUF019", # unnecessary-key-check
+ "RUF100", # unused-noqa
+ "RUF101", # redirected-noqa
+ "RUF200", # invalid-pyproject-toml
+ "RUF022", # unsorted-dunder-all
+ "S506", # unsafe-yaml-load
+ "SIM", # flake8-simplify rules
+ "TRY400", # error-instead-of-exception
+ "TRY401", # verbose-log-message
+ "UP", # pyupgrade rules
+ "W191", # tab-indentation
+ "W605", # invalid-escape-sequence
+]
+
+ignore = [
+ "E402", # module-import-not-at-top-of-file
+ "E711", # none-comparison
+ "E712", # true-false-comparison
+ "E721", # type-comparison
+ "E722", # bare-except
+ "F821", # undefined-name
+ "F841", # unused-variable
+ "FURB113", # repeated-append
+ "FURB152", # math-constant
+ "UP007", # non-pep604-annotation
+ "UP032", # f-string
+ "UP045", # non-pep604-annotation-optional
+ "B005", # strip-with-multi-characters
+ "B006", # mutable-argument-default
+ "B007", # unused-loop-control-variable
+ "B026", # star-arg-unpacking-after-keyword-arg
+ "B903", # class-as-data-structure
+ "B904", # raise-without-from-inside-except
+ "B905", # zip-without-explicit-strict
+ "N806", # non-lowercase-variable-in-function
+ "N815", # mixed-case-variable-in-class-scope
+ "PT011", # pytest-raises-too-broad
+ "SIM102", # collapsible-if
+ "SIM103", # needless-bool
+ "SIM105", # suppressible-exception
+ "SIM107", # return-in-try-except-finally
+ "SIM108", # if-else-block-instead-of-if-exp
+ "SIM113", # enumerate-for-loop
+ "SIM117", # multiple-with-statements
+ "SIM210", # if-expr-with-true-false
+]
+
+
+[lint.per-file-ignores]
+"__init__.py" = [
+ "F401", # unused-import
+ "F811", # redefined-while-unused
+]
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a9ef195
--- /dev/null
+++ b/README.md
@@ -0,0 +1,239 @@
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+StreamCap 是一个多平台直播流录制客户端,覆盖 40+ 国内外主流直播平台,支持批量录制、循环监控、定时监控和自动转码等功能。
+
+## ✨功能特性
+
+- **循环监控**:实时监控直播间状态,开播即录。
+- **定时任务**:根据设定时间范围检查直播间状态。
+- **多种输出格式**:支持 ts、flv、mkv、mov、mp4、mp3、m4a 等格式。
+- **自动转码**:录制完成后自动转码为 mp4 格式。
+- **消息推送**:支持直播状态推送,及时获取开播通知。
+
+## 📸录制界面
+
+
+
+## 🛠️快速开始
+
+### 1.直接运行已构建程序
+
+1.**下载预构建的程序**:
+
+访问 [StreamCap Releases](https://github.com/ihmily/StreamCap/releases/latest) 页面,下载最新版本的 `StreamCap.zip` 压缩包。
+
+2.**解压程序**:
+
+将下载的压缩包解压到任意目录。
+
+3.**运行可执行文件**:
+
+打开解压后的文件夹,双击运行 `StreamCap.exe` 文件即可启动程序。
+
+### 2.从源代码运行
+
+确保已安装 **Python 3.10** 及以上版本。💥
+
+1.**克隆项目代码**:
+
+```bash
+git clone https://github.com/ihmily/StreamCap.git
+cd StreamCap
+```
+
+2.**安装依赖**:
+
+```bash
+pip install -r requirements.txt
+```
+
+3.**运行程序**:
+使用以下命令启动程序:
+
+```bash
+python main.py
+```
+
+如果程序提示缺少 FFmpeg,请访问 FFmpeg 官方下载页面[Download FFmpeg](https://ffmpeg.org/download.html),下载预编译的 FFmpeg 可执行文件。
+
+## 😺已支持平台
+
+示例地址:
+
+```
+抖音:
+https://live.douyin.com/745964462470
+https://v.douyin.com/iQFeBnt/
+https://live.douyin.com/yall1102 (链接+抖音号)
+https://v.douyin.com/CeiU5cbX (主播主页地址)
+
+TikTok:
+https://www.tiktok.com/@pearlgaga88/live
+
+快手:
+https://live.kuaishou.com/u/yall1102
+
+虎牙:
+https://www.huya.com/52333
+
+斗鱼:
+https://www.douyu.com/3637778?dyshid=
+https://www.douyu.com/topic/wzDBLS6?rid=4921614&dyshid=
+
+YY:
+https://www.yy.com/22490906/22490906
+
+B站:
+https://live.bilibili.com/320
+
+小红书(推荐使用主页地址):
+https://www.xiaohongshu.com/user/profile/6330049c000000002303c7ed?appuid=5f3f478a00000000010005b3
+http://xhslink.com/xpJpfM
+
+bigo直播:
+https://www.bigo.tv/cn/716418802
+
+buled直播:
+https://app.blued.cn/live?id=Mp6G2R
+
+SOOP:
+https://play.sooplive.co.kr/sw7love
+
+网易cc:
+https://cc.163.com/583946984
+
+千度热播:
+https://qiandurebo.com/web/video.php?roomnumber=33333
+
+PandaTV:
+https://www.pandalive.co.kr/live/play/bara0109
+
+猫耳FM:
+https://fm.missevan.com/live/868895007
+
+Look直播:
+https://look.163.com/live?id=65108820&position=3
+
+WinkTV:
+https://www.winktv.co.kr/live/play/anjer1004
+
+FlexTV:
+https://www.flextv.co.kr/channels/593127/live
+
+PopkonTV:
+https://www.popkontv.com/live/view?castId=wjfal007&partnerCode=P-00117
+https://www.popkontv.com/channel/notices?mcid=wjfal007&mcPartnerCode=P-00117
+
+TwitCasting:
+https://twitcasting.tv/c:uonq
+
+百度直播:
+https://live.baidu.com/m/media/pclive/pchome/live.html?room_id=9175031377&tab_category
+
+微博直播:
+https://weibo.com/l/wblive/p/show/1022:2321325026370190442592
+
+酷狗直播:
+https://fanxing2.kugou.com/50428671?refer=2177&sourceFrom=
+
+TwitchTV:
+https://www.twitch.tv/gamerbee
+
+LiveMe:
+https://www.liveme.com/zh/v/17141543493018047815/index.html
+
+花椒直播:
+https://www.huajiao.com/l/345096174
+
+流星直播:
+https://www.7u66.com/100960
+
+ShowRoom:
+https://www.showroom-live.com/room/profile?room_id=480206 (主播主页地址)
+
+Acfun:
+https://live.acfun.cn/live/179922
+
+映客直播:
+https://www.inke.cn/liveroom/index.html?uid=22954469&id=1720860391070904
+
+音播直播:
+https://live.ybw1666.com/800002949
+
+知乎直播:
+https://www.zhihu.com/people/ac3a467005c5d20381a82230101308e9 (主播主页地址)
+
+CHZZK:
+https://chzzk.naver.com/live/458f6ec20b034f49e0fc6d03921646d2
+
+嗨秀直播:
+https://www.haixiutv.com/6095106
+
+VV星球直播:
+https://h5webcdn-pro.vvxqiu.com//activity/videoShare/videoShare.html?h5Server=https://h5p.vvxqiu.com&roomId=LP115924473&platformId=vvstar
+
+17Live:
+https://17.live/en/live/6302408
+
+浪Live:
+https://www.lang.live/en-US/room/3349463
+
+畅聊直播:
+https://live.tlclw.com/106188
+
+飘飘直播:
+https://m.pp.weimipopo.com/live/preview.html?uid=91648673&anchorUid=91625862&app=plpl
+
+六间房直播:
+https://v.6.cn/634435
+
+乐嗨直播:
+https://www.lehaitv.com/8059096
+
+花猫直播:
+https://h.catshow168.com/live/preview.html?uid=19066357&anchorUid=18895331
+
+Shopee:
+https://sg.shp.ee/GmpXeuf?uid=1006401066&session=802458
+
+Youtube:
+https://www.youtube.com/watch?v=cS6zS5hi1w0
+
+淘宝(需cookie):
+https://m.tb.cn/h.TWp0HTd
+
+京东:
+https://3.cn/28MLBy-E
+
+Faceit:
+https://www.faceit.com/zh/players/Compl1/stream
+```
+
+## 📜许可证
+
+StreamCap在Apache License 2.0下发布。有关详情,请参阅[LICENSE](./LICENSE)文件。
+
+## 🙏特别感谢
+
+特别感谢以下开源项目和技术的支持:
+
+- [flet](https://github.com/flet-dev/flet)
+- [FFmpeg](https://ffmpeg.org)
+- [streamget](https://github.com/ihmily/streamget)
+
+如果您有任何问题或建议,请随时通过GitHub Issues与我们联系。
\ No newline at end of file
diff --git a/README_EN.md b/README_EN.md
new file mode 100644
index 0000000..50d8b3f
--- /dev/null
+++ b/README_EN.md
@@ -0,0 +1,236 @@
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+StreamCap is a multi-platform live stream recording client that covers over 40 mainstream live streaming platforms worldwide. It supports batch recording, loop monitoring, scheduled monitoring, and automatic transcoding.
+
+## ✨Features
+
+- **Loop Monitoring**: Real-time monitoring of live stream status. Recording starts immediately when a stream goes live.
+- **Scheduled Tasks**: Checks live stream status within a set time range.
+- **Multiple Output Formats**: Supports ts, flv, mkv, mov, mp4, mp3, m4a, and other formats.
+- **Automatic Transcoding**: Automatically transcodes recorded files to mp4 format after recording.
+- **Message Push**: Supports live stream status push notifications to keep you informed of live status.
+
+## 📸Recording Interface
+
+
+
+## 🛠️Quick Start
+
+### 1. Running the Pre-built Program
+
+1.**Download the Pre-built Program**:
+
+Visit the [StreamCap Releases](https://github.com/ihmily/StreamCap/releases/latest) page and download the latest version of the `StreamCap.zip` package.
+
+2.**Unzip the Program**:
+
+Extract the downloaded package to any directory.
+
+3.**Run the Executable File**:
+
+Open the extracted folder and double-click the `StreamCap.exe` file to launch the program.
+
+### 2. Running from Source Code
+
+Ensure you have **Python 3.10** or above installed.💥
+
+1.**Clone the Project Code**:
+
+```bash
+git clone https://github.com/ihmily/StreamCap.git
+cd StreamCap
+```
+
+2.**Install Dependencies**:
+
+```bash
+pip install -r requirements.txt
+```
+
+3.**Run the Program**:
+
+Use the following command to start the program:
+
+```bash
+python main.py
+```
+
+If the program prompts that FFmpeg is missing, please visit the FFmpeg official download page [Download FFmpeg](https://ffmpeg.org/download.html) to download the precompiled FFmpeg executable.
+
+## 😺 Supported Platforms
+
+Example input URLs:
+
+```
+Douyin:
+https://live.douyin.com/745964462470
+https://v.douyin.com/iQFeBnt/
+https://live.douyin.com/yall1102 (Link+unique ID)
+https://v.douyin.com/CeiU5cbX (Anchor homepage address)
+
+TikTok:
+https://www.tiktok.com/@pearlgaga88/live
+
+Kuaishou:
+https://live.kuaishou.com/u/yall1102
+
+Huya:
+https://www.huya.com/52333
+
+Douyu:
+https://www.douyu.com/3637778?dyshid=
+https://www.douyu.com/topic/wzDBLS6?rid=4921614&dyshid=
+
+YY:
+https://www.yy.com/22490906/22490906
+
+BiliBili:
+https://live.bilibili.com/320
+
+ReadNote:
+http://xhslink.com/xpJpfM
+
+Bigo:
+https://www.bigo.tv/cn/716418802
+
+Blued:
+https://app.blued.cn/live?id=Mp6G2R
+
+SOOP:
+https://play.sooplive.co.kr/sw7love
+
+Netease CC:
+https://cc.163.com/583946984
+
+Qiandurebo:
+https://qiandurebo.com/web/video.php?roomnumber=33333
+
+PandaTV:
+https://www.pandalive.co.kr/live/play/bara0109
+
+MaoerFM:
+https://fm.missevan.com/live/868895007
+
+Look Live:
+https://look.163.com/live?id=65108820&position=3
+
+WinkTV:
+https://www.winktv.co.kr/live/play/anjer1004
+
+FlexTV:
+https://www.flextv.co.kr/channels/593127/live
+
+PopkonTV:
+https://www.popkontv.com/live/view?castId=wjfal007&partnerCode=P-00117
+https://www.popkontv.com/channel/notices?mcid=wjfal007&mcPartnerCode=P-00117
+
+TwitCasting:
+https://twitcasting.tv/c:uonq
+
+Baidu Live:
+https://live.baidu.com/m/media/pclive/pchome/live.html?room_id=9175031377&tab_category
+
+Weibo Live:
+https://weibo.com/l/wblive/p/show/1022:2321325026370190442592
+
+Kugou Live:
+https://fanxing2.kugou.com/50428671?refer=2177&sourceFrom=
+
+TwitchTV:
+https://www.twitch.tv/gamerbee
+
+LiveMe:
+https://www.liveme.com/zh/v/17141543493018047815/index.html
+
+Huajiao Live:
+https://www.huajiao.com/l/345096174
+
+
+ShowRoom:
+https://www.showroom-live.com/room/profile?room_id=480206 (Anchor homepage address)
+
+Acfun:
+https://live.acfun.cn/live/179922
+
+Inke:
+https://www.inke.cn/liveroom/index.html?uid=22954469&id=1720860391070904
+
+Yinbo:
+https://live.ybw1666.com/800002949
+
+Zhihu:
+https://www.zhihu.com/people/ac3a467005c5d20381a82230101308e9 (Anchor homepage address)
+
+CHZZK:
+https://chzzk.naver.com/live/458f6ec20b034f49e0fc6d03921646d2
+
+Haixiu Live:
+https://www.haixiutv.com/6095106
+
+VVXqiu:
+https://h5webcdn-pro.vvxqiu.com//activity/videoShare/videoShare.html?h5Server=https://h5p.vvxqiu.com&roomId=LP115924473&platformId=vvstar
+
+17Live:
+https://17.live/en/live/6302408
+
+Lang Live:
+https://www.lang.live/en-US/room/3349463
+
+PiaoPiao Live:
+https://m.pp.weimipopo.com/live/preview.html?uid=91648673&anchorUid=91625862&app=plpl
+
+Six Room Live:
+https://v.6.cn/634435
+
+Lehai Live:
+https://www.lehaitv.com/8059096
+
+Catshow Live:
+https://h.catshow168.com/live/preview.html?uid=19066357&anchorUid=18895331
+
+Shopee:
+https://sg.shp.ee/GmpXeuf?uid=1006401066&session=802458
+
+Youtube:
+https://www.youtube.com/watch?v=cS6zS5hi1w0
+
+Taobao(Need cookie):
+https://m.tb.cn/h.TWp0HTd
+
+JD:
+https://3.cn/28MLBy-E
+
+Faceit:
+https://www.faceit.com/zh/players/Compl1/stream
+```
+
+## 📜License
+
+StreamCap is released under the Apache License 2.0. For more details, see the [LICENSE](./LICENSE) file.
+
+## 🙏Special Thanks
+
+Special thanks to the following open-source projects and technologies for their support:
+
+- [flet](https://github.com/flet-dev/flet)
+- [FFmpeg](https://ffmpeg.org)
+- [streamget](https://github.com/ihmily/streamget)
+
+If you have any questions or suggestions, please feel free to contact us via GitHub Issues.
+
+---
\ No newline at end of file
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..739e48a
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,8 @@
+import os
+import sys
+
+from .installation_manager import InstallationManager
+
+execute_dir = os.path.split(os.path.realpath(sys.argv[0]))[0]
+
+__all__ = ["InstallationManager", "execute_dir"]
diff --git a/app/app_manager.py b/app/app_manager.py
new file mode 100644
index 0000000..5a17a65
--- /dev/null
+++ b/app/app_manager.py
@@ -0,0 +1,89 @@
+import os
+
+import flet as ft
+
+from . import InstallationManager, execute_dir
+from .core.config_manager import ConfigManager
+from .core.language_manager import LanguageManager
+from .core.record_manager import RecordingManager
+from .ui.components.recording_card import RecordingCardManager
+from .ui.components.show_snackbar import ShowSnackBar
+from .ui.navigation.sidebar import LeftNavigationMenu, NavigationSidebar
+from .ui.views.about_view import AboutPage
+from .ui.views.home_view import HomePage
+from .ui.views.settings_view import SettingsPage
+from .utils import utils
+
+
+class App:
+ def __init__(self, page: ft.Page):
+ self.install_progress = None
+ self.page = page
+ self.run_path = execute_dir
+ self.assets_dir = os.path.join(execute_dir, "assets")
+ self.config_manager = ConfigManager(self.run_path)
+ self.content_area = ft.Column(
+ controls=[],
+ expand=True,
+ alignment=ft.MainAxisAlignment.START,
+ horizontal_alignment=ft.CrossAxisAlignment.START,
+ )
+
+ self.settings = SettingsPage(self)
+ self.language_manager = LanguageManager(self)
+ self.about = AboutPage(self)
+ self.home = HomePage(self)
+ self.pages = self.initialize_pages()
+ self.language_code = self.settings.language_code
+ self.sidebar = NavigationSidebar(self)
+ self.left_navigation_menu = LeftNavigationMenu(self)
+
+ self.page.snack_bar_area = ft.Container()
+ self.dialog_area = ft.Container()
+ self.complete_page = ft.Row(
+ expand=True,
+ controls=[
+ self.left_navigation_menu,
+ ft.VerticalDivider(width=1),
+ self.content_area,
+ self.dialog_area,
+ self.page.snack_bar_area,
+ ]
+ )
+ self.page.add(self.complete_page)
+ self.snack_bar = ShowSnackBar(self.page)
+ self.subprocess_start_up_info = utils.get_startup_info()
+ self.record_card_manager = RecordingCardManager(self)
+ self.record_manager = RecordingManager(self)
+ self.current_page = None
+ self._loading_page = False
+ self.recording_enabled = True
+ self.install_manager = InstallationManager(self)
+ self.page.run_task(self.install_manager.check_env)
+ self.page.run_task(self.record_manager.check_free_space)
+
+ def initialize_pages(self):
+ return {
+ "settings": self.settings,
+ "home": HomePage(self),
+ "about": AboutPage(self),
+ }
+
+ async def switch_page(self, page_name):
+ if self._loading_page:
+ return
+
+ self._loading_page = True
+
+ try:
+ await self.clear_content_area()
+ if page := self.pages.get(page_name):
+ await self.settings.is_changed()
+ self.current_page = page
+ await page.load()
+ finally:
+ self._loading_page = False
+
+ async def clear_content_area(self):
+ self.content_area.clean()
+ self.content_area.update()
diff --git a/app/core/__init__.py b/app/core/__init__.py
new file mode 100644
index 0000000..8ad561d
--- /dev/null
+++ b/app/core/__init__.py
@@ -0,0 +1,3 @@
+from .platform_handlers import get_platform_handler
+
+__all__ = ["get_platform_handler"]
diff --git a/app/core/config_manager.py b/app/core/config_manager.py
new file mode 100644
index 0000000..2916a62
--- /dev/null
+++ b/app/core/config_manager.py
@@ -0,0 +1,152 @@
+import json
+import os
+import shutil
+from typing import Any
+
+import aiofiles
+
+from ..utils.logger import logger
+
+
+class ConfigManager:
+ def __init__(self, run_path):
+ self.config_path = os.path.join(run_path, "config")
+ self.language_config_path = os.path.join(self.config_path, "language.json")
+ self.default_config_path = os.path.join(self.config_path, "default_settings.json")
+ self.user_config_path = os.path.join(self.config_path, "user_settings.json")
+ self.cookies_config_path = os.path.join(self.config_path, "cookies.json")
+ self.about_config_path = os.path.join(self.config_path, "version.json")
+ self.recordings_config_path = os.path.join(self.config_path, "recordings.json")
+ self.accounts_config_path = os.path.join(self.config_path, "accounts.json")
+
+ os.makedirs(os.path.dirname(self.default_config_path), exist_ok=True)
+ self.init()
+
+ def init(self):
+ self.init_default_config()
+ self.init_user_config()
+ self.init_cookies_config()
+ self.init_accounts_config()
+ self.init_recordings_config()
+
+ @staticmethod
+ def _init_config(config_path, default_config=None):
+ """Initialize a configuration file with default values if it does not exist."""
+ if not os.path.exists(config_path):
+ if default_config is None:
+ default_config = {}
+ try:
+ with open(config_path, "w", encoding="utf-8") as file:
+ json.dump(default_config, file, ensure_ascii=False, indent=4)
+ logger.info(f"Initialized configuration file: {config_path}")
+ except Exception as e:
+ logger.error(f"Failed to initialize configuration file {config_path}: {e}")
+
+ def init_default_config(self):
+ default_config = {}
+ self._init_config(self.default_config_path, default_config)
+
+ def init_user_config(self):
+ if os.path.exists(self.user_config_path) and self.load_user_config():
+ return
+ shutil.copy(self.default_config_path, self.user_config_path)
+
+ def init_cookies_config(self):
+ cookies_config = {}
+ self._init_config(self.cookies_config_path, cookies_config)
+
+ def init_accounts_config(self):
+ cookies_config = {}
+ self._init_config(self.accounts_config_path, cookies_config)
+
+ def init_recordings_config(self):
+ cookies_config = {}
+ self._init_config(self.recordings_config_path, cookies_config)
+
+ @staticmethod
+ def _load_config(config_path, error_message):
+ """Load configuration from a JSON file."""
+ try:
+ with open(config_path, encoding="utf-8") as file:
+ return json.load(file)
+ except json.JSONDecodeError:
+ logger.error(f"Invalid JSON format in file: {config_path}")
+ return {}
+ except FileNotFoundError:
+ logger.error(f"Configuration file not found: {config_path}")
+ return {}
+ except Exception as e:
+ logger.error(f"{error_message}: {e}")
+ return {}
+
+ def load_default_config(self):
+ return self._load_config(self.default_config_path, "An error occurred while loading default config")
+
+ def load_user_config(self):
+ return self._load_config(self.user_config_path, "An error occurred while loading user config")
+
+ def load_recordings_config(self):
+ return self._load_config(self.recordings_config_path, "An error occurred while loading recordings config")
+
+ def load_accounts_config(self):
+ return self._load_config(self.accounts_config_path, "An error occurred while loading accounts config")
+
+ def load_cookies_config(self):
+ return self._load_config(self.cookies_config_path, "An error occurred while loading cookies config")
+
+ def load_about_config(self):
+ return self._load_config(self.about_config_path, "An error occurred while loading about config")
+
+ def load_language_config(self):
+ return self._load_config(self.language_config_path, "An error occurred while loading language config")
+
+ def load_i18n_config(self, path):
+ """Load i18n configuration from a JSON file."""
+ return self._load_config(path, "An error occurred while loading i18n config")
+
+ @staticmethod
+ async def _save_config(config_path, config, success_message, error_message):
+ """Save configuration to a JSON file."""
+ try:
+ async with aiofiles.open(config_path, "w", encoding="utf-8") as file:
+ await file.write(json.dumps(config, ensure_ascii=False, indent=4))
+ logger.info(success_message)
+ except Exception as e:
+ logger.error(f"{error_message}: {e}")
+
+ async def save_recordings_config(self, config):
+ await self._save_config(
+ self.recordings_config_path,
+ config,
+ success_message="Recordings configuration saved.",
+ error_message="An error occurred while saving recordings config",
+ )
+
+ async def save_accounts_config(self, config):
+ await self._save_config(
+ self.accounts_config_path,
+ config,
+ success_message="Accounts configuration saved.",
+ error_message="An error occurred while saving accounts config",
+ )
+
+ async def save_user_config(self, config):
+ await self._save_config(
+ self.user_config_path,
+ config,
+ success_message="User configuration saved.",
+ error_message="An error occurred while saving user config",
+ )
+
+ async def save_cookies_config(self, config):
+ await self._save_config(
+ self.cookies_config_path,
+ config,
+ success_message="Cookies configuration saved.",
+ error_message="An error occurred while saving cookies config",
+ )
+
+ def get_config_value(self, key: str, default: Any = None):
+ user_config = self.load_user_config()
+ default_config = self.load_default_config()
+ return user_config.get(key, default_config.get(key, default))
diff --git a/app/core/language_manager.py b/app/core/language_manager.py
new file mode 100644
index 0000000..c9a636b
--- /dev/null
+++ b/app/core/language_manager.py
@@ -0,0 +1,45 @@
+import os
+
+from ..utils.logger import logger
+from .config_manager import ConfigManager
+
+
+class LanguageManager:
+ """
+ Manages language settings and loads internationalization (i18n) configurations.
+ """
+
+ def __init__(self, app):
+ self._observers = []
+ self.language = {}
+ self.app = app
+ self.load()
+
+ def load(self):
+ """
+ Initialize the LanguageManager with settings and load the language configuration.
+ """
+ config_manager = ConfigManager(self.app.run_path)
+ logger.info(f"Language Code: {self.app.settings.language_code}")
+ i18n_filename = f"{self.app.settings.language_code}.json"
+ i18n_file_path = os.path.join(self.app.run_path, "locales", i18n_filename)
+ self.language = config_manager.load_i18n_config(i18n_file_path)
+ return self.language
+
+ def add_observer(self, observer):
+ """Add an observer that will be notified when the language changes."""
+ if observer not in self._observers:
+ self._observers.append(observer)
+
+ def remove_observer(self, observer):
+ """Remove an observer."""
+ if observer in self._observers:
+ self._observers.remove(observer)
+
+ def notify_observers(self):
+ """Notify all observers that the language has changed."""
+ for observer in self._observers:
+ if hasattr(observer, "page_name"):
+ observer.load_language()
+ else:
+ observer.load()
diff --git a/app/core/platform_handlers/__init__.py b/app/core/platform_handlers/__init__.py
new file mode 100644
index 0000000..b67a58c
--- /dev/null
+++ b/app/core/platform_handlers/__init__.py
@@ -0,0 +1,183 @@
+from ...utils.logger import logger
+from .base import PlatformHandler, StreamData
+from .handlers import (
+ AcfunHandler,
+ BaiduHandler,
+ BigoHandler,
+ BilibiliHandler,
+ BluedHandler,
+ ChzzkHandler,
+ DouyinHandler,
+ DouyuHandler,
+ FaceitHandler,
+ FlexTVHandler,
+ HaixiuHandler,
+ HuajiaoHandler,
+ HuamaoHandler,
+ HuyaHandler,
+ InkeHandler,
+ JDHandler,
+ KuaishouHandler,
+ KugouHandler,
+ LangLiveHandler,
+ LehaiHandler,
+ LivemeHandler,
+ LookHandler,
+ MaoerFMHandler,
+ NeteaseHandler,
+ PamdaTVHandler,
+ PiaopiaoHandler,
+ PopkonTVHandler,
+ QiandureboHandler,
+ RedNoteHandler,
+ ShopeeHandler,
+ ShowRoomHandlerHandler,
+ SixRoomHandler,
+ SoopHandler,
+ TaobaoHandler,
+ TikTokHandler,
+ TwitcastingHandler,
+ TwitchHandler,
+ VVXQHandler,
+ WeiboHandler,
+ WinkTVHandler,
+ YinboHandler,
+ YiqiLiveHandler,
+ YoutubeHandler,
+ YYHandler,
+ ZhihuHandler,
+)
+
+
+def get_platform_handler(
+ live_url: str,
+ proxy: str | None = None,
+ cookies: dict | str | None = None,
+ record_quality: str = "default",
+ platform: str | None = None,
+ username: str | None = None,
+ password: str | None = None,
+ account_type: str | None = None,
+) -> PlatformHandler | None:
+ handler_instance = PlatformHandler.get_handler_instance(
+ live_url, proxy, cookies, record_quality, platform, username, password, account_type
+ )
+ if handler_instance:
+ return handler_instance
+ logger.warning(f"Unknown live platform:{live_url}")
+ return None
+
+
+def get_platform_info(record_url: str) -> tuple:
+ platform_map = {
+ "douyin.com/": ("抖音直播", "douyin"),
+ "https://www.tiktok.com/": ("TikTok直播", "tiktok"),
+ "https://live.kuaishou.com/": ("快手直播", "kuaishou"),
+ "https://www.huya.com/": ("虎牙直播", "huya"),
+ "https://www.douyu.com/": ("斗鱼直播", "douyu"),
+ "https://www.yy.com/": ("YY直播", "yy"),
+ "https://live.bilibili.com/": ("B站直播", "bilibili"),
+ "https://www.xiaohongshu.com/": ("小红书直播", "xiaohongshu"),
+ "xhslink.com/": ("小红书直播", "xhs"),
+ "https://www.bigo.tv/": ("Bigo直播", "bigo"),
+ "https://app.blued.cn/": ("Blued直播", "blued"),
+ "sooplive.co.kr/": ("SOOP", "soop"),
+ "cc.163.com/": ("网易CC直播", "netease"),
+ "qiandurebo.com/": ("千度热播", "qiandurebo"),
+ "pandalive.co.kr/": ("PandaTV", "pandalive"),
+ "fm.missevan.com/": ("猫耳FM直播", "maoerfm"),
+ "winktv.co.kr/": ("WinkTV", "winktv"),
+ "flextv.co.kr/": ("FlexTV", "flextv"),
+ "look.163.com/": ("Look直播", "look"),
+ "popkontv.com/": ("PopkonTV", "popkontv"),
+ "twitcasting.tv/": ("TwitCasting", "twitcasting"),
+ "live.baidu.com/": ("百度直播", "baidu"),
+ "weibo.com/": ("微博直播", "weibo"),
+ "kugou.com/": ("酷狗直播", "kugou"),
+ "twitch.tv/": ("TwitchTV", "twitch"),
+ "liveme.com/": ("LiveMe", "liveme"),
+ "huajiao.com/": ("花椒直播", "huajiao"),
+ "7u66.com/": ("流星直播", "liuxing"),
+ "showroom-live.com/": ("ShowRoom", "showroom"),
+ "live.acfun.cn/": ("Acfun", "acfun"),
+ "tlclw.com/": ("畅聊直播", "changliao"),
+ "ybw1666.com/": ("音播直播", "yingbo"),
+ "inke.cn/": ("映客直播", "inke"),
+ "zhihu.com/": ("知乎直播", "zhihu"),
+ "chzzk.naver.com/": ("CHZZK", "chzzk"),
+ "haixiutv.com/": ("嗨秀直播", "haixiu"),
+ "vvxqiu.com/": ("VV星球", "vvxq"),
+ "17.live/": ("17Live", "17live"),
+ "lang.live/": ("浪Live", "lang"),
+ "m.pp.weimipopo.com/": ("漂漂直播", "piaopiao"),
+ ".6.cn/": ("六间房直播", "6room"),
+ "lehaitv.com/": ("乐嗨直播", "lehai"),
+ "h.catshow168.com/": ("花猫直播", "catshow"),
+ "live.shopee": ("shopee", "shopee"),
+ ".shp.": ("shopee", "shopee"),
+ "youtube.com/": ("Youtube", "youtube"),
+ "tb.cn": ("淘宝直播", "taobao"),
+ "3.cn": ("京东直播", "jd"),
+ "faceit.com": ("faceit", "faceit"),
+ ".m3u8": ("自定义录制直播", "custom"),
+ ".flv": ("自定义录制直播", "custom"),
+ }
+
+ for key, value in platform_map.items():
+ if key in record_url:
+ return value[0], value[1]
+
+ return None, None
+
+
+__all__ = [
+ "AcfunHandler",
+ "BaiduHandler",
+ "BigoHandler",
+ "BilibiliHandler",
+ "BluedHandler",
+ "ChzzkHandler",
+ "DouyinHandler",
+ "DouyuHandler",
+ "FaceitHandler",
+ "FlexTVHandler",
+ "HaixiuHandler",
+ "HuajiaoHandler",
+ "HuamaoHandler",
+ "HuyaHandler",
+ "InkeHandler",
+ "JDHandler",
+ "KuaishouHandler",
+ "KugouHandler",
+ "LangLiveHandler",
+ "LehaiHandler",
+ "LivemeHandler",
+ "LookHandler",
+ "MaoerFMHandler",
+ "NeteaseHandler",
+ "PamdaTVHandler",
+ "PiaopiaoHandler",
+ "PlatformHandler",
+ "PopkonTVHandler",
+ "QiandureboHandler",
+ "RedNoteHandler",
+ "ShopeeHandler",
+ "ShowRoomHandlerHandler",
+ "SixRoomHandler",
+ "SoopHandler",
+ "StreamData",
+ "TaobaoHandler",
+ "TikTokHandler",
+ "TwitcastingHandler",
+ "TwitchHandler",
+ "VVXQHandler",
+ "WeiboHandler",
+ "WinkTVHandler",
+ "YYHandler",
+ "YinboHandler",
+ "YiqiLiveHandler",
+ "YoutubeHandler",
+ "ZhihuHandler",
+ "get_platform_handler",
+ "get_platform_info",
+]
diff --git a/app/core/platform_handlers/base.py b/app/core/platform_handlers/base.py
new file mode 100644
index 0000000..8c7e3e6
--- /dev/null
+++ b/app/core/platform_handlers/base.py
@@ -0,0 +1,117 @@
+import abc
+import inspect
+import re
+import threading
+from typing import Any, Optional, TypeVar
+
+from streamget import StreamData
+
+T = TypeVar("T", bound="PlatformHandler")
+InstanceKey = tuple[str | None, tuple[tuple[str, str], ...] | None, str, str | None]
+
+
+class PlatformHandler(abc.ABC):
+ _registry: dict[str, type["PlatformHandler"]] = {}
+ _instances: dict[InstanceKey, "PlatformHandler"] = {}
+ _lock: threading.Lock = threading.Lock()
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ username: str | None = None,
+ password: str | None = None,
+ account_type: str | None = None,
+ ) -> None:
+ self.proxy = proxy
+ self.cookies = cookies
+ self.record_quality = record_quality
+ self.platform = platform
+ self.username = username
+ self.password = password
+ self.account_type = account_type
+
+ @abc.abstractmethod
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ """
+ Abstract method to get stream information based on the live URL.
+ """
+ pass
+
+ @classmethod
+ def register(cls: type[T], *patterns: str) -> type[T]:
+ """
+ Register a platform handler class with one or more URL patterns.
+ """
+ with cls._lock:
+ for pattern in patterns:
+ cls._registry[pattern] = cls
+ return cls
+
+ @classmethod
+ def get_registered_patterns(cls) -> dict[str, type["PlatformHandler"]]:
+ """
+ Return a copy of the registered URL patterns and their corresponding handler classes.
+ """
+ with cls._lock:
+ return cls._registry.copy()
+
+ @classmethod
+ def _get_instance_key(
+ cls, proxy: str | None, cookies: str | None, record_quality: str, platform: str | None
+ ) -> InstanceKey:
+ """
+ Generate a unique key for each instance based on the provided parameters.
+ """
+ return proxy, cookies, record_quality, platform
+
+ @classmethod
+ def _get_handler_class(cls, live_url: str) -> type["PlatformHandler"] | None:
+ """
+ Find the appropriate handler class based on the live URL.
+ """
+ registered_patterns = cls.get_registered_patterns()
+ for pattern, handler_class in registered_patterns.items():
+ if re.search(pattern, live_url):
+ return handler_class
+ return None
+
+ @classmethod
+ def get_handler_instance(
+ cls,
+ live_url: str,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ username: str | None = None,
+ password: str | None = None,
+ account_type: str | None = None,
+ ) -> Optional["PlatformHandler"]:
+ """
+ Get or create an instance of a platform handler based on the live URL and other parameters.
+ """
+ handler_class = cls._get_handler_class(live_url)
+ if not handler_class:
+ return None
+
+ instance_key = cls._get_instance_key(proxy, cookies, record_quality, platform)
+ if instance_key not in cls._instances:
+ init_signature = inspect.signature(handler_class.__init__)
+ handler_kwargs: dict[str, Any] = {
+ "proxy": proxy,
+ "cookies": cookies,
+ "record_quality": record_quality,
+ "platform": platform,
+ "username": username,
+ "password": password,
+ "account_type": account_type,
+ }
+ filtered_kwargs = {k: v for k, v in handler_kwargs.items() if k in init_signature.parameters}
+ with cls._lock:
+ if instance_key not in cls._instances:
+ cls._instances[instance_key] = handler_class(**filtered_kwargs)
+
+ return cls._instances[instance_key]
diff --git a/app/core/platform_handlers/handlers.py b/app/core/platform_handlers/handlers.py
new file mode 100644
index 0000000..200c5db
--- /dev/null
+++ b/app/core/platform_handlers/handlers.py
@@ -0,0 +1,1019 @@
+import streamget
+
+from ...utils.utils import trace_error_decorator
+from .base import PlatformHandler, StreamData
+
+
+class DouyinHandler(PlatformHandler):
+ platform = "douyin"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.DouyinLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ """
+ Fetch stream information for a Douyin live URL.
+ """
+ if not self.live_stream:
+ self.live_stream = streamget.DouyinLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+
+ if "v.douyin.com" in live_url:
+ json_data = await self.live_stream.fetch_app_stream_data(url=live_url)
+ else:
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class TikTokHandler(PlatformHandler):
+ platform = "tiktok"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.TikTokLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.TikTokLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class KuaishouHandler(PlatformHandler):
+ platform = "kuaishou"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.KwaiLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.KwaiLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class HuyaHandler(PlatformHandler):
+ platform = "huya"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.HuyaLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.HuyaLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_app_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class DouyuHandler(PlatformHandler):
+ platform = "douyu"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.DouyuLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.DouyuLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class YYHandler(PlatformHandler):
+ platform = "YY"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.YYLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.YYLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class BilibiliHandler(PlatformHandler):
+ platform = "bilibili"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.BilibiliLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.BilibiliLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class RedNoteHandler(PlatformHandler):
+ platform = "rednote"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.RedNoteLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.RedNoteLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_app_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class BigoHandler(PlatformHandler):
+ platform = "bigo"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.BigoLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.BigoLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class BluedHandler(PlatformHandler):
+ platform = "blued"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.BluedLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.BluedLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class SoopHandler(PlatformHandler):
+ platform = "soop"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ username: str | None = None,
+ password: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform, username, password)
+ self.live_stream: streamget.SoopLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.SoopLiveStream(
+ proxy_addr=self.proxy, cookies=self.cookies, username=self.username, password=self.password
+ )
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class NeteaseHandler(PlatformHandler):
+ platform = "netease"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.NeteaseLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.NeteaseLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class QiandureboHandler(PlatformHandler):
+ platform = "qiandurebo"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.QiandureboLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.QiandureboLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class PamdaTVHandler(PlatformHandler):
+ platform = "pandatv"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.PandaLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.PandaLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class MaoerFMHandler(PlatformHandler):
+ platform = "maoerfm"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.MaoerLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.MaoerLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class LookHandler(PlatformHandler):
+ platform = "look"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.LookLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.LookLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class WinkTVHandler(PlatformHandler):
+ platform = "winktv"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.WinkTVLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.WinkTVLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class FlexTVHandler(PlatformHandler):
+ platform = "flextv"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ username: str | None = None,
+ password: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform, username, password)
+ self.live_stream: streamget.FlexTVLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.FlexTVLiveStream(
+ proxy_addr=self.proxy, cookies=self.cookies, username=self.username, password=self.password
+ )
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class PopkonTVHandler(PlatformHandler):
+ platform = "popkontv"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ username: str | None = None,
+ password: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform, username, password)
+ self.live_stream: streamget.PopkonTVLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.PopkonTVLiveStream(
+ proxy_addr=self.proxy, cookies=self.cookies, username=self.username, password=self.password
+ )
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class TwitcastingHandler(PlatformHandler):
+ platform = "twitcasting"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ username: str | None = None,
+ password: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform, username, password)
+ self.live_stream: streamget.TwitCastingLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.TwitCastingLiveStream(
+ proxy_addr=self.proxy, cookies=self.cookies, username=self.username, password=self.password
+ )
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class BaiduHandler(PlatformHandler):
+ platform = "baidu"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.BaiduLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.BaiduLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class WeiboHandler(PlatformHandler):
+ platform = "weibo"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.WeiboLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.WeiboLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class KugouHandler(PlatformHandler):
+ platform = "kugou"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.KugouLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.KugouLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class TwitchHandler(PlatformHandler):
+ platform = "twitch"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.TwitchLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.TwitchLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class LivemeHandler(PlatformHandler):
+ platform = "liveme"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.LiveMeLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.LiveMeLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class HuajiaoHandler(PlatformHandler):
+ platform = "huajiao"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.HuajiaoLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.HuajiaoLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class ShowRoomHandlerHandler(PlatformHandler):
+ platform = "showroom"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.ShowRoomLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.ShowRoomLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class AcfunHandler(PlatformHandler):
+ platform = "acfun"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.AcfunLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.AcfunLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class InkeHandler(PlatformHandler):
+ platform = "inke"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.InkeLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.InkeLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class YinboHandler(PlatformHandler):
+ platform = "yinbo"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.YinboLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.YinboLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class ZhihuHandler(PlatformHandler):
+ platform = "zhihu"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.ZhihuLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.ZhihuLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class ChzzkHandler(PlatformHandler):
+ platform = "chzzk"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.ChzzkLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.ChzzkLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class HaixiuHandler(PlatformHandler):
+ platform = "haixiu"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.HaixiuLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.HaixiuLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class VVXQHandler(PlatformHandler):
+ platform = "vvxqiu"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.VVXQLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.VVXQLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class YiqiLiveHandler(PlatformHandler):
+ platform = "17live"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.YiqiLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.YiqiLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class LangLiveHandler(PlatformHandler):
+ platform = "langlive"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.LangLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.LangLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class PiaopiaoHandler(PlatformHandler):
+ platform = "piaopiao"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.PiaopaioLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.PiaopaioLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class SixRoomHandler(PlatformHandler):
+ platform = "sixroom"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.SixRoomLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.SixRoomLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class LehaiHandler(PlatformHandler):
+ platform = "lehai"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.LehaiLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.LehaiLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class HuamaoHandler(PlatformHandler):
+ platform = "huamao"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.HuamaoLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.HuamaoLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class ShopeeHandler(PlatformHandler):
+ platform = "shopee"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.ShopeeLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.ShopeeLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class YoutubeHandler(PlatformHandler):
+ platform = "youtube"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.YoutubeLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.YoutubeLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class TaobaoHandler(PlatformHandler):
+ platform = "taobao"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.TaobaoLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.TaobaoLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class JDHandler(PlatformHandler):
+ platform = "jd"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.JDLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.JDLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+class FaceitHandler(PlatformHandler):
+ platform = "faceit"
+
+ def __init__(
+ self,
+ proxy: str | None = None,
+ cookies: str | None = None,
+ record_quality: str | None = None,
+ platform: str | None = None,
+ ) -> None:
+ super().__init__(proxy, cookies, record_quality, platform)
+ self.live_stream: streamget.FaceitLiveStream | None = None
+
+ @trace_error_decorator
+ async def get_stream_info(self, live_url: str) -> StreamData:
+ if not self.live_stream:
+ self.live_stream = streamget.FaceitLiveStream(proxy_addr=self.proxy, cookies=self.cookies)
+ json_data = await self.live_stream.fetch_web_stream_data(url=live_url)
+ return await self.live_stream.fetch_stream_url(json_data, self.record_quality)
+
+
+DouyinHandler.register(r"https://.*\.douyin\.com/")
+TikTokHandler.register(r"https://.*\.tiktok\.com/")
+KuaishouHandler.register(r"https://live\.kuaishou\.com/")
+HuyaHandler.register(r"https://.*\.huya\.com/")
+DouyuHandler.register(r"https://.*\.douyu\.com/")
+YYHandler.register(r"https://.*\.yy\.com/")
+BilibiliHandler.register(r"https://live\.bilibili\.com/")
+RedNoteHandler.register(r"www\.xiaohongshu\.com/", r"xhslink\.com/")
+BigoHandler.register(r"https://www\.bigo\.tv/", r"https://slink\.bigovideo\.tv/")
+BluedHandler.register(r"https://app\.blued\.cn/")
+SoopHandler.register(r"sooplive\.co\.kr/")
+NeteaseHandler.register(r"cc\.163\.com/")
+QiandureboHandler.register(r"qiandurebo.com/")
+PamdaTVHandler.register(r".*\.pandalive.co.kr/")
+MaoerFMHandler.register(r"fm.missevan.com/")
+LookHandler.register(r"look.163.com/")
+WinkTVHandler.register(r"www.winktv.co.kr/")
+FlexTVHandler.register(r"www\.flextv\.co\.kr/")
+PopkonTVHandler.register(r"www\.popkontv\.com/")
+TwitcastingHandler.register(r"twitcasting\.tv")
+BaiduHandler.register(r".*\.baidu\.com")
+WeiboHandler.register(r"weibo\.com/")
+KugouHandler.register(r".*\.kugou\.com")
+TwitchHandler.register(r"https://.*\.twitch\.tv/")
+LivemeHandler.register(r"https://.*\.liveme\.com/")
+HuajiaoHandler.register(r"https://.*\.huajiao\.com/")
+ShowRoomHandlerHandler.register(r".*\.showroom-live\.com")
+AcfunHandler.register(r"live.acfun.cn/")
+InkeHandler.register(r"https://.*\.inke\.cn/")
+YinboHandler.register(r"live.ybw1666.com")
+ZhihuHandler.register(r"https://.*\.zhihu\.com/")
+ChzzkHandler.register(r"chzzk\.naver\.com/")
+HaixiuHandler.register(r"https://.*\.haixiutv\.com/")
+VVXQHandler.register(r".*\.vvxqiu\.com")
+YiqiLiveHandler.register(r"17\.live")
+LangLiveHandler.register(r"https://.*\.lang\.live/")
+PiaopiaoHandler.register(r".*\.weimipopo.com/")
+SixRoomHandler.register(r"v.6.cn/")
+LehaiHandler.register(r"https://.*\.lehaitv\.com/")
+HuamaoHandler.register(r"h.catshow168.com")
+ShopeeHandler.register(r".*.shp.ee/")
+YoutubeHandler.register(r".*\.youtube\.com/")
+TaobaoHandler.register(r".*\.tb\.cn/")
+JDHandler.register(r"3\.cn/")
+FaceitHandler.register(r"https://.*\.faceit\.com/")
diff --git a/app/core/record_manager.py b/app/core/record_manager.py
new file mode 100644
index 0000000..bb86c11
--- /dev/null
+++ b/app/core/record_manager.py
@@ -0,0 +1,339 @@
+import asyncio
+from datetime import datetime, timedelta
+
+from ..messages.message_pusher import MessagePusher
+from ..models.recording_model import Recording
+from ..models.recording_status_model import RecordingStatus
+from ..utils import utils
+from ..utils.logger import logger
+from .platform_handlers import get_platform_info
+from .stream_manager import LiveStreamRecorder
+
+
+class RecordingManager:
+ def __init__(self, app):
+ self.app = app
+ self.settings = app.settings
+ self.recordings = []
+ self.periodic_task_started = False
+ self.loop_time_seconds = None
+ self.app.language_manager.add_observer(self)
+ self.load_recordings()
+ self._ = {}
+ self.load()
+ self.initialize_dynamic_state()
+
+ def load(self):
+ language = self.app.language_manager.language
+ for key in ("recording_manager", "video_quality"):
+ self._.update(language.get(key, {}))
+
+ def load_recordings(self):
+ """Load recordings from a JSON file into objects."""
+ recordings_data = self.app.config_manager.load_recordings_config()
+ self.recordings = [Recording.from_dict(rec) for rec in recordings_data]
+ logger.info(f"Live Recordings: Loaded {len(self.recordings)} items")
+
+ def initialize_dynamic_state(self):
+ """Initialize dynamic state for all recordings."""
+ loop_time_seconds = self.settings.user_config.get("loop_time_seconds")
+ self.loop_time_seconds = int(loop_time_seconds or 300)
+ for recording in self.recordings:
+ recording.loop_time_seconds = self.loop_time_seconds
+ recording.update_title(self._[recording.quality])
+
+ async def update_recording(self, recording: Recording, updated_info: dict):
+ """Update an existing recording object and persist changes to a JSON file."""
+ if recording:
+ recording.update(updated_info)
+ self.app.page.run_task(self.save_to_json)
+
+ @staticmethod
+ async def _update_recording(
+ recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool
+ ):
+ attrs_update = {
+ "monitor_status": monitor_status,
+ "display_title": display_title,
+ "status_info": status_info,
+ "selected": selected,
+ }
+ for attr, value in attrs_update.items():
+ setattr(recording, attr, value)
+
+ async def start_monitor_recording(self, recording: Recording, auto_save: bool = True):
+ """
+ Start monitoring a single recording if it is not already being monitored.
+ """
+ if not recording.monitor_status:
+ await self._update_recording(
+ recording=recording,
+ monitor_status=True,
+ display_title=recording.title,
+ status_info=RecordingStatus.MONITORING,
+ selected=False,
+ )
+ self.app.page.run_task(self.check_if_live, recording)
+ self.app.page.run_task(self.app.record_card_manager.update_cards, recording)
+ if auto_save:
+ self.app.page.run_task(self.save_to_json)
+
+ async def stop_monitor_recording(self, recording: Recording, auto_save: bool = True):
+ """
+ Stop monitoring a single recording if it is currently being monitored.
+ """
+ if recording.monitor_status:
+ await self._update_recording(
+ recording=recording,
+ monitor_status=False,
+ display_title=f"[{self._['monitor_stopped']}] {recording.title}",
+ status_info=RecordingStatus.STOPPED_MONITORING,
+ selected=False,
+ )
+ self.stop_recording(recording)
+ self.app.page.run_task(self.app.record_card_manager.update_cards, recording)
+ if auto_save:
+ self.app.page.run_task(self.save_to_json)
+
+ async def start_monitor_recordings(self):
+ """
+ Start monitoring multiple recordings based on user selection or all recordings if none are selected.
+ """
+ selected_recordings = await self.get_selected_recordings()
+ pre_start_monitor_recordings = selected_recordings if selected_recordings else self.recordings
+ cards_obj = self.app.record_card_manager.cards_obj
+ for recording in pre_start_monitor_recordings:
+ if cards_obj[recording.rec_id]["card"].visible:
+ self.app.page.run_task(self.start_monitor_recording, recording, auto_save=False)
+ self.app.page.run_task(self.save_to_json)
+ logger.info(f"Batch Start Monitor Recordings: {[i.rec_id for i in pre_start_monitor_recordings]}")
+
+ async def stop_monitor_recordings(self, selected_recordings: list[Recording | None] | None = None):
+ """
+ Stop monitoring multiple recordings based on user selection or all recordings if none are selected.
+ """
+ if not selected_recordings:
+ selected_recordings = await self.get_selected_recordings()
+ pre_stop_monitor_recordings = selected_recordings or self.recordings
+ cards_obj = self.app.record_card_manager.cards_obj
+ for recording in pre_stop_monitor_recordings:
+ if cards_obj[recording.rec_id]["card"].visible:
+ self.app.page.run_task(self.stop_monitor_recording, recording, auto_save=False)
+ self.app.page.run_task(self.save_to_json)
+ logger.info(f"Batch Stop Monitor Recordings: {[i.rec_id for i in pre_stop_monitor_recordings]}")
+
+ async def get_selected_recordings(self):
+ return [recording for recording in self.recordings if recording.selected]
+
+ def remove_recordings(self, recordings: list[Recording]):
+ """Remove a recording from the list and update the JSON file."""
+ for recording in recordings:
+ if recording in self.recordings:
+ self.recordings.remove(recording)
+ logger.info(f"Delete Items: {recording.rec_id}-{recording.streamer_name}")
+ self.app.page.run_task(self.save_to_json)
+
+ def find_recording_by_id(self, rec_id: str):
+ """Find a recording by its ID (hash of dict representation)."""
+ for rec in self.recordings:
+ if rec.rec_id == rec_id:
+ return rec
+ return None
+
+ async def save_to_json(self):
+ """Persist recordings to a JSON file."""
+ recordings_data = [rec.to_dict() for rec in self.recordings]
+ await self.app.config_manager.save_recordings_config(recordings_data)
+
+ async def check_all_live_status(self):
+ """Check the live status of all recordings and update their display titles."""
+ for recording in self.recordings:
+ if recording.monitor_status and not recording.recording:
+ is_exceeded = utils.is_time_interval_exceeded(recording.detection_time, recording.loop_time_seconds)
+ if not recording.detection_time or is_exceeded:
+ self.app.page.run_task(self.check_if_live, recording)
+
+ async def setup_periodic_live_check(self, interval: int = 180):
+ """Set up a periodic task to check live status."""
+
+ async def periodic_check():
+ while True:
+ await asyncio.sleep(interval)
+ await self.check_free_space()
+ if self.app.recording_enabled:
+ await self.check_all_live_status()
+
+ if not self.periodic_task_started:
+ self.periodic_task_started = True
+ await periodic_check()
+
+ async def check_if_live(self, recording: Recording):
+ """Check if the live stream is available, fetch stream data and update is_live status."""
+ if not recording.monitor_status:
+ recording.display_title = f"[{self._['monitor_stopped']}] {recording.title}"
+ recording.status_info = RecordingStatus.STOPPED_MONITORING
+
+ elif not recording.is_checking:
+ recording.status_info = RecordingStatus.STATUS_CHECKING
+ recording.detection_time = datetime.now().time()
+ if recording.scheduled_recording and recording.scheduled_start_time and recording.monitor_hours:
+ end_time = utils.add_hours_to_time(recording.scheduled_start_time, recording.monitor_hours)
+ scheduled_time_range = f"{recording.scheduled_start_time}~{end_time}"
+ recording.scheduled_time_range = scheduled_time_range
+ in_scheduled = utils.is_current_time_within_range(scheduled_time_range)
+ if not in_scheduled:
+ recording.status_info = RecordingStatus.NOT_IN_SCHEDULED_CHECK
+ logger.info(f"Skip Detection: {recording.url} not in scheduled check range {scheduled_time_range}")
+ return
+
+ recording.is_checking = True
+ platform, platform_key = get_platform_info(recording.url)
+
+ if self.settings.user_config["language"] != "zh_CN":
+ platform = platform_key
+
+ output_dir = self.settings.get_video_save_path()
+ await self.check_free_space(output_dir)
+ if not self.app.recording_enabled:
+ recording.is_checking = False
+ recording.status_info = RecordingStatus.NOT_RECORDING_SPACE
+ return
+
+ recording_info = {
+ "platform": platform,
+ "platform_key": platform_key,
+ "live_url": recording.url,
+ "output_dir": output_dir,
+ "segment_record": recording.segment_record,
+ "segment_time": recording.segment_time,
+ "save_format": recording.record_format,
+ "quality": recording.quality,
+ }
+
+ recorder = LiveStreamRecorder(self.app, recording, recording_info)
+
+ stream_info = await recorder.fetch_stream()
+ logger.info(f"Stream Data: {stream_info}")
+ if not stream_info or not stream_info.anchor_name:
+ logger.error(f"Fetch stream data failed: {recording.url}")
+ recording.is_checking = False
+ return
+
+ if self.settings.user_config.get("remove_emojis"):
+ stream_info.anchor_name = utils.clean_name(stream_info.anchor_name, self._["live_room"])
+
+ recording.is_live = stream_info.is_live
+ is_record = True
+ if recording.is_live and not recording.recording:
+ recording.status_info = RecordingStatus.PREPARING_RECORDING
+ recording.live_title = stream_info.title
+ if recording.streamer_name.strip() == self._["live_room"]:
+ recording.streamer_name = stream_info.anchor_name
+ recording.title = f"{recording.streamer_name} - {self._[recording.quality]}"
+ recording.display_title = f"[{self._['is_live']}] {recording.title}"
+
+ if self.settings.user_config["stream_start_notification_enabled"]:
+ push_content = self._["push_content"]
+ begin_push_message_text = self.settings.user_config.get("custom_stream_start_content")
+ if begin_push_message_text:
+ push_content = begin_push_message_text
+
+ push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S")
+ push_content = push_content.replace("[room_name]", recording.streamer_name).replace(
+ "[time]", push_at
+ )
+ msg_title = self.settings.user_config.get("custom_notification_title").strip()
+ msg_title = msg_title or self._["status_notify"]
+
+ msg_manager = MessagePusher(self.settings)
+ self.app.page.run_task(msg_manager.push_messages, msg_title, push_content)
+
+ if self.settings.user_config.get("only_notify_no_record"):
+ notify_loop_time = self.settings.user_config.get("notify_loop_time")
+ recording.loop_time_seconds = int(notify_loop_time or 3600)
+ is_record = False
+ else:
+ recording.loop_time_seconds = self.loop_time_seconds
+
+ if is_record:
+ self.start_update(recording)
+ self.app.page.run_task(recorder.start_recording, stream_info)
+
+ self.app.page.run_task(self.app.record_card_manager.update_cards, recording)
+
+ else:
+ recording.status_info = RecordingStatus.MONITORING
+ title = f"{stream_info.anchor_name or recording.streamer_name} - {self._[recording.quality]}"
+ if recording.streamer_name == self._["live_room"]:
+ recording.update(
+ {
+ "streamer_name": stream_info.anchor_name,
+ "title": title,
+ "display_title": title,
+ }
+ )
+ self.app.page.run_task(self.app.record_card_manager.update_cards, recording)
+ self.app.page.run_task(self.save_to_json)
+
+ @staticmethod
+ def start_update(recording: Recording):
+ """Start the recording process."""
+ if recording.is_live and not recording.recording:
+ # Reset cumulative and last durations for a fresh start
+ recording.update(
+ {
+ "cumulative_duration": timedelta(),
+ "last_duration": timedelta(),
+ "start_time": datetime.now(),
+ "recording": True,
+ }
+ )
+ logger.info(f"Started recording for {recording.title}")
+
+ @staticmethod
+ def stop_recording(recording: Recording):
+ """Stop the recording process."""
+ if recording.recording:
+ if recording.start_time is not None:
+ elapsed = datetime.now() - recording.start_time
+ # Add the elapsed time to the cumulative duration.
+ recording.cumulative_duration += elapsed
+ # Update the last recorded duration.
+ recording.last_duration = recording.cumulative_duration
+ recording.start_time = None
+ recording.recording = False
+ logger.info(f"Stopped recording for {recording.title}")
+
+ def get_duration(self, recording: Recording):
+ """Get the duration of the current recording session in a formatted string."""
+ if recording.recording and recording.start_time is not None:
+ elapsed = datetime.now() - recording.start_time
+ # If recording, add the current session time.
+ total_duration = recording.cumulative_duration + elapsed
+ return self._["recorded"] + " " + str(total_duration).split(".")[0]
+ else:
+ # If stopped, show the last recorded total duration.
+ total_duration = recording.last_duration
+ return str(total_duration).split(".")[0]
+
+ async def delete_recording_cards(self, recordings: list[Recording]):
+ self.remove_recordings(recordings)
+ self.app.page.run_task(self.app.record_card_manager.remove_recording_card, recordings)
+
+ async def check_free_space(self, output_dir: str | None = None):
+ disk_space_limit = float(self.settings.user_config.get("recording_space_threshold"))
+ output_dir = output_dir or self.settings.get_video_save_path()
+ if utils.check_disk_capacity(output_dir) < disk_space_limit:
+ self.app.recording_enabled = False
+ logger.error(
+ f"Disk space remaining is below {disk_space_limit} GB. Recording function disabled"
+ )
+ self.app.page.run_task(
+ self.app.snack_bar.show_snack_bar,
+ self._["not_disk_space_tip"],
+ duration=86400,
+ show_close_icon=True
+ )
+
+ else:
+ self.app.recording_enabled = True
diff --git a/app/core/stream_manager.py b/app/core/stream_manager.py
new file mode 100644
index 0000000..15de4ce
--- /dev/null
+++ b/app/core/stream_manager.py
@@ -0,0 +1,399 @@
+import asyncio
+import os
+import shutil
+import subprocess
+import time
+from datetime import datetime
+from typing import Any
+
+from ..models.recording_status_model import RecordingStatus
+from ..models.video_quality_model import VideoQuality
+from ..utils import utils
+from ..utils.logger import logger
+from . import ffmpeg_builders, platform_handlers
+from .platform_handlers import StreamData
+
+
+class LiveStreamRecorder:
+ DEFAULT_SEGMENT_TIME = "1800"
+ DEFAULT_SAVE_FORMAT = "mp4"
+ DEFAULT_QUALITY = VideoQuality.OD
+
+ def __init__(self, app, recording, recording_info):
+ self.app = app
+ self.settings = app.settings
+ self.recording = recording
+ self.recording_info = recording_info
+ self.subprocess_start_info = app.subprocess_start_up_info
+
+ self.user_config = self.settings.user_config
+ self.account_config = self.settings.accounts_config
+ self.platform_key = self._get_info("platform_key")
+ self.cookies = self.settings.cookies_config.get(self.platform_key)
+
+ self.platform = self._get_info("platform")
+ self.live_url = self._get_info("live_url")
+ self.output_dir = self._get_info("output_dir")
+ self.segment_record = self._get_info("segment_record", default=False)
+ self.segment_time = self._get_info("segment_time", default=self.DEFAULT_SEGMENT_TIME)
+ self.quality = self._get_info("quality", default=self.DEFAULT_QUALITY)
+ self.save_format = self._get_info("save_format", default=self.DEFAULT_SAVE_FORMAT).lower()
+ self.proxy = self.is_use_proxy()
+ os.makedirs(self.output_dir, exist_ok=True)
+ self.app.language_manager.add_observer(self)
+ self._ = {}
+ self.load()
+
+ def load(self):
+ language = self.app.language_manager.language
+ for key in ("recording_manager", "stream_manager"):
+ self._.update(language.get(key, {}))
+
+ def _get_info(self, key: str, default: Any = None):
+ return self.recording_info.get(key, default) or default
+
+ def is_use_proxy(self):
+ default_proxy_platform = self.user_config.get("default_platform_with_proxy", "")
+ proxy_list = default_proxy_platform.replace(",", ",").replace(" ", "").split(",")
+ if self.user_config.get("enable_proxy") and self.platform_key in proxy_list:
+ self.proxy = self.user_config.get("proxy_address")
+ return self.proxy
+
+ def _get_filename(self, stream_info: StreamData) -> str:
+ live_title = None
+ stream_info.title = utils.clean_name(stream_info.title, None)
+ if self.user_config.get("filename_includes_title") and stream_info.title:
+ stream_info.title = self._clean_and_truncate_title(stream_info.title)
+ live_title = stream_info.title
+
+ if self.recording.streamer_name and self.recording.streamer_name != self._["live_room"]:
+ stream_info.anchor_name = self.recording.streamer_name
+ else:
+ stream_info.anchor_name = utils.clean_name(stream_info.anchor_name, self._["live_room"])
+
+ now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
+ full_filename = "_".join([i for i in (stream_info.anchor_name, live_title, now) if i])
+ return full_filename
+
+ def _get_output_dir(self, stream_info: StreamData) -> str:
+ if self.recording.recording_dir:
+ return self.recording.recording_dir
+
+ now = datetime.today().strftime("%Y-%m-%d_%H-%M-%S")
+ output_dir = self.output_dir.rstrip("/").rstrip("\\")
+ if self.user_config.get("folder_name_author"):
+ output_dir = os.path.join(output_dir, stream_info.anchor_name)
+ if self.user_config.get("folder_name_time"):
+ output_dir = os.path.join(output_dir, now[:10])
+ if self.user_config.get("folder_name_title") and stream_info.title:
+ live_title = self._clean_and_truncate_title(stream_info.title)
+ if self.user_config.get("folder_name_time"):
+ output_dir = os.path.join(output_dir, f"{live_title}_{stream_info.anchor_name}")
+ else:
+ output_dir = os.path.join(output_dir, f"{now[:10]}_{live_title}")
+ os.makedirs(output_dir, exist_ok=True)
+ self.recording.recording_dir = output_dir
+ self.app.page.run_task(self.app.record_manager.save_to_json)
+ return output_dir
+
+ def _get_save_path(self, filename: str) -> str:
+ suffix = self.save_format
+ suffix = "_%03d." + suffix if self.segment_record and self.save_format != "flv" else "." + suffix
+ save_file_path = os.path.join(self.output_dir, filename + suffix).replace(" ", "_")
+ return save_file_path
+
+ @staticmethod
+ def _clean_and_truncate_title(title: str) -> str | None:
+ if not title:
+ return None
+ cleaned_title = title[:30].replace(",", ",").replace(" ", "")
+ return cleaned_title
+
+ def _get_record_url(self, url: str):
+ http_record_list = ["shopee"]
+ if self.platform_key in http_record_list:
+ url = url.replace("https://", "http://")
+ if self.user_config.get("force_https_recording") and url.startswith("http://"):
+ url = url.replace("http://", "https://")
+ return url
+
+ async def fetch_stream(self) -> StreamData:
+ logger.info(f"Live URL: {self.live_url}")
+ logger.info(f"Use Proxy: {self.proxy or None}")
+ self.recording.use_proxy = bool(self.proxy)
+ handler = platform_handlers.get_platform_handler(
+ live_url=self.live_url,
+ proxy=self.proxy,
+ cookies=self.cookies,
+ record_quality=self.quality,
+ platform=self.platform,
+ username=self.account_config.get(self.platform_key, {}).get("username"),
+ password=self.account_config.get(self.platform_key, {}).get("password"),
+ account_type=self.account_config.get(self.platform_key, {}).get("account_type"),
+ )
+ stream_info = await handler.get_stream_info(self.live_url)
+ self.recording.is_checking = False
+ return stream_info
+
+ async def start_recording(self, stream_info: StreamData):
+ """
+ Construct ffmpeg recording parameters and start recording
+ """
+
+ filename = self._get_filename(stream_info)
+ self.output_dir = self._get_output_dir(stream_info)
+ save_path = self._get_save_path(filename)
+ logger.info(f"Save Path: {save_path}")
+ self.recording.recording_dir = os.path.dirname(save_path)
+ os.makedirs(self.recording.recording_dir, exist_ok=True)
+ record_url = self._get_record_url(stream_info.record_url)
+
+ ffmpeg_builder = ffmpeg_builders.create_builder(
+ self.save_format,
+ record_url=record_url,
+ proxy=self.proxy,
+ segment_record=self.segment_record,
+ segment_time=self.segment_time,
+ full_path=save_path,
+ headers=self.get_headers_params(record_url, self.platform_key)
+ )
+ ffmpeg_command = ffmpeg_builder.build_command()
+ logger.info(f"FFmpeg Command: {ffmpeg_command}")
+ self.app.page.run_task(
+ self.start_ffmpeg,
+ stream_info.anchor_name,
+ self.live_url,
+ stream_info.record_url,
+ ffmpeg_command,
+ self.save_format,
+ self.user_config.get("custom_script_command"),
+ )
+
+ async def start_ffmpeg(
+ self,
+ record_name: str,
+ live_url: str,
+ record_url: str,
+ ffmpeg_command: list,
+ save_type: str,
+ script_command: str | None = None,
+ ) -> bool:
+ """
+ The child process executes ffmpeg for recording
+ """
+ save_file_path = ffmpeg_command[-1]
+
+ process = await asyncio.create_subprocess_exec(
+ *ffmpeg_command,
+ stdin=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ startupinfo=self.subprocess_start_info,
+ )
+
+ try:
+ self.recording.status_info = RecordingStatus.RECORDING
+ logger.info(f"Recording in Progress: {live_url}")
+ logger.log("STREAM", f"Recording Stream URL: {record_url}")
+ while True:
+ if not self.recording.recording or not self.app.recording_enabled:
+ logger.info(f"Preparing to End Recording: {live_url}")
+
+ if os.name == "nt":
+ if process.stdin:
+ process.stdin.write(b"q")
+ await process.stdin.drain()
+ else:
+ # import signal
+ # process.send_signal(signal.SIGINT)
+ process.terminate()
+
+ if process.stdin:
+ process.stdin.close()
+
+ try:
+ await asyncio.wait_for(process.wait(), timeout=10.0)
+ except asyncio.TimeoutError:
+ process.kill()
+ await process.wait()
+
+ if process.returncode is not None:
+ logger.info(f"Exit loop recording (normal 0 | abnormal 1): code={process.returncode}, {live_url}")
+ break
+
+ await asyncio.sleep(1)
+
+ stdout, stderr = await process.communicate()
+ if stderr:
+ logger.error(f"FFmpeg Stderr Output: {str(stderr.decode()).splitlines()[0]}")
+ self.recording.status_info = RecordingStatus.RECORDING_ERROR
+ self.app.record_manager.stop_recording(self.recording)
+ await self.app.record_card_manager.update_cards(self.recording)
+ await self.app.snack_bar.show_snack_bar(
+ record_name + " " + self._["record_stream_error"], duration=2000
+ )
+
+ return_code = process.returncode
+ safe_return_code = [0, 255]
+ if return_code in safe_return_code:
+ if self.recording.monitor_status:
+ self.recording.status_info = RecordingStatus.NOT_RECORDING
+ else:
+ self.recording.status_info = RecordingStatus.STOPPED_MONITORING
+ self.recording.live_title = None
+ if not self.recording.recording:
+ logger.success(f"Live recording has stopped: {record_name}")
+ else:
+ self.recording.recording = False
+ logger.success(f"Live recording completed: {record_name}")
+
+ self.app.page.run_task(self.app.record_card_manager.update_cards, self.recording)
+ if self.app.recording_enabled:
+ self.app.page.run_task(self.app.record_manager.check_if_live, self.recording)
+ else:
+ self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE
+
+ if self.user_config.get("convert_to_mp4") and self.save_format == "ts":
+ if self.segment_record:
+ file_paths = utils.get_file_paths(os.path.dirname(save_file_path))
+ prefix = os.path.basename(save_file_path).rsplit("_", maxsplit=1)[0]
+ for path in file_paths:
+ if prefix in path:
+ self.app.page.run_task(self.converts_mp4, path, self.user_config["delete_original"])
+ else:
+ self.app.page.run_task(self.converts_mp4, save_file_path, self.user_config["delete_original"])
+
+ if self.user_config.get("execute_custom_script") and script_command:
+ logger.info("Prepare a direct script in the background")
+ self.app.page.run_task(
+ self.custom_script_execute,
+ script_command,
+ record_name,
+ save_file_path,
+ save_type,
+ self.segment_record,
+ self.user_config.get("convert_to_mp4"),
+ )
+ logger.success("Successfully added script execution")
+
+ except Exception as e:
+ logger.error(f"An error occurred during the subprocess execution: {e}")
+ return False
+
+ return True
+
+ async def converts_mp4(self, converts_file_path: str, is_original_delete: bool = True) -> None:
+ converts_success = False
+ save_path = None
+ try:
+ if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0:
+ save_path = converts_file_path.rsplit(".", maxsplit=1)[0] + ".mp4"
+ _output = subprocess.check_output(
+ [
+ "ffmpeg",
+ "-i", converts_file_path,
+ "-c:v", "copy",
+ "-c:a", "copy",
+ "-f", "mp4",
+ save_path,
+ ],
+ stderr=subprocess.STDOUT,
+ startupinfo=self.subprocess_start_info,
+ )
+
+ converts_success = True
+ logger.info(f"Video transcoding completed: {save_path}")
+
+ except subprocess.CalledProcessError as e:
+ logger.error(f"Video transcoding failed! Error message: {e.output.decode()}")
+
+ try:
+ if converts_success:
+ if is_original_delete:
+ time.sleep(1)
+ if os.path.exists(converts_file_path):
+ os.remove(converts_file_path)
+ logger.info(f"Delete Original File: {converts_file_path}")
+ else:
+ converts_dir = f"{os.path.dirname(save_path)}/original"
+ os.makedirs(converts_dir, exist_ok=True)
+ shutil.move(converts_file_path, converts_dir)
+ logger.info(f"Move Transcoding Files: {converts_file_path}")
+
+ except subprocess.CalledProcessError as e:
+ logger.error(f"Error occurred during conversion: {e}")
+ except Exception as e:
+ logger.error(f"An unknown error occurred: {e}")
+
+ async def custom_script_execute(
+ self,
+ script_command: str,
+ record_name: str,
+ save_file_path: str,
+ save_type: str,
+ split_video_by_time: bool,
+ converts_to_mp4: bool,
+ ):
+ if "python" in script_command:
+ params = [
+ f'--record_name "{record_name}"',
+ f'--save_file_path "{save_file_path}"',
+ f"--save_type {save_type}--split_video_by_time {split_video_by_time}",
+ f"--converts_to_mp4 {converts_to_mp4}",
+ ]
+ else:
+ params = [
+ f'"{record_name.split(" ", maxsplit=1)[-1]}"',
+ f'"{save_file_path}"',
+ save_type,
+ f"split_video_by_time: {split_video_by_time}",
+ f"converts_to_mp4: {converts_to_mp4}",
+ ]
+ script_command = script_command.strip() + " " + " ".join(params)
+ self.app.page.run_task(self.run_script_async, script_command)
+ logger.success("Script command execution completed!")
+
+ async def run_script_async(self, command: str) -> None:
+ try:
+ process = await asyncio.create_subprocess_exec(
+ *command.split(),
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ startupinfo=self.subprocess_start_info,
+ text=True
+ )
+
+ stdout, stderr = await process.communicate()
+
+ if stdout:
+ logger.info(stdout.splitlines()[0])
+ if stderr:
+ logger.error(stderr.splitlines()[0])
+
+ if process.returncode != 0:
+ logger.info(f"Custom Script process exited with return code {process.returncode}")
+
+ except PermissionError:
+ logger.error(
+ "Script has no execution permission!, If it is a Linux environment, "
+ "please first execute: chmod+x your_script.sh to grant script executable permission"
+ )
+ except OSError:
+ logger.error("Please add `#!/bin/bash` at the beginning of your bash script file.")
+ except Exception as e:
+ logger.error(f"An error occurred: {e}")
+
+ @staticmethod
+ def get_headers_params(live_url, platform_key):
+ live_domain = "/".join(live_url.split("/")[0:3])
+ record_headers = {
+ 'pandalive': 'origin:https://www.pandalive.co.kr',
+ 'winktv': 'origin:https://www.winktv.co.kr',
+ 'popkontv': 'origin:https://www.popkontv.com',
+ 'flextv': 'origin:https://www.flextv.co.kr',
+ 'qiandurebo': 'referer:https://qiandurebo.com',
+ '17live': 'referer:https://17.live/en/live/6302408',
+ 'lang': 'referer:https://www.lang.live',
+ 'shopee': f'origin:{live_domain}',
+ }
+ return record_headers.get(platform_key)
diff --git a/app/installation_manager.py b/app/installation_manager.py
new file mode 100644
index 0000000..9926f2f
--- /dev/null
+++ b/app/installation_manager.py
@@ -0,0 +1,160 @@
+
+import flet as ft
+
+from .scripts.ffmpeg_install import check_ffmpeg_installed, install_ffmpeg
+from .scripts.node_install import check_nodejs_installed, install_nodejs
+from .utils.logger import logger
+
+
+class InstallationManager:
+ def __init__(self, app):
+ self.app = app
+ self.page: ft.Page = app.page
+ self.install_dialog = None
+ self.components_to_install = []
+ self.completed_components = set()
+ self.failed_components = set()
+ self.app.language_manager.add_observer(self)
+ self._ = {}
+ self.load()
+
+ def load(self):
+ language = self.app.language_manager.language
+ for key in ("base", "install_manager"):
+ self._.update(language.get(key, {}))
+
+ async def get_install_components(self):
+ components = [
+ {"name": "FFmpeg", "check_func": check_ffmpeg_installed, "install_func": install_ffmpeg},
+ {"name": "Node.js", "check_func": check_nodejs_installed, "install_func": install_nodejs},
+ ]
+ for component in components:
+ is_install = await component["check_func"]()
+ if not is_install:
+ self.components_to_install.append(component)
+
+ async def install_component(self, component_info):
+ install_func = component_info["name"]
+ try:
+ result = await component_info["install_func"](
+ lambda progress, status: self.update_component_progress(install_func, progress, status)
+ )
+ if result:
+ await self.update_component_progress(install_func, 1.0, self._["complete"])
+ self.completed_components.add(install_func)
+ except Exception as e:
+ await self.update_component_progress(install_func, 0, f"{self._['error']}: {str(e)}")
+ self.failed_components.add(install_func)
+
+ async def install_components(self):
+ left_btn = self.install_dialog.actions[0]
+ right_btn = self.install_dialog.actions[1]
+
+ left_btn.disabled = True
+ left_btn.text = self._["installing"]
+ self.page.update()
+
+ for component in self.components_to_install:
+ if component["name"] not in self.completed_components:
+ await self.install_component(component)
+
+ if len(self.completed_components) + len(self.failed_components) == len(self.components_to_install):
+ right_btn.text = self._["close"]
+
+ if self.failed_components:
+ left_btn.icon = ft.Icons.REFRESH
+ left_btn.text = self._["reinstall"]
+ left_btn.disabled = False
+
+ right_btn.icon = ft.Icons.ERROR_OUTLINED
+ right_btn.style = ft.ButtonStyle(
+ color=ft.Colors.WHITE, bgcolor=ft.Colors.RED_400, icon_color=ft.Colors.RED_600)
+ else:
+ left_btn.text = self._["installed"]
+
+ right_btn.icon = ft.Icons.CHECK_CIRCLE_OUTLINED
+ right_btn.style = ft.ButtonStyle(
+ color=ft.Colors.WHITE, bgcolor=ft.Colors.GREEN_400, icon_color=ft.Colors.GREEN_600)
+ self.page.update()
+
+ async def update_component_progress(self, component_name, progress, status):
+ for item in self.install_dialog.content.controls[4].controls:
+ if isinstance(item, ft.Row) and item.controls[0].controls[0].value == component_name:
+ item.controls[1].controls[0].value = progress
+ item.controls[1].controls[1].value = f"{int(progress * 100)}%"
+ item.controls[0].controls[1].value = status
+ if progress >= 1.0:
+ item.controls[1].controls[0].color = ft.Colors.GREEN_700
+ self.page.update()
+ break
+
+ async def show_install_dialog(self):
+ components_list = ft.ListView(expand=1, spacing=10, padding=20, auto_scroll=True)
+
+ for component in self.components_to_install:
+ progress_ring = ft.ProgressRing(width=40, height=40, stroke_width=3)
+ status_text = ft.Text(f"{component['name']} - {self._['wait_install']}...", size=14, no_wrap=False)
+ component_item = ft.Row(
+ controls=[
+ ft.Column([ft.Text(component["name"], size=16), status_text],
+ alignment=ft.MainAxisAlignment.START, expand=True),
+ ft.Column([progress_ring, ft.Text("0%", size=12)], horizontal_alignment=ft.CrossAxisAlignment.END),
+ ],
+ alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
+ )
+ components_list.controls.append(component_item)
+
+ dialog_content = ft.Column(
+ controls=[
+ ft.Icon(ft.Icons.DOWNLOADING, size=40, color=ft.Colors.BLUE_700),
+ ft.Text(self._["install_guide"], size=20),
+ ft.Divider(height=20),
+ ft.Text(self._["install_tip"], size=14),
+ components_list,
+ ],
+ horizontal_alignment=ft.CrossAxisAlignment.CENTER,
+ spacing=15,
+ height=int(self.page.window.height * 0.6),
+ )
+
+ self.install_dialog = ft.AlertDialog(
+ modal=True,
+ title=ft.Text(self._["lack_components"]),
+ content=dialog_content,
+ actions=[
+ ft.TextButton(
+ text=self._["install_now"],
+ icon=ft.Icons.DOWNLOAD,
+ style=ft.ButtonStyle(
+ color=ft.Colors.WHITE, bgcolor=ft.Colors.BLUE_600, overlay_color=ft.Colors.BLUE_800
+ ),
+ on_click=self.on_install_clicked,
+ ),
+ ft.TextButton(
+ text=self._["later_on"],
+ icon=ft.Icons.ACCESS_TIME,
+ style=ft.ButtonStyle(color=ft.Colors.GREY_700),
+ on_click=self.close_dialog,
+ ),
+ ],
+ actions_alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
+ )
+
+ self.page.overlay.append(self.install_dialog)
+ self.install_dialog.open = True
+ self.page.update()
+
+ async def close_dialog(self, _):
+ if self.install_dialog and self.install_dialog.open:
+ self.install_dialog.open = False
+ self.page.update()
+
+ async def on_install_clicked(self, _):
+ await self.install_components()
+
+ async def check_env(self):
+ await self.get_install_components()
+ if self.components_to_install:
+ logger.info(f"Missing components: {[i['name'] for i in self.components_to_install]}")
+ self.page.run_task(self.show_install_dialog)
diff --git a/app/messages/__init__.py b/app/messages/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/messages/message_pusher.py b/app/messages/message_pusher.py
new file mode 100644
index 0000000..713539a
--- /dev/null
+++ b/app/messages/message_pusher.py
@@ -0,0 +1,71 @@
+from asyncio import create_task
+
+from ..utils.logger import logger
+from .notification_service import NotificationService
+
+
+class MessagePusher:
+ def __init__(self, settings):
+ self.settings = settings
+ self.notifier = NotificationService()
+
+ async def push_messages(self, msg_title: str, push_content: str):
+ """Push messages to all enabled notification services"""
+ if self.settings.user_config.get("dingtalk_enabled"):
+ create_task(
+ self.notifier.send_to_dingtalk(
+ url=self.settings.user_config.get("dingtalk_webhook_url"),
+ content=push_content,
+ number=self.settings.user_config.get("dingtalk_at_objects"),
+ is_atall=self.settings.user_config.get("dingtalk_at_all"),
+ )
+ )
+ logger.info("Push DingTalk message successfully")
+
+ if self.settings.user_config.get("wechat_enabled"):
+ create_task(
+ self.notifier.send_to_wechat(
+ url=self.settings.user_config.get("wechat_webhook_url"), title=msg_title, content=push_content
+ )
+ )
+ logger.info("Push Wechat message successfully")
+
+ if self.settings.user_config.get("bark_enabled"):
+ create_task(
+ self.notifier.send_to_bark(
+ api=self.settings.user_config.get("bark_webhook_url"),
+ title=msg_title,
+ content=push_content,
+ level=self.settings.user_config.get("bark_interrupt_level"),
+ sound=self.settings.user_config.get("bark_sound"),
+ )
+ )
+ logger.info("Push Bark message successfully")
+
+ if self.settings.user_config.get("ntfy_enabled"):
+ create_task(
+ self.notifier.send_to_ntfy(
+ api=self.settings.user_config.get("ntfy_server_url"),
+ title=msg_title,
+ content=push_content,
+ tags=self.settings.user_config.get("ntfy_tags"),
+ action_url=self.settings.user_config.get("ntfy_action_url"),
+ email=self.settings.user_config.get("ntfy_email"),
+ )
+ )
+ logger.info("Push Ntfy message successfully")
+
+ if self.settings.user_config.get("email_enabled"):
+ create_task(
+ self.notifier.send_to_email(
+ email_host=self.settings.user_config.get("smtp_server"),
+ login_email=self.settings.user_config.get("email_username"),
+ password=self.settings.user_config.get("email_password"),
+ sender_email=self.settings.user_config.get("sender_email"),
+ sender_name=self.settings.user_config.get("sender_name"),
+ to_email=self.settings.user_config.get("recipient_email"),
+ title=msg_title,
+ content=push_content,
+ )
+ )
+ logger.info("Push Email message successfully")
diff --git a/app/messages/notification_service.py b/app/messages/notification_service.py
new file mode 100644
index 0000000..19575bb
--- /dev/null
+++ b/app/messages/notification_service.py
@@ -0,0 +1,189 @@
+import base64
+import smtplib
+from email.header import Header
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from typing import Any, Optional
+
+import httpx
+
+from ..utils.logger import logger
+
+
+class NotificationService:
+ def __init__(self):
+ self.headers = {"Content-Type": "application/json"}
+
+ async def _async_post(self, url: str, json_data: dict[str, Any]) -> dict[str, Any]:
+ try:
+ async with httpx.AsyncClient() as client:
+ response = await client.post(url, json=json_data, headers=self.headers)
+ response.raise_for_status()
+ return response.json()
+ except Exception as e:
+ logger.info(f"Push failed, push address: {url}, Error message: {e}")
+ return {"error": str(e)}
+
+ async def send_to_dingtalk(
+ self, url: str, content: str, number: Optional[str] = None, is_atall: bool = False
+ ) -> dict[str, list[str]]:
+ results = {"success": [], "error": []}
+ api_list = [u.strip() for u in url.replace(",", ",").split(",") if u.strip()]
+ for api in api_list:
+ json_data = {
+ "msgtype": "text",
+ "text": {"content": content},
+ "at": {"atMobiles": [number] if number else [], "isAtAll": is_atall},
+ }
+ resp = await self._async_post(api, json_data)
+ if resp.get("errcode") == 0:
+ results["success"].append(api)
+ else:
+ results["error"].append(api)
+ return results
+
+ async def send_to_wechat(self, url: str, title: str, content: str) -> dict[str, Any]:
+ results = {"success": [], "error": []}
+ api_list = url.replace(",", ",").split(",") if url.strip() else []
+ for api in api_list:
+ json_data = {"title": title, "content": content}
+ resp = await self._async_post(api, json_data)
+ if resp.get("code") == 200:
+ results["success"].append(api)
+ else:
+ results["error"].append(api)
+ logger.info(f"WeChat push failed, push address: {api}, Failure message: {json_data['msg']}")
+ return results
+
+ @staticmethod
+ async def send_to_email(
+ email_host: str,
+ login_email: str,
+ password: str,
+ sender_email: str,
+ sender_name: str,
+ to_email: str,
+ title: str,
+ content: str,
+ smtp_port: str | None = None,
+ open_ssl: bool = True,
+ ) -> dict[str, Any]:
+ receivers = to_email.replace(",", ",").split(",") if to_email.strip() else []
+
+ try:
+ message = MIMEMultipart()
+ send_name = base64.b64encode(sender_name.encode("utf-8")).decode()
+ message["From"] = f"=?UTF-8?B?{send_name}?= <{sender_email}>"
+ message["Subject"] = Header(title, "utf-8")
+ if len(receivers) == 1:
+ message["To"] = receivers[0]
+
+ t_apart = MIMEText(content, "plain", "utf-8")
+ message.attach(t_apart)
+
+ if open_ssl:
+ smtp_port = smtp_port or 465
+ smtp_obj = smtplib.SMTP_SSL(email_host, int(smtp_port))
+ else:
+ smtp_port = smtp_port or 25
+ smtp_obj = smtplib.SMTP(email_host, int(smtp_port))
+ smtp_obj.login(login_email, password)
+ smtp_obj.sendmail(sender_email, receivers, message.as_string())
+ return {"success": receivers, "error": []}
+ except smtplib.SMTPException as e:
+ logger.info(f"Email push failed, push email: {to_email}, Error message: {e}")
+ return {"success": [], "error": receivers}
+
+ async def send_to_telegram(self, chat_id: int, token: str, content: str) -> dict[str, Any]:
+ try:
+ json_data = {"chat_id": chat_id, "text": content}
+ url = "https://api.telegram.org/bot" + token + "/sendMessage"
+ _resp = await self._async_post(url, json_data)
+ return {"success": [1], "error": []}
+ except Exception as e:
+ logger.info(f"Telegram push failed, chat ID: {chat_id}, Error message: {e}")
+ return {"success": [], "error": [1]}
+
+ async def send_to_bark(
+ self,
+ api: str,
+ title: str = "message",
+ content: str = "test",
+ level: str = "active",
+ badge: int = 1,
+ auto_copy: int = 1,
+ sound: str = "",
+ icon: str = "",
+ group: str = "",
+ is_archive: int = 1,
+ url: str = "",
+ ) -> dict[str, Any]:
+ results = {"success": [], "error": []}
+ api_list = api.replace(",", ",").split(",") if api.strip() else []
+ for _api in api_list:
+ json_data = {
+ "title": title,
+ "body": content,
+ "level": level,
+ "badge": badge,
+ "autoCopy": auto_copy,
+ "sound": sound,
+ "icon": icon,
+ "group": group,
+ "isArchive": is_archive,
+ "url": url,
+ }
+ resp = await self._async_post(_api, json_data)
+ if resp.get("code") == 200:
+ results["success"].append(_api)
+ else:
+ results["error"].append(_api)
+ logger.info(f"Bark push failed, push address: {_api}, Failure message: {json_data['message']}")
+ return results
+
+ async def send_to_ntfy(
+ self,
+ api: str,
+ title: str = "message",
+ content: str = "test",
+ tags: str = "tada",
+ priority: int = 3,
+ action_url: str = "",
+ attach: str = "",
+ filename: str = "",
+ click: str = "",
+ icon: str = "",
+ delay: str = "",
+ email: str = "",
+ call: str = "",
+ ) -> dict[str, Any]:
+ results = {"success": [], "error": []}
+ api_list = api.replace(",", ",").split(",") if api.strip() else []
+ tags = tags.replace(",", ",").split(",") if tags else ["partying_face"]
+ actions = [{"action": "view", "label": "view live", "url": action_url}] if action_url else []
+ for _api in api_list:
+ server, topic = _api.rsplit("/", maxsplit=1)
+ json_data = {
+ "topic": topic,
+ "title": title,
+ "message": content,
+ "tags": tags,
+ "priority": priority,
+ "attach": attach,
+ "filename": filename,
+ "click": click,
+ "actions": actions,
+ "markdown": False,
+ "icon": icon,
+ "delay": delay,
+ "email": email,
+ "call": call,
+ }
+
+ resp = await self._async_post(_api, json_data)
+ if "error" not in resp:
+ results["success"].append(_api)
+ else:
+ results["error"].append(_api)
+ logger.info(f"Ntfy push failed, push address: {_api}, Failure message: {json_data['error']}")
+ return results
diff --git a/app/models/__init__.py b/app/models/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/models/recording_model.py b/app/models/recording_model.py
new file mode 100644
index 0000000..090ca43
--- /dev/null
+++ b/app/models/recording_model.py
@@ -0,0 +1,116 @@
+from datetime import timedelta
+
+
+class Recording:
+ def __init__(
+ self,
+ rec_id,
+ url,
+ streamer_name,
+ record_format,
+ quality,
+ segment_record,
+ segment_time,
+ monitor_status,
+ scheduled_recording,
+ scheduled_start_time,
+ monitor_hours,
+ recording_dir,
+ ):
+ """
+ Initialize a recording object.
+
+ :param rec_id: Unique identifier for the recording task.
+ :param url: URL address of the live stream.
+ :param streamer_name: Name of the streamer.
+ :param record_format: Format of the recorded file, e.g., 'mp4', 'ts', 'mkv'.
+ :param quality: Quality of the recorded video, e.g., 'OD', 'UHD', 'HD'.
+ :param segment_record: Whether to enable segmented recording.
+ :param segment_time: Time interval (in seconds) for segmented recording if enabled.
+ :param monitor_status: Monitoring status, whether the live room is being monitored.
+ :param scheduled_recording: Whether to enable scheduled recording.
+ :param scheduled_start_time: Scheduled start time for recording (string format like '18:30:00').
+ :param monitor_hours: Number of hours to monitor from the scheduled recording start time, e.g., 3.
+ :param recording_dir: Directory path where the recorded files will be saved.
+ """
+
+ self.rec_id = rec_id
+ self.url = url
+ self.quality = quality
+ self.record_format = record_format
+ self.monitor_status = monitor_status
+ self.segment_record = segment_record
+ self.segment_time = segment_time
+ self.streamer_name = streamer_name
+ self.scheduled_recording = scheduled_recording
+ self.scheduled_start_time = scheduled_start_time
+ self.monitor_hours = monitor_hours
+ self.scheduled_time_range = None
+ self.title = f"{streamer_name} - {self.quality}"
+ self.speed = "X KB/s"
+ self.is_live = False
+ self.recording = False # Record status
+ self.start_time = None
+ self.recording_dir = recording_dir
+ self.cumulative_duration = timedelta() # Accumulated recording time
+ self.last_duration = timedelta() # Save the total time of the last recording
+ self.display_title = self.title
+ self.selected = False
+ self.is_checking = False
+ self.status_info = None
+ self.live_title = None
+ self.detection_time = None
+ self.loop_time_seconds = None
+ self.use_proxy = None
+
+ def to_dict(self):
+ """Convert the Recording instance to a dictionary for saving."""
+ return {
+ "rec_id": self.rec_id,
+ "url": self.url,
+ "streamer_name": self.streamer_name,
+ "record_format": self.record_format,
+ "quality": self.quality,
+ "segment_record": self.segment_record,
+ "segment_time": self.segment_time,
+ "monitor_status": self.monitor_status,
+ "scheduled_recording": self.scheduled_recording,
+ "scheduled_start_time": self.scheduled_start_time,
+ "monitor_hours": self.monitor_hours,
+ "recording_dir": self.recording_dir,
+ }
+
+ @classmethod
+ def from_dict(cls, data):
+ """Create a Recording instance from a dictionary."""
+ recording = cls(
+ data.get("rec_id"),
+ data.get("url"),
+ data.get("streamer_name"),
+ data.get("record_format"),
+ data.get("quality"),
+ data.get("segment_record"),
+ data.get("segment_time"),
+ data.get("monitor_status"),
+ data.get("scheduled_recording"),
+ data.get("scheduled_start_time"),
+ data.get("monitor_hours"),
+ data.get("recording_dir"),
+ )
+ recording.title = data.get("title", recording.title)
+ recording.display_title = data.get("display_title", recording.title)
+ recording.last_duration_str = data.get("last_duration")
+ if recording.last_duration_str is not None:
+ recording.last_duration = timedelta(seconds=float(recording.last_duration_str))
+ return recording
+
+ def update_title(self, quality_info, prefix=None):
+ """Helper method to update the title."""
+ self.title = f"{self.streamer_name} - {quality_info}"
+ self.display_title = f"{prefix or ''}{self.title}"
+
+ def update(self, updated_info: dict):
+ """Update the recording object with new information."""
+ for attr, value in updated_info.items():
+ if hasattr(self, attr):
+ setattr(self, attr, value)
diff --git a/app/models/recording_status_model.py b/app/models/recording_status_model.py
new file mode 100644
index 0000000..4d99d65
--- /dev/null
+++ b/app/models/recording_status_model.py
@@ -0,0 +1,17 @@
+class RecordingStatus:
+ STOPPED_MONITORING = "STOPPED_MONITORING"
+ MONITORING = "MONITORING"
+ RECORDING = "RECORDING"
+ NOT_RECORDING = "NOT_RECORDING"
+ STATUS_CHECKING = "STATUS_CHECKING"
+ NOT_IN_SCHEDULED_CHECK = "NOT_IN_SCHEDULED_CHECK"
+ PREPARING_RECORDING = "PREPARING_RECORDING"
+ RECORDING_ERROR = "RECORDING_ERROR"
+ NOT_RECORDING_SPACE = "NOT_RECORDING_SPACE"
+
+ @classmethod
+ def get_status(cls):
+ """Get all properties of the RecordingStatus class"""
+ attributes = cls.__dict__
+ recording_status = [value for name, value in attributes.items() if name.isupper()]
+ return recording_status
diff --git a/app/models/video_format_model.py b/app/models/video_format_model.py
new file mode 100644
index 0000000..d9d668d
--- /dev/null
+++ b/app/models/video_format_model.py
@@ -0,0 +1,15 @@
+class VideoFormat:
+ TS = "TS"
+ MP4 = "MP4"
+ FLV = "FLV"
+ MKV = "MKV"
+ MOV = "MOV"
+ MP3 = "MP3"
+ M4A = "M4A"
+
+ @classmethod
+ def get_formats(cls):
+ """Get all properties of the VideoFormat class"""
+ attributes = cls.__dict__
+ video_formats = [value for name, value in attributes.items() if name.isupper()]
+ return video_formats
diff --git a/app/models/video_quality_model.py b/app/models/video_quality_model.py
new file mode 100644
index 0000000..c719160
--- /dev/null
+++ b/app/models/video_quality_model.py
@@ -0,0 +1,13 @@
+class VideoQuality:
+ OD = "OD"
+ UHD = "UHD"
+ HD = "HD"
+ SD = "SD"
+ LD = "LD"
+
+ @classmethod
+ def get_qualities(cls):
+ """Get all properties of the VideoQuality class"""
+ attributes = cls.__dict__
+ video_qualities = [value for name, value in attributes.items() if name.isupper()]
+ return video_qualities
diff --git a/app/scripts/__init__.py b/app/scripts/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/scripts/node_install.py b/app/scripts/node_install.py
new file mode 100644
index 0000000..64f8660
--- /dev/null
+++ b/app/scripts/node_install.py
@@ -0,0 +1,239 @@
+import asyncio
+import os
+import platform
+import re
+import shutil
+import subprocess
+import sys
+import zipfile
+from pathlib import Path
+
+import distro
+import httpx
+
+from ..utils.logger import logger
+from ..utils.utils import get_startup_info
+
+current_platform = platform.system()
+execute_dir = os.path.split(os.path.realpath(sys.argv[0]))[0]
+current_env_path = os.environ.get("PATH")
+node_path = os.path.join(execute_dir, "node")
+startupinfo = get_startup_info()
+
+
+async def unzip_file(zip_path: str | Path, extract_to: str | Path, delete: bool = True) -> None:
+ if not os.path.exists(extract_to):
+ os.makedirs(extract_to, exist_ok=True)
+
+ loop = asyncio.get_running_loop()
+ try:
+ await loop.run_in_executor(None, _sync_unzip, zip_path, extract_to)
+ logger.debug(f"Compressed file decompression completed: {zip_path}")
+ except Exception as e:
+ logger.error(f"Failed to decompress the compressed file: {e}")
+ raise Exception("Failed to decompress the compressed file")
+
+ if delete and os.path.exists(zip_path):
+ os.remove(zip_path)
+
+
+def _sync_unzip(zip_path: str | Path, extract_to: str | Path) -> None:
+ if not zipfile.is_zipfile(zip_path):
+ os.remove(zip_path)
+ logger.error(f"The file is not a valid ZIP file: {zip_path}")
+ raise ValueError("The file is not a valid ZIP file")
+
+ with zipfile.ZipFile(zip_path, "r") as zip_ref:
+ zip_ref.extractall(extract_to)
+
+
+async def install_nodejs_windows(update_progress):
+ try:
+ logger.warning("Node.js is not installed.")
+ logger.debug("Installing the stable version of Node.js for Windows...")
+ await update_progress(0.1, "Get Node.js installation resources")
+ async with httpx.AsyncClient(follow_redirects=True) as client:
+ response = await client.get("https://nodejs.cn/download/")
+ if response.status_code == 200:
+ text = response.text
+ match = re.search("https://npmmirror.com/mirrors/node/(v.*?)/node-(v.*?)-x64.msi", text)
+ if match:
+ version = match.group(1)
+ system_bit = "x64" if "32" not in platform.machine() else "x86"
+ url = f"https://npmmirror.com/mirrors/node/{version}/node-{version}-win-{system_bit}.zip"
+ else:
+ logger.error("Failed to retrieve the download URL for the latest version of Node.js")
+ raise Exception("The resource address cannot be accessed")
+
+ full_file_name = url.rsplit("/", maxsplit=1)[-1]
+ zip_file_path = Path(execute_dir) / full_file_name
+
+ if Path(zip_file_path).exists():
+ await update_progress(0.8, "Node.js installation file already exists")
+ else:
+ await update_progress(0.2, "Start downloading Node.js installation package")
+ async with client.stream("GET", url) as resp:
+ if resp.status_code != 200:
+ raise Exception("The resource address cannot be accessed")
+
+ total_size = int(resp.headers.get("Content-Length", 0))
+ downloaded = 0
+ with open(zip_file_path, "wb") as f:
+ async for chunk in resp.aiter_bytes():
+ f.write(chunk)
+ downloaded += len(chunk)
+
+ progress = 0.2 + 0.6 * (downloaded / total_size)
+ await update_progress(
+ round(progress, 2), f"Downloading... {downloaded // 1024}KB/{total_size // 1024}KB"
+ )
+
+ await update_progress(0.8, "Extracting and cleaning installation files")
+ await unzip_file(zip_file_path, execute_dir)
+ extract_dir_path = str(zip_file_path).rsplit(".", maxsplit=1)[0]
+ new_extract_dir_path = Path(extract_dir_path).parent / "node"
+ await update_progress(0.9, "Configuring Node.js environment variables")
+ if Path(extract_dir_path).exists():
+ if Path(new_extract_dir_path).exists():
+ shutil.rmtree(new_extract_dir_path)
+ os.rename(extract_dir_path, new_extract_dir_path)
+ os.environ["PATH"] = execute_dir + "/node" + os.pathsep + current_env_path
+ result = subprocess.run(["node", "-v"], capture_output=True, startupinfo=startupinfo)
+ if result.returncode == 0:
+ return True
+ else:
+ raise Exception("Please restart the program")
+ else:
+ logger.error("Failed to retrieve the Node.js version page")
+ raise Exception("Failed to obtain the Node.js download address")
+
+ except Exception as e:
+ logger.error(f"Node.js installation failed, {e}")
+ raise RuntimeError(f"Node.js install failed, {e}") from None
+
+
+async def install_nodejs_centos(update_progress):
+ try:
+ logger.warning("Node.js is not installed.")
+ logger.debug("Installing the latest version of Node.js for CentOS...")
+ await update_progress(0.1, "Get Node.js installation resources")
+ result = subprocess.run(
+ "curl -fsSL https://mirrors.tuna.tsinghua.edu.cn/nodesource/rpm/setup_lts.x | bash -",
+ shell=True,
+ capture_output=True,
+ startupinfo=startupinfo
+ )
+ if result.returncode != 0:
+ logger.error("Failed to run NodeSource installation script")
+
+ result = subprocess.run(["yum", "install", "-y", "epel-release"], capture_output=True, startupinfo=startupinfo)
+ if result.returncode != 0:
+ logger.error("Failed to install EPEL repository")
+
+ result = subprocess.run(["yum", "install", "-y", "nodejs"], capture_output=True, startupinfo=startupinfo)
+ if result.returncode == 0:
+ logger.debug("Node.js installation was successful. Restart for changes to take effect.")
+ return True
+ else:
+ logger.error("Node.js installation failed")
+ raise Exception("Please manually install by yourself")
+ except Exception as e:
+ logger.error(f"Node.js installation failed {e}")
+ raise RuntimeError(f"Node.js install failed, {e}") from None
+
+
+async def install_nodejs_ubuntu(update_progress):
+ try:
+ logger.warning("Node.js is not installed.")
+ logger.debug("Installing the latest version of Node.js for Ubuntu...")
+ await update_progress(0.1, "Get Node.js installation resources")
+ install_script = "curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -"
+ result = subprocess.run(install_script, shell=True, capture_output=True, startupinfo=startupinfo)
+ if result.returncode != 0:
+ logger.error("Failed to run NodeSource installation script")
+
+ install_command = ["apt", "install", "-y", "nodejs"]
+ result = subprocess.run(install_command, capture_output=True, startupinfo=startupinfo)
+ if result.returncode == 0:
+ logger.debug("Node.js installation was successful. Restart for changes to take effect.")
+ return True
+ else:
+ logger.error("Node.js installation failed")
+ raise Exception("Please manually install by yourself")
+ except Exception as e:
+ logger.error(f"Node.js installation failed, {e}")
+ raise RuntimeError(f"Node.js install failed, {e}") from None
+
+
+async def install_nodejs_mac(update_progress):
+ logger.warning("Node.js is not installed.")
+ logger.debug("Installing the latest version of Node.js for macOS...")
+ await update_progress(0.1, "Get Node.js installation resources")
+ await asyncio.sleep(2)
+ await update_progress(0.3, "Please be patient and wait...")
+ try:
+ result = subprocess.run(["brew", "install", "node"], capture_output=True, startupinfo=startupinfo)
+ if result.returncode == 0:
+ logger.debug("Node.js installation was successful. Restart for changes to take effect.")
+ return True
+ else:
+ logger.error("Node.js installation failed")
+ raise Exception("Please manually install by yourself")
+ except subprocess.CalledProcessError as e:
+ logger.error(f"Failed to install Node.js using Homebrew. {e}")
+ logger.error("Please install Node.js manually or check your Homebrew installation.")
+ raise Exception("Please check if Homebrew has been installed") from None
+ except Exception as e:
+ logger.error(f"Node.js installation failed, {e}")
+ raise RuntimeError("Node.js install failed") from None
+
+
+def get_package_manager():
+ dist_id = distro.id()
+ if dist_id in ["centos", "fedora", "rhel", "amzn", "oracle", "scientific", "opencloudos", "alinux"]:
+ return "RHS"
+ else:
+ return "DBS"
+
+
+async def install_nodejs(update_progress) -> bool:
+ if current_platform == "Windows":
+ return await install_nodejs_windows(update_progress)
+ elif current_platform == "Linux":
+ os_type = get_package_manager()
+ if os_type == "RHS":
+ return await install_nodejs_centos(update_progress)
+ else:
+ return await install_nodejs_ubuntu(update_progress)
+ elif current_platform == "Darwin":
+ return await install_nodejs_mac(update_progress)
+ else:
+ logger.debug(
+ f"Node.js auto installation is not supported on this platform: {current_platform}. "
+ f"Please install Node.js manually."
+ )
+ return False
+
+
+def update_env_path():
+ if current_platform != "Windows":
+ path_list = ["/usr/bin/", "/usr/local/bin", "/opt/homebrew/bin"]
+ current_env_path_list = current_env_path.split(os.pathsep)
+ env_path = [path for path in path_list if path not in set(current_env_path_list)]
+ node_env_path = os.pathsep.join([node_path] + env_path + current_env_path_list)
+ else:
+ node_env_path = node_path + os.pathsep + current_env_path
+ os.environ["PATH"] = node_env_path
+
+
+async def check_nodejs_installed() -> bool:
+ try:
+ update_env_path()
+ result = subprocess.run(["node", "-v"], capture_output=True, startupinfo=startupinfo, text=True)
+ logger.info(result)
+ version_info = result.stdout.strip()
+ if result.returncode == 0 and version_info:
+ return True
+ except FileNotFoundError as e:
+ logger.info(e)
+ return False
diff --git a/app/ui/__init__.py b/app/ui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/ui/base_page.py b/app/ui/base_page.py
new file mode 100644
index 0000000..60ec08e
--- /dev/null
+++ b/app/ui/base_page.py
@@ -0,0 +1,18 @@
+import flet as ft
+
+
+class PageBase:
+ def __init__(self, app):
+ """Initialize the base page class.
+
+ :param app: The main application object.
+ """
+ self.app = app
+ self.page: ft.Page = app.page
+ self.content_area = app.content_area
+ self._ = {}
+
+ async def load(self):
+ """Load page content into the content area.
+ """
+ raise NotImplementedError("Subclasses must implement this method")
diff --git a/app/ui/components/__init__.py b/app/ui/components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/ui/components/card_dialog.py b/app/ui/components/card_dialog.py
new file mode 100644
index 0000000..9747f41
--- /dev/null
+++ b/app/ui/components/card_dialog.py
@@ -0,0 +1,62 @@
+import flet as ft
+
+
+class CardDialog(ft.AlertDialog):
+ def __init__(self, app, recording):
+ self.app = app
+ self._ = {}
+ self.load()
+ super().__init__(
+ title=ft.Text(self._["recording_info"]),
+ content=self.get_content(recording),
+ actions=[
+ ft.TextButton(self._["close"], on_click=self.close_panel),
+ ],
+ actions_alignment=ft.MainAxisAlignment.END,
+ modal=False,
+ )
+
+ def load(self):
+ language = self.app.language_manager.language
+ for key in ("recording_card", "recording_manager", "base", "video_quality"):
+ self._.update(language.get(key, {}))
+
+ def get_content(self, recording):
+ """Record card information content"""
+ anchor_name = recording.streamer_name
+ live_link = recording.url
+ live_title = recording.live_title or self._["none"]
+ record_format = recording.record_format
+ quality_info = self._[recording.quality]
+ use_proxy = self._["yes"] if recording.use_proxy else self._["no"]
+ segment_record_status = self._["enabled"] if recording.segment_record else self._["disabled"]
+ segment_time = f"{recording.segment_time}{self._['seconds']}"
+ monitor_status = self._["enabled"] if recording.monitor_status else self._["disabled"]
+ scheduled_recording_status = self._["enabled"] if recording.scheduled_recording else self._["disabled"]
+ scheduled_time_range = recording.scheduled_time_range or self._["none"]
+ save_path = recording.recording_dir or self._["no_recording_dir_tip"]
+ recording_status_info = self._[recording.status_info]
+
+ dialog_content = ft.Column(
+ [
+ ft.Text(f"{self._['anchor_name']}: {anchor_name}", size=14, selectable=True),
+ ft.Text(f"{self._['live_link']}: {live_link}", size=14, selectable=True),
+ ft.Text(f"{self._['live_title']}: {live_title}", size=14, selectable=True),
+ ft.Text(f"{self._['record_format']}: {record_format}", size=14),
+ ft.Text(f"{self._['record_quality']}: {quality_info}", size=14),
+ ft.Text(f"{self._['use_proxy']}: {use_proxy}", size=14),
+ ft.Text(f"{self._['segment_record']}: {segment_record_status}", size=14),
+ ft.Text(f"{self._['segment_time']}: {segment_time}", size=14),
+ ft.Text(f"{self._['monitor_status']}: {monitor_status}", size=14),
+ ft.Text(f"{self._['scheduled_recording']}: {scheduled_recording_status}", size=14),
+ ft.Text(f"{self._['scheduled_time_range']}: {scheduled_time_range}", size=14),
+ ft.Text(f"{self._['save_path']}: {save_path}", size=14, selectable=True),
+ ft.Text(f"{self._['recording_status']}: {recording_status_info}", size=14),
+ ],
+ spacing=5,
+ )
+ return dialog_content
+
+ def close_panel(self, _):
+ self.open = False
+ self.update()
diff --git a/app/ui/components/help_dialog.py b/app/ui/components/help_dialog.py
new file mode 100644
index 0000000..c7efa29
--- /dev/null
+++ b/app/ui/components/help_dialog.py
@@ -0,0 +1,42 @@
+import flet as ft
+
+
+class HelpDialog(ft.AlertDialog):
+ def __init__(self, app):
+ self.app = app
+ self.app.language_manager.add_observer(self)
+ self._ = {}
+ self.load()
+ super().__init__(
+ title=ft.Text(self._["shortcut_key_help"]),
+ content=self.get_content(),
+ actions=[ft.TextButton(self._["close"], on_click=self.close_panel)],
+ actions_alignment=ft.MainAxisAlignment.END,
+ modal=False,
+ )
+
+ def load(self):
+ language = self.app.language_manager.language
+ for key in ("help_dialog", "base"):
+ self._.update(language.get(key, {}))
+
+ def get_content(self):
+ """Help card information content"""
+ help_text_list = [
+ self._["description"],
+ f"\n{self._['main_page']}",
+ self._["search_recording"],
+ self._["refresh_list"],
+ self._["add_new_recording"],
+ self._["start_all"],
+ self._["stop_all"],
+ self._["delete_all"],
+ f"\n{self._['settings_page']}",
+ self._["save_configuration"],
+ f"\n{self._['view_help']}",
+ ]
+ return ft.Text("\n".join(help_text_list))
+
+ def close_panel(self, _):
+ self.open = False
+ self.update()
diff --git a/app/ui/components/recording_card.py b/app/ui/components/recording_card.py
new file mode 100644
index 0000000..5158883
--- /dev/null
+++ b/app/ui/components/recording_card.py
@@ -0,0 +1,320 @@
+import asyncio
+import os.path
+from functools import partial
+
+import flet as ft
+
+from ...models.recording_model import Recording
+from ...models.recording_status_model import RecordingStatus
+from ...utils import utils
+from .card_dialog import CardDialog
+from .recording_dialog import RecordingDialog
+
+
+class RecordingCardManager:
+ def __init__(self, app):
+ self.app = app
+ self.cards_obj = {}
+ self.update_duration_tasks = {}
+ self.is_card_selected = {}
+ self.app.language_manager.add_observer(self)
+ self._ = {}
+ self.load()
+
+ def load(self):
+ language = self.app.language_manager.language
+ for key in ("recording_card", "recording_manager", "base", "home_page", "video_quality"):
+ self._.update(language.get(key, {}))
+
+ async def create_card(self, recording: Recording):
+ """Create a card for a given recording."""
+ rec_id = recording.rec_id
+ if not self.cards_obj.get(rec_id):
+ if self.app.recording_enabled:
+ self.app.page.run_task(self.app.record_manager.check_if_live, recording)
+ else:
+ recording.status_info = RecordingStatus.NOT_RECORDING_SPACE
+ card_data = self._create_card_components(recording)
+ self.cards_obj[rec_id] = card_data
+ self.start_update_task(recording)
+ return card_data["card"]
+
+ def _create_card_components(self, recording: Recording):
+ """create card components."""
+ speed = recording.speed
+ duration_text_label = ft.Text(self.app.record_manager.get_duration(recording), size=12)
+
+ record_button = ft.IconButton(
+ icon=self.get_icon_for_recording_state(recording),
+ tooltip=self.get_tip_for_recording_state(recording),
+ on_click=partial(self.recording_button_on_click, recording=recording),
+ )
+
+ edit_button = ft.IconButton(
+ icon=ft.Icons.EDIT,
+ tooltip=self._["edit_record_config"],
+ on_click=partial(self.edit_recording_button_click, recording=recording),
+ )
+
+ monitor_button = ft.IconButton(
+ icon=self.get_icon_for_monitor_state(recording),
+ tooltip=self.get_tip_for_monitor_state(recording),
+ on_click=partial(self.monitor_button_on_click, recording=recording),
+ )
+
+ delete_button = ft.IconButton(
+ icon=ft.Icons.DELETE,
+ tooltip=self._["delete_monitor"],
+ on_click=partial(self.recording_delete_button_click, recording=recording),
+ )
+
+ if recording.monitor_status:
+ display_title = recording.title
+ else:
+ display_title = f"[{self._['monitor_stopped']}] {recording.title}"
+ display_title_label = ft.Text(display_title, size=14, selectable=True)
+ open_folder_button = ft.IconButton(
+ icon=ft.Icons.FOLDER,
+ tooltip=self._["open_folder"],
+ on_click=partial(self.recording_dir_button_on_click, recording=recording),
+ )
+ recording_info_button = ft.IconButton(
+ icon=ft.Icons.INFO,
+ tooltip=self._["recording_info"],
+ on_click=partial(self.recording_info_button_on_click, recording=recording),
+ )
+ speed_text_label = ft.Text(speed, size=12)
+
+ card_container = ft.Container(
+ content=ft.Column(
+ [
+ display_title_label,
+ duration_text_label,
+ speed_text_label,
+ ft.Row(
+ [
+ record_button,
+ open_folder_button,
+ recording_info_button,
+ delete_button,
+ edit_button,
+ monitor_button,
+ ]
+ ),
+ ],
+ spacing=5,
+ tight=True,
+ ),
+ padding=10,
+ on_click=partial(self.recording_card_on_click, recording=recording),
+ bgcolor=None,
+ border_radius=5,
+ )
+ card = ft.Card(key=str(recording.rec_id), content=card_container)
+
+ return {
+ "card": card,
+ "display_title_label": display_title_label,
+ "duration_label": duration_text_label,
+ "speed_label": speed_text_label,
+ "record_button": record_button,
+ "open_folder_button": open_folder_button,
+ "recording_info_button": recording_info_button,
+ "edit_button": edit_button,
+ "monitor_button": monitor_button,
+ }
+
+ async def update_cards(self, recording):
+ """Update only the recordings cards in the scrollable content area."""
+ if recording.rec_id in self.cards_obj:
+ recording_card = self.cards_obj[recording.rec_id]
+ recording_card["display_title_label"].value = recording.display_title
+ recording_card["duration_label"].value = self.app.record_manager.get_duration(recording)
+ recording_card["speed_label"].value = recording.speed
+ recording_card["record_button"].icon = self.get_icon_for_recording_state(recording)
+ recording_card["record_button"].tooltip = self.get_tip_for_recording_state(recording)
+ recording_card["monitor_button"].icon = self.get_icon_for_monitor_state(recording)
+ recording_card["monitor_button"].tooltip = self.get_tip_for_monitor_state(recording)
+ recording_card["card"].content.bgcolor = await self.update_record_hover(recording)
+ recording_card["card"].update()
+
+ async def update_monitor_state(self, recording: Recording):
+ """Update the monitor button state based on the current monitoring status."""
+ if recording.monitor_status:
+ recording.update(
+ {
+ "recording": False,
+ "monitor_status": not recording.monitor_status,
+ "status_info": RecordingStatus.STOPPED_MONITORING,
+ "display_title": f"[{self._['monitor_stopped']}] {recording.title}",
+ }
+ )
+ self.app.record_manager.stop_recording(recording)
+ self.app.page.run_task(self.app.snack_bar.show_snack_bar, self._["stop_monitor_tip"])
+ else:
+ recording.update(
+ {
+ "monitor_status": not recording.monitor_status,
+ "status_info": RecordingStatus.MONITORING,
+ "display_title": f"{recording.title}",
+ }
+ )
+ self.app.page.run_task(self.app.record_manager.check_if_live, recording)
+ self.app.page.run_task(self.app.snack_bar.show_snack_bar, self._["start_monitor_tip"], ft.Colors.GREEN)
+ await self.update_cards(recording)
+ self.app.page.run_task(self.app.record_manager.save_to_json)
+
+ async def show_recording_info_dialog(self, recording: Recording):
+ """Display a dialog with detailed information about the recording."""
+ dialog = CardDialog(self.app, recording)
+ dialog.open = True
+ self.app.dialog_area.content = dialog
+ self.app.page.update()
+
+ async def edit_recording_callback(self, recording_list: list[dict]):
+ recording = recording_list[0]
+ rec_id = recording["rec_id"]
+ recording_obj = self.app.record_manager.find_recording_by_id(rec_id)
+ await self.app.record_manager.update_recording(recording_obj, updated_info=recording)
+ if not recording["monitor_status"]:
+ recording_obj.display_title = f"[{self._['monitor_stopped']}] " + recording_obj.title
+ await self.update_cards(recording_obj)
+
+ async def on_toggle_recording(self, recording: Recording):
+ """Toggle the recording state for a specific recording."""
+ if recording and self.app.recording_enabled:
+ if recording.recording:
+ self.app.record_manager.stop_recording(recording)
+ await self.app.snack_bar.show_snack_bar(self._["stop_record_tip"])
+ else:
+ if recording.monitor_status:
+ await self.app.record_manager.check_if_live(recording)
+ if recording.is_live:
+ self.app.record_manager.start_update(recording)
+ await self.app.snack_bar.show_snack_bar(self._["pre_record_tip"], bgcolor=ft.Colors.GREEN)
+ else:
+ await self.app.snack_bar.show_snack_bar(self._["is_not_live_tip"])
+ else:
+ await self.app.snack_bar.show_snack_bar(self._["please_start_monitor_tip"])
+
+ await self.update_cards(recording)
+
+ async def on_delete_recording(self, recording: Recording):
+ """Delete a recording from the list and update UI."""
+ if recording:
+ if recording.recording:
+ await self.app.snack_bar.show_snack_bar(self._["please_stop_monitor_tip"])
+ return
+ await self.app.record_manager.delete_recording_cards([recording])
+ await self.app.snack_bar.show_snack_bar(
+ self._["delete_recording_success_tip"], bgcolor=ft.Colors.GREEN, duration=2000
+ )
+
+ async def remove_recording_card(self, recordings: list[Recording]):
+ home_page = self.app.current_page
+ for recording in recordings:
+ if recording.rec_id in self.cards_obj:
+ card = self.cards_obj[recording.rec_id]["card"]
+ home_page.recording_card_area.controls.remove(card)
+ del self.cards_obj[recording.rec_id]
+ home_page.recording_card_area.update()
+
+ @staticmethod
+ async def update_record_hover(recording: Recording):
+ return ft.Colors.GREY_400 if recording.selected else None
+
+ @staticmethod
+ def get_icon_for_recording_state(recording: Recording):
+ """Return the appropriate icon based on the recording's state."""
+ return ft.Icons.PLAY_CIRCLE if not recording.recording else ft.Icons.STOP_CIRCLE
+
+ def get_tip_for_recording_state(self, recording: Recording):
+ return self._["stop_record"] if recording.recording else self._["start_record"]
+
+ @staticmethod
+ def get_icon_for_monitor_state(recording: Recording):
+ """Return the appropriate icon based on the monitor's state."""
+ return ft.Icons.VISIBILITY if recording.monitor_status else ft.Icons.VISIBILITY_OFF
+
+ def get_tip_for_monitor_state(self, recording: Recording):
+ return self._["stop_monitor"] if recording.monitor_status else self._["start_monitor"]
+
+ async def update_duration(self, recording: Recording):
+ """Update the duration text periodically."""
+ while True:
+ await asyncio.sleep(1) # Update every second
+ if not recording or recording.rec_id not in self.cards_obj: # Stop task if card is removed
+ break
+
+ if recording.recording:
+ duration_label = self.cards_obj[recording.rec_id]["duration_label"]
+ duration_label.value = self.app.record_manager.get_duration(recording)
+ duration_label.update()
+
+ def start_update_task(self, recording: Recording):
+ """Start a background task to update the duration text."""
+ self.update_duration_tasks[recording.rec_id] = self.app.page.run_task(self.update_duration, recording)
+
+ async def on_card_click(self, recording: Recording):
+ """Handle card click events."""
+ recording.selected = not recording.selected
+ self.cards_obj[recording.rec_id]["card"].content.bgcolor = await self.update_record_hover(recording)
+ self.cards_obj[recording.rec_id]["card"].update()
+
+ async def recording_dir_on_click(self, recording: Recording):
+ if recording.recording_dir:
+ if os.path.exists(recording.recording_dir):
+ utils.open_folder(recording.recording_dir)
+ else:
+ await self.app.snack_bar.show_snack_bar(self._["no_folder_tip"])
+
+ async def edit_recording_button_click(self, _, recording: Recording):
+ """Handle edit button click by showing the edit dialog with existing recording info."""
+
+ if recording.recording or recording.monitor_status:
+ await self.app.snack_bar.show_snack_bar(self._["please_stop_monitor_tip"])
+ return
+
+ await RecordingDialog(
+ self.app,
+ on_confirm_callback=self.edit_recording_callback,
+ recording=recording,
+ ).show_dialog()
+
+ async def recording_delete_button_click(self, _, recording: Recording):
+ async def confirm_dlg(_):
+ self.app.page.run_task(self.on_delete_recording, recording)
+ await close_dialog(None)
+
+ async def close_dialog(_):
+ delete_alert_dialog.open = False
+ delete_alert_dialog.update()
+
+ delete_alert_dialog = ft.AlertDialog(
+ title=ft.Text(self._["confirm"]),
+ content=ft.Text(self._["delete_confirm_tip"]),
+ actions=[
+ ft.TextButton(text=self._["cancel"], on_click=close_dialog),
+ ft.TextButton(text=self._["sure"], on_click=confirm_dlg),
+ ],
+ actions_alignment=ft.MainAxisAlignment.END,
+ modal=False,
+ )
+ delete_alert_dialog.open = True
+ self.app.dialog_area.content = delete_alert_dialog
+ self.app.page.update()
+
+ async def recording_button_on_click(self, _, recording: Recording):
+ await self.on_toggle_recording(recording)
+
+ async def recording_dir_button_on_click(self, _, recording: Recording):
+ await self.recording_dir_on_click(recording)
+
+ async def recording_info_button_on_click(self, _, recording: Recording):
+ await self.show_recording_info_dialog(recording)
+
+ async def monitor_button_on_click(self, _, recording: Recording):
+ await self.update_monitor_state(recording)
+
+ async def recording_card_on_click(self, _, recording: Recording):
+ await self.on_card_click(recording)
diff --git a/app/ui/components/recording_dialog.py b/app/ui/components/recording_dialog.py
new file mode 100644
index 0000000..5bf893d
--- /dev/null
+++ b/app/ui/components/recording_dialog.py
@@ -0,0 +1,359 @@
+import flet as ft
+
+from ...core.platform_handlers import get_platform_info
+from ...models.video_format_model import VideoFormat
+from ...models.video_quality_model import VideoQuality
+from ...utils import utils
+from ...utils.logger import logger
+
+
+class RecordingDialog:
+ def __init__(self, app, on_confirm_callback=None, recording=None):
+ self.app = app
+ self.page = self.app.page
+ self.on_confirm_callback = on_confirm_callback
+ self.recording = recording
+ self.app.language_manager.add_observer(self)
+ self._ = {}
+ self.load()
+
+ def load(self):
+ language = self.app.language_manager.language
+ for key in ("recording_dialog", "home_page", "base", "video_quality"):
+ self._.update(language.get(key, {}))
+
+ async def show_dialog(self):
+ """Show a dialog for adding or editing a recording."""
+ initial_values = self.recording.to_dict() if self.recording else {}
+
+ async def on_url_change(_):
+ """Enable or disable the submit button based on whether the URL field is filled."""
+ is_active = utils.is_valid_url(url_field.value.strip()) or utils.contains_url(batch_input.value.strip())
+ dialog.actions[1].disabled = not is_active
+ self.page.update()
+
+ url_field = ft.TextField(
+ label=self._["input_live_link"],
+ hint_text=self._["example"] + ":https://www.example.com/xxxxxx",
+ border_radius=5,
+ filled=False,
+ value=initial_values.get("url"),
+ on_change=on_url_change,
+ )
+ quality_dropdown = ft.Dropdown(
+ label=self._["select_resolution"],
+ options=[ft.dropdown.Option(i, text=self._[i]) for i in VideoQuality.get_qualities()],
+ border_radius=5,
+ filled=False,
+ value=initial_values.get("quality", VideoQuality.OD),
+ width=500,
+ )
+ streamer_name_field = ft.TextField(
+ label=self._["input_anchor_name"],
+ hint_text=self._["default_input"],
+ border_radius=5,
+ filled=False,
+ value=initial_values.get("streamer_name", ""),
+ )
+ record_format_field = ft.Dropdown(
+ label=self._["input_record_format"],
+ options=[ft.dropdown.Option(i) for i in VideoFormat.get_formats()],
+ border_radius=5,
+ filled=False,
+ value=initial_values.get("record_format", VideoFormat.TS),
+ width=500,
+ )
+
+ recording_dir_field = ft.TextField(
+ label=self._["input_save_path"],
+ hint_text=self._["default_input"],
+ border_radius=5,
+ filled=False,
+ value=initial_values.get("recording_dir"),
+ )
+
+ user_config = self.app.settings.user_config
+ segmented_recording_enabled = user_config.get('segmented_recording_enabled', False)
+ video_segment_time = user_config.get('video_segment_time', 1800)
+ segment_record = initial_values.get("segment_record", segmented_recording_enabled)
+ segment_time = initial_values.get("segment_time", video_segment_time)
+
+ async def on_segment_setting_change(e):
+ selected_value = e.control.value
+ segment_input.visible = selected_value == self._["yes"]
+ self.page.update()
+
+ segment_setting_dropdown = ft.Dropdown(
+ label=self._["is_segment_enabled"],
+ options=[
+ ft.dropdown.Option(self._["yes"]),
+ ft.dropdown.Option(self._["no"]),
+ ],
+ border_radius=5,
+ filled=False,
+ value=self._["yes"] if segment_record else self._["no"],
+ on_change=on_segment_setting_change,
+ width=500,
+ )
+
+ segment_input = ft.TextField(
+ label=self._["segment_record_time"],
+ hint_text=self._["input_segment_time"],
+ border_radius=5,
+ filled=False,
+ value=segment_time,
+ visible=segment_record,
+ )
+
+ scheduled_recording = initial_values.get("scheduled_recording", False)
+ scheduled_start_time = initial_values.get("scheduled_start_time")
+ monitor_hours = initial_values.get("monitor_hours", 5)
+
+ async def on_scheduled_setting_change(e):
+ selected_value = e.control.value
+ schedule_and_monitor_row.visible = selected_value == "true"
+ monitor_hours_input.visible = selected_value == "true"
+ self.page.update()
+
+ async def pick_time(_):
+ async def handle_change(_):
+ scheduled_start_time_input.value = time_picker.value
+ scheduled_start_time_input.update()
+
+ time_picker = ft.TimePicker(
+ confirm_text=self._['confirm'],
+ cancel_text=self._['cancel'],
+ error_invalid_text=self._['time_out_of_range'],
+ help_text=self._['pick_time_slot'],
+ hour_label_text=self._['hour_label_text'],
+ minute_label_text=self._['minute_label_text'],
+ on_change=handle_change
+ )
+ self.page.open(time_picker)
+
+ scheduled_setting_dropdown = ft.Dropdown(
+ label=self._["scheduled_recording"],
+ options=[
+ ft.dropdown.Option("true", self._["yes"]),
+ ft.dropdown.Option("false", self._["no"]),
+ ],
+ border_radius=5,
+ filled=False,
+ value="true" if scheduled_recording else "false",
+ on_change=on_scheduled_setting_change,
+ width=500,
+ )
+
+ scheduled_start_time_input = ft.TextField(
+ label=self._["scheduled_start_time"],
+ hint_text=self._["example"] + ":18:30:00",
+ border_radius=5,
+ filled=False,
+ value=scheduled_start_time,
+ )
+
+ time_picker_button = ft.ElevatedButton(
+ self._['pick_time'],
+ icon=ft.Icons.TIME_TO_LEAVE,
+ on_click=pick_time,
+ tooltip=self._['pick_time_tip']
+ )
+
+ schedule_and_monitor_row = ft.Row(
+ [
+ ft.Container(content=scheduled_start_time_input, expand=True),
+ ft.Container(content=time_picker_button),
+ ],
+ spacing=10,
+ visible=scheduled_recording,
+ )
+
+ monitor_hours_input = ft.TextField(
+ label=self._["monitor_hours"],
+ hint_text=self._["example"] + ":5",
+ border_radius=5,
+ filled=False,
+ value=monitor_hours,
+ keyboard_type=ft.KeyboardType.NUMBER,
+ visible=scheduled_recording,
+ )
+ hint_text_dict = {
+ "en": "Example:\n0,https://v.douyin.com/AbcdE,nickname1\n0,https://v.douyin.com/EfghI,nickname2\n\nPS: "
+ "0=original image or Blu ray, 1=ultra clear, 2=high-definition, 3=standard definition, 4=smooth\n",
+ "zh_CN": "示例:\n0,https://v.douyin.com/AbcdE,主播名1\n0,https://v.douyin.com/EfghI,主播名2"
+ "\n\n其中0=原画或者蓝光,1=超清,2=高清,3=标清,4=流畅",
+ }
+
+ # Batch input field
+ batch_input = ft.TextField(
+ label=self._["batch_input_tip"],
+ multiline=True,
+ min_lines=10,
+ max_lines=15,
+ border_radius=5,
+ filled=False,
+ visible=True,
+ hint_style=ft.TextStyle(
+ size=14,
+ color=ft.Colors.GREY_500,
+ font_family="Arial",
+ ),
+ on_change=on_url_change,
+ hint_text=hint_text_dict.get(self.app.language_code, hint_text_dict["zh_CN"]),
+ )
+
+ tabs = ft.Tabs(
+ selected_index=0,
+ animation_duration=300,
+ tabs=[
+ ft.Tab(
+ text=self._["single_input"],
+ content=ft.Container(
+ content=ft.Column(
+ [
+ ft.Container(margin=ft.margin.only(top=10)),
+ url_field,
+ streamer_name_field,
+ record_format_field,
+ quality_dropdown,
+ recording_dir_field,
+ segment_setting_dropdown,
+ segment_input,
+ scheduled_setting_dropdown,
+ schedule_and_monitor_row,
+ monitor_hours_input,
+ ],
+ tight=True,
+ spacing=10,
+ scroll=ft.ScrollMode.AUTO,
+ )
+ ),
+ ),
+ ft.Tab(
+ text=self._["batch_input"], content=ft.Container(content=batch_input, margin=ft.margin.only(top=15))
+ ),
+ ],
+ )
+
+ async def not_supported(url):
+ logger.warning(f"This platform does not support recording: {url}")
+ await self.app.snack_bar.show_snack_bar(self._["platform_not_supported_tip"], duration=3000)
+
+ async def on_confirm(e):
+ if tabs.selected_index == 0:
+ quality_info = self._[quality_dropdown.value]
+
+ if not streamer_name_field.value:
+ anchor_name = self._["live_room"]
+ title = f"{anchor_name} - {quality_info}"
+ else:
+ anchor_name = streamer_name_field.value.strip()
+ title = f"{anchor_name} - {quality_info}"
+
+ display_title = title
+ rec_id = self.recording.rec_id if self.recording else None
+ live_url = url_field.value.strip()
+ platform, platform_key = get_platform_info(live_url)
+ if not platform:
+ await not_supported(url_field.value)
+ await close_dialog(e)
+ return
+
+ recordings_info = [
+ {
+ "rec_id": rec_id,
+ "url": live_url,
+ "streamer_name": anchor_name,
+ "record_format": record_format_field.value,
+ "quality": quality_dropdown.value,
+ "quality_info": quality_info,
+ "title": title,
+ "speed": "X KB/s",
+ "segment_record": segment_input.visible,
+ "segment_time": segment_input.value,
+ "monitor_status": initial_values.get("monitor_status", True),
+ "display_title": display_title,
+ "scheduled_recording": schedule_and_monitor_row.visible,
+ "scheduled_start_time": str(scheduled_start_time_input.value),
+ "monitor_hours": monitor_hours_input.value,
+ "recording_dir": recording_dir_field.value,
+ }
+ ]
+ await self.on_confirm_callback(recordings_info)
+
+ elif tabs.selected_index == 1: # Batch entry
+ lines = batch_input.value.splitlines()
+ recordings_info = []
+ streamer_name = ""
+ quality = "OD"
+ quality_dict = {"0": "OD", "1": "UHD", "2": "HD", "3": "SD", "4": "LD"}
+ for line in lines:
+ if "http" not in line:
+ continue
+ res = [i for i in line.strip().replace(",", ",").split(",") if i]
+ if len(res) == 3:
+ quality, url, streamer_name = res
+ elif len(res) == 2:
+ if res[1].startswith("http"):
+ quality, url = res
+ else:
+ url, streamer_name = res
+ else:
+ url = res[0]
+
+ platform, platform_key = get_platform_info(url)
+ if not platform:
+ await not_supported(url)
+ continue
+
+ quality = quality_dict.get(quality, "OD")
+ title = f"{streamer_name} - {self._[quality]}"
+ display_title = title
+ if not streamer_name:
+ streamer_name = self._["live_room"]
+ display_title = streamer_name + url.split("?")[0] + "... - " + self._[quality]
+
+ recording_info = {
+ "url": url.strip(),
+ "streamer_name": streamer_name,
+ "quality": quality,
+ "quality_info": self._[VideoQuality.OD],
+ "title": title,
+ "display_title": display_title,
+ }
+ recordings_info.append(recording_info)
+
+ await self.on_confirm_callback(recordings_info)
+
+ await close_dialog(e)
+
+ async def close_dialog(_):
+ dialog.open = False
+ self.page.update()
+
+ close_button = ft.IconButton(icon=ft.Icons.CLOSE, tooltip=self._["close"], on_click=close_dialog)
+
+ title_text = self._["edit_record"] if self.recording else self._["add_record"]
+ dialog = ft.AlertDialog(
+ open=True,
+ modal=True,
+ title=ft.Row(
+ [
+ ft.Text(title_text, size=16, theme_style=ft.TextThemeStyle.TITLE_LARGE),
+ ft.Container(width=10),
+ close_button,
+ ],
+ alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
+ width=500,
+ ),
+ content=tabs,
+ actions=[
+ ft.TextButton(text=self._["cancel"], on_click=close_dialog),
+ ft.TextButton(text=self._["sure"], on_click=on_confirm, disabled=self.recording is None),
+ ],
+ actions_alignment=ft.MainAxisAlignment.END,
+ shape=ft.RoundedRectangleBorder(radius=10),
+ )
+
+ self.page.overlay.append(dialog)
+ self.page.update()
diff --git a/app/ui/components/search_dialog.py b/app/ui/components/search_dialog.py
new file mode 100644
index 0000000..ac814f9
--- /dev/null
+++ b/app/ui/components/search_dialog.py
@@ -0,0 +1,57 @@
+import flet as ft
+
+
+class SearchDialog(ft.AlertDialog):
+ def __init__(self, home_page, on_close=None):
+ self.home_page = home_page
+ self._ = {}
+ self.load()
+
+ super().__init__(
+ title=ft.Text(self._["search"], size=20, weight=ft.FontWeight.BOLD),
+ content_padding=ft.padding.only(left=20, top=15, right=20, bottom=20),
+ )
+ self.query = ft.TextField(
+ hint_text=self._["search_keyword"],
+ expand=True,
+ border_radius=5,
+ border_color=ft.Colors.GREY_400,
+ focused_border_color=ft.Colors.BLUE,
+ cursor_color=ft.Colors.BLACK,
+ hint_style=ft.TextStyle(color=ft.Colors.GREY_500, size=14),
+ text_style=ft.TextStyle(size=16, color=ft.Colors.BLACK),
+ )
+ self.actions = [
+ ft.TextButton(
+ self._["cancel"],
+ icon=ft.Icons.CLOSE,
+ style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=5)),
+ on_click=self.close_dlg,
+ ),
+ ft.TextButton(
+ self._["sure"],
+ icon=ft.Icons.SEARCH,
+ style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=5)),
+ on_click=self.submit_query,
+ ),
+ ]
+ self.content = ft.Column(
+ [self.query, ft.Divider(height=1, thickness=1, color=ft.Colors.GREY_300)], tight=True, width=400
+ )
+ self.actions_alignment = ft.MainAxisAlignment.END
+ self.on_close = on_close
+ self.home_page.app.language_manager.add_observer(self)
+
+ def load(self):
+ language = self.home_page.app.language_manager.language
+ for key in ("search_dialog", "home_page", "base"):
+ self._.update(language.get(key, {}))
+
+ async def close_dlg(self, _e):
+ self.open = False
+ self.update()
+
+ async def submit_query(self, e):
+ query = self.query.value.strip()
+ await self.home_page.filter_recordings(query)
+ await self.close_dlg(e)
diff --git a/app/ui/components/show_snackbar.py b/app/ui/components/show_snackbar.py
new file mode 100644
index 0000000..08b240a
--- /dev/null
+++ b/app/ui/components/show_snackbar.py
@@ -0,0 +1,25 @@
+import flet as ft
+
+
+class ShowSnackBar:
+ def __init__(self, page):
+ self.page = page
+
+ async def show_snack_bar(self, message, bgcolor=None, duration=1500, action=None, emoji=None,
+ show_close_icon=False):
+ """Helper method to show a snack bar with optional emoji."""
+ formatted_message = f"{emoji} {message}" if emoji else message
+
+ snack_bar = ft.SnackBar(
+ content=ft.Text(formatted_message),
+ behavior=ft.SnackBarBehavior.FLOATING,
+ action=action,
+ bgcolor=bgcolor,
+ duration=duration,
+ margin=ft.margin.only(left=self.page.width - 300, top=0, right=10, bottom=10),
+ show_close_icon=show_close_icon
+ )
+
+ snack_bar.open = True
+ self.page.snack_bar_area.content = snack_bar
+ self.page.update()
diff --git a/app/ui/navigation/__init__.py b/app/ui/navigation/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/ui/navigation/sidebar.py b/app/ui/navigation/sidebar.py
new file mode 100644
index 0000000..b938f53
--- /dev/null
+++ b/app/ui/navigation/sidebar.py
@@ -0,0 +1,183 @@
+import flet as ft
+
+from ..themes import PopupColorItem, ThemeManager
+
+
+class ControlGroup:
+ def __init__(self, icon, label, index, name, selected_icon):
+ self.icon = icon
+ self.label = label
+ self.index = index
+ self.name = name
+ self.selected_icon = selected_icon
+
+
+class NavigationItem(ft.Container):
+ def __init__(self, destination, item_clicked):
+ super().__init__()
+ self.ink = True
+ self.padding = 10
+ self.border_radius = 5
+ self.destination = destination
+ self.icon = destination.icon
+ self.text = destination.label
+ self.content = ft.Row([ft.Icon(self.icon), ft.Text(self.text)])
+ self.on_click = lambda e: item_clicked(e)
+
+
+class NavigationColumn(ft.Column):
+ def __init__(self, sidebar, page, app):
+ super().__init__()
+ self.expand = 4
+ self.spacing = 0
+ self.scroll = ft.ScrollMode.ALWAYS
+ self.sidebar = sidebar
+ self.selected_index = 0
+ self.page = page
+ self.app = app
+ self.controls = self.get_navigation_items()
+
+ def get_navigation_items(self):
+ return [
+ NavigationItem(destination, item_clicked=self.item_clicked) for destination in self.sidebar.control_groups
+ ]
+
+ def item_clicked(self, e):
+ self.selected_index = e.control.destination.index
+ self.update_selected_item()
+ self.page.go(f"/{e.control.destination.name}")
+
+ def update_selected_item(self):
+ for item in self.controls:
+ item.bgcolor = None
+ item.content.controls[0].icon = item.destination.icon
+ if 0 <= self.selected_index < len(self.controls):
+ self.controls[self.selected_index].bgcolor = ft.Colors.SECONDARY_CONTAINER
+ self.controls[self.selected_index].content.controls[0].icon = self.controls[
+ self.selected_index
+ ].destination.selected_icon
+
+
+class LeftNavigationMenu(ft.Column):
+ def __init__(self, app):
+ super().__init__()
+ self.app = app
+ self.sidebar = app.sidebar
+ self.page = app.page
+ self.rail = None
+ self.dark_light_text = None
+ self.dark_light_icon = None
+ self.bottom_controls = None
+ self.first_run = True
+ self.theme_manager = ThemeManager(self.app)
+ self.app.language_manager.add_observer(self)
+ self._ = {}
+ self.load()
+
+ def load(self):
+ self._ = self.app.language_manager.language.get("sidebar")
+ self.rail = NavigationColumn(sidebar=self.sidebar, page=self.page, app=self.app)
+
+ self.dark_light_text = ft.Text(self._["light_theme"])
+ self.dark_light_icon = ft.IconButton(
+ icon=ft.Icons.BRIGHTNESS_2_OUTLINED,
+ tooltip=self._["toggle_night_theme"],
+ on_click=self.theme_changed,
+ )
+
+ colors_list = [
+ ("deeppurple", "Deep purple"),
+ ("purple", "Purple"),
+ ("indigo", "Indigo"),
+ ("blue", "Blue"),
+ ("teal", "Teal"),
+ ("deeporange", "Deep orange"),
+ ("orange", "Orange"),
+ ("pink", "Pink"),
+ ("brown", "Brown"),
+ ("bluegrey", "Blue Grey"),
+ ("green", "Green"),
+ ("cyan", "Cyan"),
+ ("lightblue", "Light Blue"),
+ ("", "Default"),
+ ]
+
+ self.bottom_controls = ft.Column(
+ controls=[
+ ft.Row(
+ controls=[
+ self.dark_light_icon,
+ self.dark_light_text,
+ ],
+ alignment=ft.MainAxisAlignment.START,
+ ),
+ ft.Row(
+ controls=[
+ ft.PopupMenuButton(
+ icon=ft.Icons.COLOR_LENS_OUTLINED,
+ tooltip=self._["colors"],
+ items=[PopupColorItem(color=color, name=name) for color, name in colors_list],
+ ),
+ ft.Text(self._["theme_color"]),
+ ],
+ alignment=ft.MainAxisAlignment.START,
+ ),
+ ],
+ alignment=ft.MainAxisAlignment.END,
+ )
+
+ self.controls = [
+ self.rail,
+ ft.Container(expand=True),
+ self.bottom_controls,
+ ]
+
+ self.width = 160
+ self.spacing = 0
+ self.alignment = ft.MainAxisAlignment.START
+
+ async def theme_changed(self, _):
+ page = self.app.page
+ self._ = self.app.language_manager.language.get("sidebar")
+ if page.theme_mode == ft.ThemeMode.LIGHT:
+ page.theme_mode = ft.ThemeMode.DARK
+ self.dark_light_text.value = self._["dark_theme"]
+ self.dark_light_icon.icon = ft.Icons.BRIGHTNESS_HIGH_OUTLINED
+ self.dark_light_icon.tooltip = self._["toggle_day_theme"]
+ else:
+ page.theme_mode = ft.ThemeMode.LIGHT
+ self.dark_light_text.value = self._["light_theme"]
+ self.dark_light_icon.icon = ft.Icons.BRIGHTNESS_2_OUTLINED
+ self.dark_light_icon.tooltip = self._["toggle_night_theme"]
+ await self.on_theme_change()
+ page.update()
+
+ async def on_theme_change(self):
+ """When the theme changes, recreate the content and update the page"""
+ if self.app.current_page.page_name == "about":
+ await self.app.current_page.load()
+
+
+class NavigationSidebar:
+ def __init__(self, app):
+ self.app = app
+ self.control_groups = []
+ self.selected_control_group = None
+ self.app.language_manager.add_observer(self)
+ self._ = {}
+ self.load()
+
+ def load(self):
+ self._ = self.app.language_manager.language.get("sidebar")
+ self.control_groups = [
+ ControlGroup(icon=ft.Icons.HOME, label=self._["home"], index=0, name="home", selected_icon=ft.Icons.HOME),
+ ControlGroup(
+ icon=ft.Icons.SETTINGS,
+ label=self._["settings"],
+ index=1,
+ name="settings",
+ selected_icon=ft.Icons.SETTINGS,
+ ),
+ ControlGroup(icon=ft.Icons.INFO, label=self._["about"], index=2, name="about", selected_icon=ft.Icons.INFO),
+ ]
+ self.selected_control_group = self.control_groups[0]
diff --git a/app/ui/themes/__init__.py b/app/ui/themes/__init__.py
new file mode 100644
index 0000000..946e519
--- /dev/null
+++ b/app/ui/themes/__init__.py
@@ -0,0 +1,4 @@
+from .theme import PopupColorItem
+from .theme_manager import ThemeManager
+
+__all__ = ["PopupColorItem", "ThemeManager"]
diff --git a/app/ui/themes/theme.py b/app/ui/themes/theme.py
new file mode 100644
index 0000000..879e035
--- /dev/null
+++ b/app/ui/themes/theme.py
@@ -0,0 +1,71 @@
+import flet as ft
+
+
+class PopupColorItem(ft.PopupMenuItem):
+ def __init__(self, color, name):
+ super().__init__()
+ self.content = ft.Row(
+ controls=[
+ ft.Icon(name=ft.Icons.COLOR_LENS_OUTLINED, color=color),
+ ft.Text(name),
+ ],
+ )
+ self.on_click = lambda e: self.seed_color_changed(e)
+ self.data = color
+
+ def seed_color_changed(self, e):
+ page = e.page
+ page.theme.color_scheme_seed = self.data
+ page.theme.color_scheme = ft.ColorScheme(primary=self.data)
+ page.update()
+ self.save_theme_color(e)
+
+ def save_theme_color(self, e):
+ page = e.page
+ page.client_storage.set("theme_color", self.data)
+
+
+def create_light_theme(custom_font: str) -> ft.Theme:
+ """Define light colored theme"""
+ return ft.Theme(
+ font_family=custom_font,
+ text_theme=ft.TextTheme(
+ body_medium=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font),
+ body_large=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font),
+ display_small=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font),
+ display_medium=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font),
+ display_large=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font),
+ headline_small=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font),
+ headline_medium=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font),
+ headline_large=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font),
+ title_small=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font),
+ title_medium=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font),
+ title_large=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font),
+ label_small=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font),
+ label_medium=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font),
+ label_large=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font),
+ ),
+ )
+
+
+def create_dark_theme(custom_font: str) -> ft.Theme:
+ """Define dark theme"""
+ return ft.Theme(
+ font_family=custom_font,
+ text_theme=ft.TextTheme(
+ body_medium=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font),
+ body_large=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font),
+ display_small=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font),
+ display_medium=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font),
+ display_large=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font),
+ headline_small=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font),
+ headline_medium=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font),
+ headline_large=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font),
+ title_small=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font),
+ title_medium=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font),
+ title_large=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font),
+ label_small=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font),
+ label_medium=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font),
+ label_large=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font),
+ ),
+ )
diff --git a/app/ui/themes/theme_manager.py b/app/ui/themes/theme_manager.py
new file mode 100644
index 0000000..b23c0fa
--- /dev/null
+++ b/app/ui/themes/theme_manager.py
@@ -0,0 +1,44 @@
+import sys
+
+import flet as ft
+
+from .theme import create_dark_theme, create_light_theme
+
+
+class ThemeManager:
+ def __init__(self, app):
+ self.page = app.page
+ self.custom_font = None
+ self.assets_dir = app.assets_dir
+ self.init_fonts()
+ self.apply_initial_theme()
+
+ def init_fonts(self):
+ """Initialize fonts for the application."""
+ custom_font = "AlibabaPuHuiTi Light"
+ if sys.platform == "darwin":
+ custom_font = "AlibabaPuHuiTi Light Mac"
+
+ self.page.fonts = {
+ "AlibabaPuHuiTi Light": f"{self.assets_dir}/fonts/AlibabaPuHuiTi-2/AlibabaPuHuiTi-2-45-Light.otf",
+ "AlibabaPuHuiTi Light Mac": f"{self.assets_dir}/fonts/AlibabaPuHuiTi-2/AlibabaPuHuiTi-2-45-Light-Mac.otf",
+ }
+ self.custom_font = custom_font
+
+ def apply_initial_theme(self):
+ """Apply initial theme based on saved settings or default to light theme."""
+ self.page.theme = create_light_theme(self.custom_font)
+ self.page.dark_theme = create_dark_theme(self.custom_font)
+
+ theme_color = self.page.client_storage.get("theme_color")
+ if theme_color is not None:
+ self.update_theme_color(theme_color)
+ else:
+ self.update_theme_color("blue")
+
+ def update_theme_color(self, color):
+ """Update the current theme color scheme and save it."""
+ self.page.theme.color_scheme_seed = color
+ self.page.theme.color_scheme = ft.ColorScheme(primary=color)
+ self.page.update()
+ self.page.client_storage.set("theme_color", color)
diff --git a/app/ui/views/__init__.py b/app/ui/views/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/ui/views/about_view.py b/app/ui/views/about_view.py
new file mode 100644
index 0000000..70f4378
--- /dev/null
+++ b/app/ui/views/about_view.py
@@ -0,0 +1,248 @@
+import webbrowser
+
+import flet as ft
+
+from ..base_page import PageBase
+from ..components.help_dialog import HelpDialog
+
+
+class AboutPage(PageBase):
+ def __init__(self, app):
+ super().__init__(app)
+ self.page_name = "about"
+ self._about_config = {}
+ self.app.language_manager.add_observer(self)
+ self.load_language()
+ self.page.on_keyboard_event = self.on_keyboard
+
+ def load_language(self):
+ self._ = self.app.language_manager.language.get("about_page")
+ self._about_config = self.app.config_manager.load_about_config()
+
+ async def load(self):
+ """Load the about page content."""
+ self.content_area.clean()
+
+ # Dynamically set colors based on the current theme mode
+ theme_mode = self.page.theme_mode
+ if theme_mode == ft.ThemeMode.DARK:
+ card_bg_color = None
+ text_color = None
+ text_color_500 = None
+ text_color_600 = None
+ text_color_700 = None
+ else:
+ card_bg_color = ft.Colors.WHITE
+ text_color = ft.Colors.GREY_800
+ text_color_500 = ft.Colors.GREY_500
+ text_color_600 = ft.Colors.GREY_600
+ text_color_700 = ft.Colors.GREY_700
+
+ language_code = self.app.language_code
+ version_updates = self._about_config["version_updates"][0]
+ open_source_license = self._about_config["open_source_license"]
+
+ about_page_layout = ft.Container(
+ content=ft.Column(
+ scroll=ft.ScrollMode.AUTO,
+ controls=[
+ # Title and version information
+ ft.Text(
+ self._["about_project"],
+ size=32,
+ weight=ft.FontWeight.BOLD,
+ text_align=ft.TextAlign.CENTER,
+ color=ft.Colors.PRIMARY,
+ ),
+ ft.Text(
+ f"{self._['ui_version']}: {version_updates['version']} | "
+ f"{self._['kernel_version']}: {version_updates['kernel_version']} | "
+ f"{self._['license']}: {open_source_license}",
+ size=14,
+ text_align=ft.TextAlign.CENTER,
+ color=text_color_500,
+ ),
+ # Software Introduction Card
+ ft.Container(
+ content=ft.Column(
+ controls=[
+ ft.Text(self._["introduction"], size=20, weight=ft.FontWeight.W_600, color=text_color),
+ ft.Text(
+ self._about_config["introduction"].get(language_code),
+ size=16,
+ text_align=ft.TextAlign.JUSTIFY,
+ color=text_color_600,
+ ),
+ ],
+ spacing=15,
+ expand=True,
+ ),
+ padding=20,
+ margin=ft.margin.symmetric(0, 10),
+ bgcolor=card_bg_color,
+ border_radius=15,
+ shadow=ft.BoxShadow(
+ spread_radius=1,
+ blur_radius=10,
+ color=ft.Colors.BLACK26,
+ offset=ft.Offset(0, 4),
+ ),
+ ),
+ # Feature Highlights Card
+ ft.Container(
+ content=ft.Column(
+ controls=[
+ ft.Text(self._["feature"], size=20, weight=ft.FontWeight.W_600, color=text_color),
+ ft.Row(
+ controls=[
+ ft.Column(
+ controls=[
+ ft.Icon(ft.Icons.VIDEO_LIBRARY, color=ft.Colors.BLUE),
+ ft.Text(self._["support_platforms"], size=14, color=text_color_700),
+ ],
+ horizontal_alignment=ft.CrossAxisAlignment.CENTER,
+ ),
+ ft.Column(
+ controls=[
+ ft.Icon(ft.Icons.SETTINGS, color=ft.Colors.GREEN),
+ ft.Text(self._["customize_recording"], size=14, color=text_color_700),
+ ],
+ horizontal_alignment=ft.CrossAxisAlignment.CENTER,
+ ),
+ ft.Column(
+ controls=[
+ ft.Icon(ft.Icons.LIGHTBULB, color=ft.Colors.ORANGE),
+ ft.Text(self._["open_source"], size=14, color=text_color_700),
+ ],
+ horizontal_alignment=ft.CrossAxisAlignment.CENTER,
+ ),
+ ft.Column(
+ controls=[
+ ft.Icon(ft.Icons.AUTORENEW, color=ft.Colors.PURPLE),
+ ft.Text(self._["automatic_transcoding"], size=14, color=text_color_700),
+ ],
+ horizontal_alignment=ft.CrossAxisAlignment.CENTER,
+ ),
+ ft.Column(
+ controls=[
+ ft.Icon(ft.Icons.NOTIFICATIONS_ACTIVE, color=ft.Colors.RED),
+ ft.Text(self._["status_push"], size=14, color=text_color_700),
+ ],
+ horizontal_alignment=ft.CrossAxisAlignment.CENTER,
+ ),
+ ],
+ alignment=ft.MainAxisAlignment.CENTER,
+ spacing=100,
+ ),
+ ],
+ spacing=15,
+ expand=True,
+ ),
+ padding=20,
+ margin=10,
+ bgcolor=card_bg_color,
+ border_radius=15,
+ shadow=ft.BoxShadow(
+ spread_radius=1,
+ blur_radius=10,
+ color=ft.Colors.BLACK26,
+ offset=ft.Offset(0, 4),
+ ),
+ ),
+ # Developer Information Card
+ ft.Container(
+ content=ft.Column(
+ controls=[
+ ft.Text(self._["developer"], size=20, weight=ft.FontWeight.W_600, color=text_color),
+ ft.ListTile(
+ leading=ft.Icon(ft.Icons.PERSON, color=ft.Colors.GREY_800),
+ title=ft.Text("Hmily", size=18, weight=ft.FontWeight.W_500, color=text_color_700),
+ subtitle=ft.Text(self._["author"], size=14, color=text_color_500),
+ ),
+ ft.Row(
+ controls=[
+ ft.TextButton(
+ self._["view_update"],
+ icon=ft.Icons.CODE,
+ on_click=self.open_update_page,
+ ),
+ ft.TextButton(
+ self._["view_docs"],
+ icon=ft.Icons.DESCRIPTION,
+ on_click=self.open_dos_page,
+ ),
+ ],
+ alignment=ft.MainAxisAlignment.START,
+ ),
+ ],
+ spacing=10,
+ expand=True,
+ ),
+ padding=20,
+ margin=10,
+ bgcolor=card_bg_color,
+ border_radius=15,
+ shadow=ft.BoxShadow(
+ spread_radius=1,
+ blur_radius=10,
+ color=ft.Colors.BLACK26,
+ offset=ft.Offset(0, 4),
+ ),
+ ),
+ # Version update content card
+ ft.Container(
+ content=ft.Column(
+ controls=[
+ ft.Text(self._["update"], size=20, weight=ft.FontWeight.W_600, color=text_color),
+ ft.ListView(
+ controls=[
+ ft.Text(update, size=16, text_align=ft.TextAlign.JUSTIFY, color=text_color_600)
+ for update in version_updates["updates"][language_code]
+ ],
+ spacing=10,
+ padding=0,
+ expand=True,
+ ),
+ ],
+ spacing=15,
+ expand=True,
+ ),
+ padding=20,
+ margin=10,
+ bgcolor=card_bg_color,
+ border_radius=15,
+ shadow=ft.BoxShadow(
+ spread_radius=1,
+ blur_radius=10,
+ color=ft.Colors.BLACK26,
+ offset=ft.Offset(0, 4),
+ ),
+ ),
+ ft.Container(expand=True),
+ ],
+ alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
+ horizontal_alignment=ft.CrossAxisAlignment.CENTER,
+ expand=True,
+ ),
+ expand=True,
+ padding=20,
+ )
+
+ self.content_area.controls.append(about_page_layout)
+ self.content_area.update()
+
+ @staticmethod
+ async def open_update_page(_):
+ url = "https://github.com/ihmily/StreamCap/releases"
+ webbrowser.open(url)
+
+ @staticmethod
+ async def open_dos_page(_):
+ url = "https://github.com/ihmily/StreamCap"
+ webbrowser.open(url)
+
+ async def on_keyboard(self, e: ft.KeyboardEvent):
+ if e.alt and e.key == "H":
+ self.app.dialog_area.content = HelpDialog(self.app)
+ self.app.dialog_area.content.open = True
+ self.app.dialog_area.update()
diff --git a/app/ui/views/home_view.py b/app/ui/views/home_view.py
new file mode 100644
index 0000000..e2ef734
--- /dev/null
+++ b/app/ui/views/home_view.py
@@ -0,0 +1,258 @@
+import uuid
+
+import flet as ft
+
+from ...models.recording_model import Recording
+from ...utils.logger import logger
+from ..base_page import PageBase
+from ..components.help_dialog import HelpDialog
+from ..components.recording_dialog import RecordingDialog
+from ..components.search_dialog import SearchDialog
+
+
+class HomePage(PageBase):
+ def __init__(self, app):
+ super().__init__(app)
+ self.page_name = "home"
+ self.recording_card_area = None
+ self.add_recording_dialog = None
+ self.app.language_manager.add_observer(self)
+ self.load_language()
+ self.init()
+
+ def load_language(self):
+ language = self.app.language_manager.language
+ for key in ("home_page", "video_quality", "base"):
+ self._.update(language.get(key, {}))
+
+ def init(self):
+ self.recording_card_area = ft.Column(controls=[], spacing=10, expand=True)
+ self.add_recording_dialog = RecordingDialog(self.app, self.add_recording)
+
+ async def load(self):
+ """Load the home page content."""
+ self.content_area.controls.extend([self.create_home_title_area(), self.create_home_content_area()])
+ self.content_area.update()
+ await self.add_record_cards()
+ self.page.run_task(self.show_all_cards)
+ self.page.on_keyboard_event = self.on_keyboard
+
+ def create_home_title_area(self):
+ return ft.Row(
+ [
+ ft.Text(self._["recording_list"], theme_style=ft.TextThemeStyle.TITLE_MEDIUM),
+ ft.Container(expand=True),
+ ft.IconButton(icon=ft.Icons.SEARCH, tooltip=self._["search"], on_click=self.search_on_click),
+ ft.IconButton(icon=ft.Icons.ADD, tooltip=self._["add_record"], on_click=self.add_recording_on_click),
+ ft.IconButton(icon=ft.Icons.REFRESH, tooltip=self._["refresh"], on_click=self.refresh_cards_on_click),
+ ft.IconButton(
+ icon=ft.Icons.PLAY_ARROW,
+ tooltip=self._["batch_start"],
+ on_click=self.start_monitor_recordings_on_click,
+ ),
+ ft.IconButton(
+ icon=ft.Icons.STOP, tooltip=self._["batch_stop"], on_click=self.stop_monitor_recordings_on_click
+ ),
+ ft.IconButton(
+ icon=ft.Icons.DELETE_SWEEP,
+ tooltip=self._["batch_delete"],
+ on_click=self.delete_monitor_recordings_on_click,
+ ),
+ ],
+ alignment=ft.MainAxisAlignment.START,
+ )
+
+ async def filter_recordings(self, query):
+ recordings = self.app.record_manager.recordings
+ cards_obj = self.app.record_card_manager.cards_obj
+ new_ids = {}
+
+ if not query.strip():
+ for card in cards_obj.values():
+ card["card"].visible = True
+ else:
+ lower_query = query.strip().lower()
+ new_ids = {
+ rec.rec_id
+ for rec in recordings
+ if lower_query in str(rec.to_dict()).lower() or lower_query in rec.display_title
+ }
+
+ for card_info in cards_obj.values():
+ card_info["card"].visible = card_info["card"].key in new_ids
+ self.recording_card_area.update()
+
+ if not new_ids:
+ await self.app.snack_bar.show_snack_bar(self._["not_search_result"], duration=2000)
+ return new_ids
+
+ def create_home_content_area(self):
+ return ft.Column(
+ expand=True,
+ controls=[
+ ft.Divider(height=1),
+ ft.Container(
+ content=self.recording_card_area,
+ alignment=ft.alignment.top_left,
+ expand=True,
+ ),
+ ],
+ scroll=ft.ScrollMode.AUTO,
+ )
+
+ async def add_record_card(self, recording, update=True):
+ if recording.rec_id not in self.app.record_card_manager.cards_obj:
+ card = await self.app.record_card_manager.create_card(recording)
+ self.recording_card_area.controls.append(card)
+ self.app.record_card_manager.cards_obj[recording.rec_id]["card"] = card
+
+ if update:
+ self.recording_card_area.update()
+
+ async def add_record_cards(self):
+ for recording in self.app.record_manager.recordings:
+ await self.add_record_card(recording, update=False)
+ self.recording_card_area.update()
+ if not self.app.record_manager.periodic_task_started:
+ self.page.run_task(
+ self.app.record_manager.setup_periodic_live_check, self.app.record_manager.loop_time_seconds
+ )
+
+ async def show_all_cards(self):
+ cards_obj = self.app.record_card_manager.cards_obj
+ for card in cards_obj.values():
+ card["card"].visible = True
+ self.recording_card_area.update()
+
+ async def add_recording(self, recordings_info):
+ user_config = self.app.settings.user_config
+ logger.info(f"Add items: {len(recordings_info)}")
+ for recording_info in recordings_info:
+ if recording_info.get("record_format"):
+ recording = Recording(
+ rec_id=str(uuid.uuid4()),
+ url=recording_info["url"],
+ streamer_name=recording_info["streamer_name"],
+ quality=recording_info["quality"],
+ record_format=recording_info["record_format"],
+ segment_record=recording_info["segment_record"],
+ segment_time=recording_info["segment_time"],
+ monitor_status=recording_info["monitor_status"],
+ scheduled_recording=recording_info["scheduled_recording"],
+ scheduled_start_time=recording_info["scheduled_start_time"],
+ monitor_hours=recording_info["monitor_hours"],
+ recording_dir=recording_info["recording_dir"],
+ )
+ else:
+ recording = Recording(
+ rec_id=str(uuid.uuid4()),
+ url=recording_info["url"],
+ streamer_name=recording_info["streamer_name"],
+ quality=recording_info["quality"],
+ record_format=user_config.get("video_format", "TS"),
+ segment_record=user_config.get("segmented_recording_enabled", False),
+ segment_time=user_config.get("video_segment_time", "1800"),
+ monitor_status=True,
+ scheduled_recording=user_config.get("scheduled_recording", False),
+ scheduled_start_time=user_config.get("scheduled_start_time"),
+ monitor_hours=user_config.get("monitor_hours"),
+ recording_dir=None,
+ )
+
+ recording.loop_time_seconds = int(user_config.get("loop_time_seconds", 300))
+ recording.update_title(self._[recording.quality])
+ self.app.record_manager.recordings.append(recording)
+ self.page.run_task(self.add_record_card, recording, True)
+
+ self.page.run_task(self.app.record_manager.save_to_json)
+ await self.app.snack_bar.show_snack_bar(self._["add_recording_success_tip"], bgcolor=ft.Colors.GREEN)
+
+ async def search_on_click(self, _e):
+ """Open the search dialog when the search button is clicked."""
+ search_dialog = SearchDialog(home_page=self)
+ search_dialog.open = True
+ self.app.dialog_area.content = search_dialog
+ self.app.dialog_area.update()
+
+ async def add_recording_on_click(self, _e):
+ await self.add_recording_dialog.show_dialog()
+
+ async def refresh_cards_on_click(self, _e):
+ cards_obj = self.app.record_card_manager.cards_obj
+ recordings = self.app.record_manager.recordings
+ new_ids = {rec.rec_id for rec in recordings}
+ to_remove = [card for card_id, card in cards_obj.items() if card_id not in new_ids]
+ for card in to_remove:
+ del cards_obj[card["card"].key]
+ self.recording_card_area.controls.remove(card["card"])
+ await self.show_all_cards()
+ await self.app.snack_bar.show_snack_bar(self._["refresh_success_tip"], bgcolor=ft.Colors.GREEN)
+
+ async def start_monitor_recordings_on_click(self, _):
+ await self.app.record_manager.check_free_space()
+ if self.app.recording_enabled:
+ await self.app.record_manager.start_monitor_recordings()
+ await self.app.snack_bar.show_snack_bar(self._["start_recording_success_tip"], bgcolor=ft.Colors.GREEN)
+
+ async def stop_monitor_recordings_on_click(self, _):
+ await self.app.record_manager.stop_monitor_recordings()
+ await self.app.snack_bar.show_snack_bar(self._["stop_recording_success_tip"])
+
+ async def delete_monitor_recordings_on_click(self, _):
+ selected_recordings = await self.app.record_manager.get_selected_recordings()
+ tips = self._["batch_delete_confirm_tip"] if selected_recordings else self._["clear_all_confirm_tip"]
+
+ async def confirm_dlg(_):
+ if selected_recordings:
+ await self.app.record_manager.stop_monitor_recordings(selected_recordings)
+ await self.app.record_manager.delete_recording_cards(selected_recordings)
+ else:
+ await self.app.record_manager.stop_monitor_recordings(self.app.record_manager.recordings)
+ self.app.record_manager.recordings = []
+ self.recording_card_area.controls.clear()
+ self.app.record_card_manager.cards_obj = {}
+ self.page.run_task(self.app.record_manager.save_to_json)
+
+ self.recording_card_area.update()
+ await self.app.snack_bar.show_snack_bar(
+ self._["delete_recording_success_tip"], bgcolor=ft.Colors.GREEN, duration=2000
+ )
+ await close_dialog(None)
+
+ async def close_dialog(_):
+ batch_delete_alert_dialog.open = False
+ batch_delete_alert_dialog.update()
+
+ batch_delete_alert_dialog = ft.AlertDialog(
+ title=ft.Text(self._["confirm"]),
+ content=ft.Text(tips),
+ actions=[
+ ft.TextButton(text=self._["cancel"], on_click=close_dialog),
+ ft.TextButton(text=self._["sure"], on_click=confirm_dlg),
+ ],
+ actions_alignment=ft.MainAxisAlignment.END,
+ modal=False,
+ )
+
+ batch_delete_alert_dialog.open = True
+ self.app.dialog_area.content = batch_delete_alert_dialog
+ self.page.update()
+
+ async def on_keyboard(self, e: ft.KeyboardEvent):
+ if e.alt and e.key == "H":
+ self.app.dialog_area.content = HelpDialog(self.app)
+ self.app.dialog_area.content.open = True
+ self.app.dialog_area.update()
+ if self.app.current_page == self:
+ if e.ctrl and e.key == "F":
+ self.page.run_task(self.search_on_click, e)
+ elif e.ctrl and e.key == "R":
+ self.page.run_task(self.refresh_cards_on_click, e)
+ elif e.alt and e.key == "N":
+ self.page.run_task(self.add_recording_on_click, e)
+ elif e.alt and e.key == "B":
+ self.page.run_task(self.start_monitor_recordings_on_click, e)
+ elif e.alt and e.key == "P":
+ self.page.run_task(self.stop_monitor_recordings_on_click, e)
+ elif e.alt and e.key == "D":
+ self.page.run_task(self.delete_monitor_recordings_on_click, e)
diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py
new file mode 100644
index 0000000..c3a48db
--- /dev/null
+++ b/app/ui/views/settings_view.py
@@ -0,0 +1,1027 @@
+import asyncio
+import os
+
+import flet as ft
+
+from ...models.video_format_model import VideoFormat
+from ...models.video_quality_model import VideoQuality
+from ...utils.delay import DelayedTaskExecutor
+from ...utils.logger import logger
+from ..base_page import PageBase
+from ..components.help_dialog import HelpDialog
+
+
+class SettingsPage(PageBase):
+ def __init__(self, app):
+ super().__init__(app)
+ self.page_name = "settings"
+ self.config_manager = self.app.config_manager
+
+ self.user_config = self.config_manager.load_user_config()
+ self.language_option = self.config_manager.load_language_config()
+ self.default_config = self.config_manager.load_default_config()
+ self.cookies_config = self.config_manager.load_cookies_config()
+ self.accounts_config = self.config_manager.load_accounts_config()
+
+ self.language_code = None
+ self.default_language = None
+ self.focused_control = None
+ self.tab_recording = None
+ self.tab_push = None
+ self.tab_cookies = None
+ self.tab_accounts = None
+ self.has_unsaved_changes = {}
+ self.delay_handler = DelayedTaskExecutor(self.app, self)
+ self.load_language()
+ self.init_unsaved_changes()
+ self.page.on_keyboard_event = self.on_keyboard
+
+ async def load(self):
+ """Load the settings page content with tabs for different categories."""
+
+ self.content_area.clean()
+ language = self.app.language_manager.language
+ self._ = language["settings_page"] | language["video_quality"] | language["base"]
+ self.tab_recording = self.create_recording_settings_tab()
+ self.tab_push = self.create_push_settings_tab()
+ self.tab_cookies = self.create_cookies_settings_tab()
+ self.tab_accounts = self.create_accounts_settings_tab()
+ self.page.on_keyboard_event = self.on_keyboard
+
+ settings_tabs = ft.Tabs(
+ selected_index=0,
+ animation_duration=300,
+ tabs=[
+ ft.Tab(text=self._["recording_settings"], content=self.tab_recording),
+ ft.Tab(text=self._["push_settings"], content=self.tab_push),
+ ft.Tab(text=self._["cookies_settings"], content=self.tab_cookies),
+ ft.Tab(text=self._["accounts_settings"], content=self.tab_accounts),
+ ],
+ )
+
+ scrollable_content = ft.Container(
+ content=settings_tabs,
+ expand=True,
+ )
+
+ settings_content = ft.Container(
+ content=scrollable_content,
+ expand=True,
+ )
+
+ column_layout = ft.Column(
+ [
+ settings_content,
+ ],
+ spacing=0,
+ expand=True,
+ )
+
+ self.content_area.controls.append(column_layout)
+ self.app.complete_page.update()
+
+ def init_unsaved_changes(self):
+ self.has_unsaved_changes = {
+ "user_config": False,
+ "cookies_config": False,
+ "accounts_config": False
+ }
+
+ def load_language(self):
+ self.default_language, default_language_code = list(self.language_option.items())[0]
+ select_language = self.user_config.get("language")
+ self.language_code = self.language_option.get(select_language, default_language_code)
+ self.app.language_code = self.language_code
+
+ def get_config_value(self, key, default=None):
+ return self.user_config.get(key, self.default_config.get(key, default))
+
+ def get_cookies_value(self, key, default=""):
+ return self.cookies_config.get(key, default)
+
+ def get_accounts_value(self, key, default=None):
+ k1, k2 = key.split("_", maxsplit=1)
+ return self.accounts_config.get(k1, {}).get(k2, default)
+
+ async def restore_default_config(self, _):
+ """Restore settings to their default values."""
+
+ async def confirm_dlg(_):
+ ui_language = self.user_config["language"]
+ self.user_config = self.default_config.copy()
+ self.user_config["language"] = ui_language
+ self.app.language_manager.notify_observers()
+ self.page.run_task(self.load)
+ await self.config_manager.save_user_config(self.user_config)
+ logger.success("Default configuration restored.")
+ await self.app.snack_bar.show_snack_bar(self._["success_restore_tip"], bgcolor=ft.Colors.GREEN)
+ await close_dialog(None)
+
+ async def close_dialog(_):
+ restore_alert_dialog.open = False
+ restore_alert_dialog.update()
+
+ restore_alert_dialog = ft.AlertDialog(
+ title=ft.Text(self._["confirm"]),
+ content=ft.Text(self._["query_restore_config_tip"]),
+ actions=[
+ ft.TextButton(text=self._["cancel"], on_click=close_dialog),
+ ft.TextButton(text=self._["sure"], on_click=confirm_dlg),
+ ],
+ actions_alignment=ft.MainAxisAlignment.END,
+ modal=False,
+ )
+
+ self.app.dialog_area.content = restore_alert_dialog
+ self.app.dialog_area.content.open = True
+ self.app.dialog_area.update()
+
+ async def on_change(self, e):
+ """Handle changes in any input field and trigger auto-save."""
+ key = e.control.data
+ if isinstance(e.control, (ft.Switch, ft.Checkbox)):
+ self.user_config[key] = e.data.lower() == "true"
+ else:
+ self.user_config[key] = e.data
+ if key == "language":
+ self.load_language()
+ self.app.language_manager.load()
+ self.app.language_manager.notify_observers()
+ self.page.run_task(self.load)
+
+ if key == "loop_time_seconds":
+ self.app.record_manager.initialize_dynamic_state()
+ self.page.run_task(self.delay_handler.start_task_timer, self.save_user_config_after_delay, None)
+ self.has_unsaved_changes['user_config'] = True
+
+ def on_cookies_change(self, e):
+ """Handle changes in any input field and trigger auto-save."""
+ key = e.control.data
+ self.cookies_config[key] = e.data
+ self.page.run_task(self.delay_handler.start_task_timer, self.save_cookies_after_delay, None)
+ self.has_unsaved_changes['cookies_config'] = True
+
+ def on_accounts_change(self, e):
+ """Handle changes in any input field and trigger auto-save."""
+ key = e.control.data
+ k1, k2 = key.split("_", maxsplit=1)
+ if k1 not in self.accounts_config:
+ self.accounts_config[k1] = {}
+
+ self.accounts_config[k1][k2] = e.data
+ self.page.run_task(self.delay_handler.start_task_timer, self.save_accounts_after_delay, None)
+ self.has_unsaved_changes['accounts_config'] = True
+
+ async def save_user_config_after_delay(self, delay):
+ await asyncio.sleep(delay)
+ if self.has_unsaved_changes['user_config']:
+ await self.config_manager.save_user_config(self.user_config)
+
+ async def save_cookies_after_delay(self, delay):
+ await asyncio.sleep(delay)
+ if self.has_unsaved_changes['cookies_config']:
+ await self.config_manager.save_cookies_config(self.cookies_config)
+
+ async def save_accounts_after_delay(self, delay):
+ await asyncio.sleep(delay)
+ if self.has_unsaved_changes['accounts_config']:
+ await self.config_manager.save_accounts_config(self.accounts_config)
+
+ def get_video_save_path(self):
+ live_save_path = self.get_config_value("live_save_path")
+ if not live_save_path:
+ live_save_path = os.path.join(self.app.run_path, 'downloads')
+ return live_save_path
+
+ def create_recording_settings_tab(self):
+ """Create UI elements for recording settings."""
+ return ft.Column(
+ [
+ self.create_setting_group(
+ self._["basic_settings"],
+ self._["program_config"],
+ [
+ self.create_setting_row(
+ self._["restore_defaults"],
+ ft.IconButton(
+ icon=ft.Icons.RESTORE_OUTLINED,
+ icon_size=32,
+ tooltip=self._["restore_defaults"],
+ on_click=self.restore_default_config,
+ ),
+ ),
+ self.create_setting_row(
+ self._["program_language"],
+ ft.Dropdown(
+ options=[
+ ft.dropdown.Option(key=k, text=self._[k]) for k, v in self.language_option.items()
+ ],
+ value=self.get_config_value("language", self.default_language),
+ width=200,
+ on_change=self.on_change,
+ data="language",
+ tooltip=self._["switch_language"],
+ ),
+ ),
+ self.create_setting_row(
+ self._["filename_includes_title"],
+ ft.Switch(
+ value=self.get_config_value("filename_includes_title"),
+ on_change=self.on_change,
+ data="filename_includes_title",
+ ),
+ ),
+ self.pick_folder(
+ self._["live_recording_path"],
+ ft.TextField(
+ value=self.get_video_save_path(),
+ width=300,
+ on_change=self.on_change,
+ data="live_save_path",
+ ),
+ ),
+ self.create_setting_row(
+ self._["remove_emojis"],
+ ft.Switch(
+ value=self.get_config_value("remove_emojis"),
+ on_change=self.on_change,
+ data="remove_emojis",
+ ),
+ ),
+ self.create_folder_setting_row(self._["name_rules"]),
+ ],
+ ),
+ self.create_setting_group(
+ self._["proxy_settings"],
+ self._["is_proxy_enabled"],
+ [
+ self.create_setting_row(
+ self._["enable_proxy"],
+ ft.Switch(
+ value=self.get_config_value("enable_proxy"),
+ on_change=self.on_change,
+ data="enable_proxy",
+ ),
+ ),
+ self.create_setting_row(
+ self._["proxy_address"],
+ ft.TextField(
+ value=self.get_config_value("proxy_address"),
+ width=300,
+ on_change=self.on_change,
+ data="proxy_address",
+ ),
+ ),
+ ],
+ ),
+ self.create_setting_group(
+ self._["recording_options"],
+ self._["advanced_config"],
+ [
+ self.create_setting_row(
+ self._["video_record_format"],
+ ft.Dropdown(
+ options=[ft.dropdown.Option(i) for i in VideoFormat.get_formats()],
+ value=self.get_config_value("video_format", VideoFormat.TS),
+ width=200,
+ data="video_format",
+ on_change=self.on_change,
+ tooltip=self._["switch_video_format"],
+ ),
+ ),
+ self.create_setting_row(
+ self._["recording_quality"],
+ ft.Dropdown(
+ options=[ft.dropdown.Option(i, text=self._[i]) for i in VideoQuality.get_qualities()],
+ value=self.get_config_value("record_quality", VideoQuality.OD),
+ width=200,
+ data="record_quality",
+ on_change=self.on_change,
+ tooltip=self._["switch_recording_quality"],
+ ),
+ ),
+ self.create_setting_row(
+ self._["loop_time"],
+ ft.TextField(
+ value=self.get_config_value("loop_time_seconds"),
+ width=100,
+ data="loop_time_seconds",
+ on_change=self.on_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["is_segmented_recording_enabled"],
+ ft.Switch(
+ value=self.get_config_value("segmented_recording_enabled"),
+ data="segmented_recording_enabled",
+ on_change=self.on_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["force_https"],
+ ft.Switch(
+ value=self.get_config_value("force_https_recording"),
+ data="force_https_recording",
+ on_change=self.on_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["space_threshold"],
+ ft.TextField(
+ value=self.get_config_value("recording_space_threshold"),
+ width=100,
+ data="recording_space_threshold",
+ on_change=self.on_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["segment_time"],
+ ft.TextField(
+ value=self.get_config_value("video_segment_time"),
+ width=100,
+ data="video_segment_time",
+ on_change=self.on_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["convert_mp4"],
+ ft.Switch(
+ value=self.get_config_value("convert_to_mp4"),
+ data="convert_to_mp4",
+ on_change=self.on_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["delete_original"],
+ ft.Switch(
+ value=self.get_config_value("delete_original"),
+ data="delete_original",
+ on_change=self.on_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["generate_timestamps_subtitle"],
+ ft.Switch(
+ value=self.get_config_value("generate_time_subtitle_file"),
+ data="generate_time_subtitle_file",
+ on_change=self.on_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["custom_script"],
+ ft.Switch(
+ value=self.get_config_value("execute_custom_script"),
+ data="execute_custom_script",
+ on_change=self.on_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["script_command"],
+ ft.TextField(
+ value=self.get_config_value("custom_script_command"),
+ width=300,
+ data="custom_script_command",
+ on_change=self.on_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["default_platform_with_proxy"],
+ ft.TextField(
+ value=self.get_config_value("default_platform_with_proxy"),
+ width=300,
+ data="default_platform_with_proxy",
+ on_change=self.on_change,
+ ),
+ ),
+ ],
+ ),
+ ],
+ spacing=10,
+ scroll=ft.ScrollMode.AUTO,
+ )
+
+ def create_push_settings_tab(self):
+ """Create UI elements for push configuration."""
+ return ft.Column(
+ [
+ self.create_setting_group(
+ self._["push_notifications"],
+ self._["stream_start_notification_enabled"],
+ [
+ self.create_setting_row(
+ self._["open_broadcast_push_enabled"],
+ ft.Switch(
+ value=self.get_config_value("stream_start_notification_enabled"),
+ data="stream_start_notification_enabled",
+ on_change=self.on_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["close_broadcast_push_enabled"],
+ ft.Switch(
+ value=self.get_config_value("stream_end_notification_enabled"),
+ data="stream_end_notification_enabled",
+ on_change=self.on_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["only_notify_no_record"],
+ ft.Switch(
+ value=self.get_config_value("only_notify_no_record"),
+ data="only_notify_no_record",
+ on_change=self.on_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["notify_loop_time"],
+ ft.TextField(
+ value=self.get_config_value("notify_loop_time"),
+ width=300,
+ data="notify_loop_time",
+ on_change=self.on_change,
+ ),
+ ),
+ ],
+ ),
+ self.create_setting_group(
+ self._["custom_push_settings"],
+ self._["personalized_notification_content_behavior"],
+ [
+ self.create_setting_row(
+ self._["custom_push_title"],
+ ft.TextField(
+ value=self.get_config_value("custom_notification_title"),
+ width=300,
+ data="custom_notification_title",
+ on_change=self.on_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["custom_open_broadcast_content"],
+ ft.TextField(
+ value=self.get_config_value("custom_stream_start_content"),
+ width=300,
+ data="custom_stream_start_content",
+ on_change=self.on_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["custom_close_broadcast_content"],
+ ft.TextField(
+ value=self.get_config_value("custom_stream_end_content"),
+ width=300,
+ data="custom_stream_end_content",
+ on_change=self.on_change,
+ ),
+ ),
+ ],
+ ),
+ self.create_setting_group(
+ self._["push_channels"],
+ self._["select_and_enable_channels"],
+ [
+ ft.Row(
+ controls=[
+ self.create_channel_switch_container(
+ self._["dingtalk"], ft.Icons.BUSINESS_CENTER, "dingtalk_enabled"
+ ),
+ self.create_channel_switch_container(
+ self._["wechat"], ft.Icons.WECHAT, "wechat_enabled"
+ ),
+ self.create_channel_switch_container(
+ "Bark", ft.Icons.NOTIFICATIONS_ACTIVE, "bark_enabled"
+ ),
+ self.create_channel_switch_container("Ntfy", ft.Icons.NOTIFICATIONS, "ntfy_enabled"),
+ self.create_channel_switch_container("Telegram", ft.Icons.SMS, "telegram_enabled"),
+ self.create_channel_switch_container("Email", ft.Icons.EMAIL, "email_enabled"),
+ ],
+ wrap=False,
+ alignment=ft.MainAxisAlignment.START,
+ spacing=10,
+ run_spacing=10,
+ )
+ ],
+ ),
+ self.create_setting_group(
+ self._["channel_configuration"],
+ self._["configure_enabled_channels"],
+ [
+ self.create_channel_config(
+ self._["dingtalk"],
+ [
+ self.create_setting_row(
+ self._["dingtalk_webhook_url"],
+ ft.TextField(
+ value=self.get_config_value("dingtalk_webhook_url"),
+ hint_text=self._["dingtalk_webhook_hint"],
+ width=300,
+ on_change=self.on_change,
+ data="dingtalk_webhook_url",
+ ),
+ ),
+ self.create_setting_row(
+ self._["dingtalk_at_objects"],
+ ft.TextField(
+ value=self.get_config_value("dingtalk_at_objects"),
+ hint_text=self._["dingtalk_phone_numbers_hint"],
+ width=300,
+ on_change=self.on_change,
+ data="dingtalk_at_objects",
+ ),
+ ),
+ self.create_setting_row(
+ self._["dingtalk_at_all"],
+ ft.Switch(
+ value=self.get_config_value("dingtalk_at_all"),
+ on_change=self.on_change,
+ data="dingtalk_at_all",
+ ),
+ ),
+ ],
+ ),
+ self.create_channel_config(
+ self._["wechat"],
+ [
+ self.create_setting_row(
+ self._["wechat_webhook_url"],
+ ft.TextField(
+ value=self.get_config_value("wechat_webhook_url"),
+ width=300,
+ on_change=self.on_change,
+ data="wechat_webhook_url",
+ ),
+ ),
+ ],
+ ),
+ self.create_channel_config(
+ "Bark",
+ [
+ self.create_setting_row(
+ self._["bark_webhook_url"],
+ ft.TextField(
+ value=self.get_config_value("bark_webhook_url"),
+ width=300,
+ on_change=self.on_change,
+ data="bark_webhook_url",
+ ),
+ ),
+ self.create_setting_row(
+ self._["bark_interrupt_level"],
+ ft.Dropdown(
+ options=[ft.dropdown.Option("active"), ft.dropdown.Option("passive")],
+ value=self.get_config_value("bark_interrupt_level"),
+ width=200,
+ on_change=self.on_change,
+ data="bark_interrupt_level",
+ ),
+ ),
+ self.create_setting_row(
+ self._["bark_sound"],
+ ft.TextField(
+ width=300,
+ on_change=self.on_change,
+ data="bark_sound",
+ value=self.get_config_value("bark_sound"),
+ ),
+ ),
+ ],
+ ),
+ self.create_channel_config(
+ "Ntfy",
+ [
+ self.create_setting_row(
+ self._["ntfy_server_url"],
+ ft.TextField(
+ value=self.get_config_value("ntfy_server_url"),
+ width=300,
+ on_change=self.on_change,
+ data="ntfy_server_url",
+ ),
+ ),
+ self.create_setting_row(
+ self._["ntfy_tags"],
+ ft.TextField(
+ value=self.get_config_value("ntfy_tags"),
+ width=300,
+ on_change=self.on_change,
+ data="ntfy_tags",
+ ),
+ ),
+ self.create_setting_row(
+ self._["ntfy_email"],
+ ft.TextField(
+ value=self.get_config_value("ntfy_email"),
+ width=300,
+ on_change=self.on_change,
+ data="ntfy_email",
+ ),
+ ),
+ self.create_setting_row(
+ self._["ntfy_action_url"],
+ ft.TextField(
+ value=self.get_config_value("ntfy_action_url"),
+ width=300,
+ on_change=self.on_change,
+ data="ntfy_action_url",
+ ),
+ ),
+ ],
+ ),
+ self.create_channel_config(
+ "Telegram",
+ [
+ self.create_setting_row(
+ self._["telegram_api_token"],
+ ft.TextField(
+ value=self.get_config_value("telegram_api_token"),
+ width=300,
+ on_change=self.on_change,
+ data="telegram_api_token",
+ ),
+ ),
+ self.create_setting_row(
+ self._["telegram_chat_id"],
+ ft.TextField(
+ value=self.get_config_value("telegram_chat_id"),
+ width=300,
+ on_change=self.on_change,
+ data="telegram_chat_id",
+ ),
+ ),
+ ],
+ ),
+ self.create_channel_config(
+ "Email",
+ [
+ self.create_setting_row(
+ self._["smtp_server"],
+ ft.TextField(
+ value=self.get_config_value("smtp_server"),
+ width=300,
+ on_change=self.on_change,
+ data="smtp_server",
+ ),
+ ),
+ self.create_setting_row(
+ self._["email_username"],
+ ft.TextField(
+ value=self.get_config_value("email_username"),
+ width=300,
+ on_change=self.on_change,
+ data="email_username",
+ ),
+ ),
+ self.create_setting_row(
+ self._["email_password"],
+ ft.TextField(
+ value=self.get_config_value("email_password"),
+ width=300,
+ on_change=self.on_change,
+ data="email_password",
+ ),
+ ),
+ self.create_setting_row(
+ self._["sender_email"],
+ ft.TextField(
+ value=self.get_config_value("sender_email"),
+ width=300,
+ on_change=self.on_change,
+ data="sender_email",
+ ),
+ ),
+ self.create_setting_row(
+ self._["sender_name"],
+ ft.TextField(
+ value=self.get_config_value("sender_name"),
+ width=300,
+ on_change=self.on_change,
+ data="sender_name",
+ ),
+ ),
+ self.create_setting_row(
+ self._["recipient_email"],
+ ft.TextField(
+ value=self.get_config_value("recipient_email"),
+ width=300,
+ on_change=self.on_change,
+ data="recipient_email",
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ spacing=10,
+ scroll=ft.ScrollMode.AUTO,
+ )
+
+ def create_cookies_settings_tab(self):
+ """Create UI elements for push configuration."""
+ platforms = [
+ "douyin",
+ "tiktok",
+ "kuaishou",
+ "huya",
+ "douyu",
+ "yy",
+ "bilibili",
+ "xhs",
+ "bigo",
+ "blued",
+ "soop",
+ "netease",
+ "qiandurebo",
+ "pandalive",
+ "maoerfm",
+ "winktv",
+ "flextv",
+ "look",
+ "popkontv",
+ "twitcasting",
+ "baidu",
+ "weibo",
+ "kugou",
+ "twitch",
+ "liveme",
+ "huajiao",
+ "liuxing",
+ "showroom",
+ "acfun",
+ "changliao",
+ "yinbo",
+ "inke",
+ "zhihu",
+ "chzzk",
+ "haixiu",
+ "vvxq",
+ "17live",
+ "lang",
+ "piaopiao",
+ "6room",
+ "lehai",
+ "catshow",
+ "shopee",
+ "youtube",
+ "taobao",
+ "jd",
+ ]
+
+ setting_rows = []
+ for platform in platforms:
+ cookie_field = ft.TextField(
+ value=self.get_cookies_value(platform), width=500, data=platform, on_change=self.on_cookies_change
+ )
+ setting_rows.append(self.create_setting_row(self._[f"{platform}_cookie"], cookie_field))
+
+ return ft.Column(
+ [
+ self.create_setting_group(
+ self._["cookies_settings"], self._["configure_platform_cookies"], setting_rows
+ ),
+ ],
+ spacing=10,
+ scroll=ft.ScrollMode.AUTO,
+ )
+
+ def create_accounts_settings_tab(self):
+ """Create UI elements for push configuration."""
+ return ft.Column(
+ [
+ self.create_setting_group(
+ self._["accounts_settings"],
+ self._["configure_platform_accounts"],
+ [
+ self.create_setting_row(
+ self._["sooplive_username"],
+ ft.TextField(
+ value=self.get_accounts_value("sooplive_username"),
+ width=500,
+ data="sooplive_username",
+ on_change=self.on_accounts_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["sooplive_password"],
+ ft.TextField(
+ value=self.get_accounts_value("sooplive_password"),
+ width=500,
+ data="sooplive_password",
+ on_change=self.on_accounts_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["flextv_username"],
+ ft.TextField(
+ value=self.get_accounts_value("flextv_username"),
+ width=500,
+ data="flextv_username",
+ on_change=self.on_accounts_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["flextv_password"],
+ ft.TextField(
+ value=self.get_accounts_value("flextv_password"),
+ width=500,
+ data="flextv_password",
+ on_change=self.on_accounts_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["popkontv_username"],
+ ft.TextField(
+ value=self.get_accounts_value("popkontv_username"),
+ width=500,
+ data="popkontv_username",
+ on_change=self.on_accounts_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["popkontv_password"],
+ ft.TextField(
+ value=self.get_accounts_value("popkontv_password"),
+ width=500,
+ data="popkontv_password",
+ on_change=self.on_accounts_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["twitcasting_account_type"],
+ ft.Dropdown(
+ options=[ft.dropdown.Option("Default"), ft.dropdown.Option("Twitter")],
+ value=self.get_accounts_value("twitcasting_account_type", "Default"),
+ width=500,
+ data="twitcasting_account_type",
+ on_change=self.on_accounts_change,
+ tooltip=self._["switch_account_type"],
+ ),
+ ),
+ self.create_setting_row(
+ self._["twitcasting_username"],
+ ft.TextField(
+ value=self.get_accounts_value("twitcasting_username"),
+ width=500,
+ data="twitcasting_username",
+ on_change=self.on_accounts_change,
+ ),
+ ),
+ self.create_setting_row(
+ self._["twitcasting_password"],
+ ft.TextField(
+ value=self.get_accounts_value("twitcasting_password"),
+ width=500,
+ data="twitcasting_password",
+ on_change=self.on_accounts_change,
+ ),
+ ),
+ ],
+ ),
+ ],
+ spacing=10,
+ scroll=ft.ScrollMode.AUTO,
+ )
+
+ def create_folder_setting_row(self, label):
+ """Helper method to create a row of checkboxes for folder settings."""
+ return ft.Row(
+ [
+ ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT),
+ ft.Checkbox(
+ label=self._["author"],
+ value=self.get_config_value("folder_name_author"),
+ on_change=self.on_change,
+ data="folder_name_author",
+ ),
+ ft.Checkbox(
+ label=self._["time"],
+ value=self.get_config_value("folder_name_time"),
+ on_change=self.on_change,
+ data="folder_name_time",
+ ),
+ ft.Checkbox(
+ label=self._["title"],
+ value=self.get_config_value("folder_name_title"),
+ on_change=self.on_change,
+ data="folder_name_title",
+ ),
+ ],
+ alignment=ft.MainAxisAlignment.START,
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
+ )
+
+ def create_channel_switch_container(self, channel_name, icon, key):
+ """Helper method to create a container with a switch and an icon for each channel."""
+ return ft.Container(
+ content=ft.Row(
+ [
+ ft.Icon(icon, size=24, color=ft.Colors.GREY_700),
+ ft.Text(channel_name, size=14),
+ ft.Switch(value=self.get_config_value(key), label="", width=50, on_change=self.on_change, data=key),
+ ],
+ alignment=ft.MainAxisAlignment.START,
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
+ ),
+ padding=5,
+ margin=5,
+ )
+
+ @staticmethod
+ def create_channel_config(channel_name, settings):
+ """Helper method to create expandable configurations for each channel."""
+ return ft.ExpansionTile(
+ initially_expanded=False,
+ title=ft.Text(channel_name, size=14, weight=ft.FontWeight.BOLD),
+ controls=[ft.Container(content=ft.Column(settings, spacing=5), padding=10)],
+ tile_padding=0,
+ )
+
+ @staticmethod
+ def create_setting_group(title, description, settings):
+ """Helper method to group settings under a title."""
+ return ft.Card(
+ content=ft.Container(
+ content=ft.Column(
+ [
+ ft.Text(title, size=16, weight=ft.FontWeight.BOLD),
+ ft.Text(description, theme_style=ft.TextThemeStyle.BODY_MEDIUM, opacity=0.7),
+ *settings,
+ ],
+ spacing=5,
+ ),
+ padding=10,
+ ),
+ elevation=5,
+ margin=10,
+ )
+
+ def set_focused_control(self, control):
+ """Store the currently focused control."""
+ self.focused_control = control
+
+ def create_setting_row(self, label, control):
+ """Helper method to create a row for each setting."""
+ control.on_focus = lambda e: self.set_focused_control(e.control)
+ return ft.Row(
+ [ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), control],
+ alignment=ft.MainAxisAlignment.START,
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
+ )
+
+ def pick_folder(self, label, control):
+ def picked_folder(e: ft.FilePickerResultEvent):
+ path = e.path
+ if path:
+ control.value = path
+ control.update()
+ e.control.data = control.data
+ e.data = path
+ self.page.run_task(self.on_change, e)
+
+ def pick_folder(_):
+ folder_picker.get_directory_path()
+
+ folder_picker = ft.FilePicker(on_result=picked_folder)
+ self.page.overlay.append(folder_picker)
+ self.page.update()
+
+ btn_pick_folder = ft.ElevatedButton(
+ text=self._["select"], icon=ft.Icons.FOLDER_OPEN, on_click=pick_folder, tooltip=self._["select_btn_tip"]
+ )
+ return ft.Row(
+ [ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), control, btn_pick_folder],
+ alignment=ft.MainAxisAlignment.START,
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
+ )
+
+ async def is_changed(self):
+ if self.app.current_page != self:
+ return
+
+ show_snack_bar = False
+ save_methods = {
+ "user_config": (self.config_manager.save_user_config, self.user_config),
+ "cookies_config": (self.config_manager.save_cookies_config, self.cookies_config),
+ "accounts_config": (self.config_manager.save_accounts_config, self.accounts_config)
+ }
+
+ for config_key, should_save in self.has_unsaved_changes.items():
+ if should_save and config_key in save_methods:
+ save_method, config_value = save_methods[config_key]
+ await save_method(config_value)
+ self.has_unsaved_changes[config_key] = False
+ show_snack_bar = True
+
+ if show_snack_bar:
+ await self.app.snack_bar.show_snack_bar(
+ self._["success_save_config_tip"], duration=1500, bgcolor=ft.Colors.GREEN
+ )
+
+ async def on_keyboard(self, e: ft.KeyboardEvent):
+ if e.alt and e.key == "H":
+ self.app.dialog_area.content = HelpDialog(self.app)
+ self.app.dialog_area.content.open = True
+ self.app.dialog_area.update()
+
+ if self.app.current_page == self and e.ctrl and e.key == "S":
+ self.page.run_task(self.is_changed)
diff --git a/app/utils/__init__.py b/app/utils/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/app/utils/__init__.py
@@ -0,0 +1 @@
+
diff --git a/app/utils/delay.py b/app/utils/delay.py
new file mode 100644
index 0000000..15674aa
--- /dev/null
+++ b/app/utils/delay.py
@@ -0,0 +1,13 @@
+class DelayedTaskExecutor:
+ def __init__(self, app, settings, delay=3):
+ self.app = app
+ self.settings = settings
+ self.save_timer = None
+ self.delay = delay
+
+ async def start_task_timer(self, task, delay: int | None = None):
+ """Start a timer to save the configuration after a short delay."""
+ if self.save_timer:
+ self.save_timer.cancel()
+
+ self.save_timer = self.app.page.run_task(task, delay or self.delay)
diff --git a/app/utils/logger.py b/app/utils/logger.py
new file mode 100644
index 0000000..fd2e249
--- /dev/null
+++ b/app/utils/logger.py
@@ -0,0 +1,31 @@
+import os
+import sys
+
+from loguru import logger
+
+script_path = os.path.split(os.path.realpath(sys.argv[0]))[0]
+
+logger.add(
+ f"{script_path}/logs/streamget.log",
+ level="DEBUG",
+ format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}",
+ filter=lambda i: i["level"].name != "STREAM",
+ serialize=False,
+ enqueue=True,
+ retention=3,
+ rotation="3 MB",
+ encoding="utf-8",
+)
+
+logger.level("STREAM", no=22, color="")
+logger.add(
+ f"{script_path}/logs/play_url.log",
+ level="STREAM",
+ format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {message}",
+ filter=lambda i: i["level"].name == "STREAM",
+ serialize=False,
+ enqueue=True,
+ retention=1,
+ rotation="500 KB",
+ encoding="utf-8",
+)
diff --git a/app/utils/utils.py b/app/utils/utils.py
new file mode 100644
index 0000000..ec62aa3
--- /dev/null
+++ b/app/utils/utils.py
@@ -0,0 +1,246 @@
+import functools
+import hashlib
+import json
+import os
+import random
+import re
+import shutil
+import string
+import subprocess
+import sys
+import traceback
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any
+from urllib.parse import urlparse
+
+import execjs
+
+from .logger import logger
+
+OptionalStr = str | None
+OptionalDict = dict | None
+
+
+class Color:
+ RED = "\033[31m"
+ GREEN = "\033[32m"
+ YELLOW = "\033[33m"
+ BLUE = "\033[34m"
+ MAGENTA = "\033[35m"
+ CYAN = "\033[36m"
+ WHITE = "\033[37m"
+ RESET = "\033[0m"
+
+ @staticmethod
+ def print_colored(text, color):
+ print(f"{color}{text}{Color.RESET}")
+
+
+def trace_error_decorator(func: callable) -> callable:
+ @functools.wraps(func)
+ async def wrapper(*args: list, **kwargs: dict) -> Any:
+ try:
+ return await func(*args, **kwargs)
+ except execjs.ProgramError:
+ logger.warning("Failed to execute JS code. Please check if the Node.js environment")
+ except Exception as e:
+ error_line = traceback.extract_tb(e.__traceback__)[-1].lineno
+ error_info = f"Type: {type(e).__name__}, {e} in function {func.__name__} at line: {error_line}"
+ logger.error(error_info)
+ return []
+
+ return wrapper
+
+
+def check_md5(file_path: str | Path) -> str:
+ with open(file_path, "rb") as fp:
+ file_md5 = hashlib.md5(fp.read()).hexdigest()
+ return file_md5
+
+
+def dict_to_cookie_str(cookies_dict: dict) -> str:
+ cookie_str = "; ".join([f"{key}={value}" for key, value in cookies_dict.items()])
+ return cookie_str
+
+
+def get_file_paths(directory: str) -> list:
+ file_paths = []
+ for root, _dirs, files in os.walk(directory):
+ for file in files:
+ file_paths.append(os.path.join(root, file))
+ return file_paths
+
+
+def remove_emojis(text: str, replace_text: str = "") -> str:
+ emoji_pattern = re.compile(
+ "["
+ "\U0001f1e0-\U0001f1ff" # flags (iOS)
+ "\U0001f300-\U0001f5ff" # symbols & pictographs
+ "\U0001f600-\U0001f64f" # emoticons
+ "\U0001f680-\U0001f6ff" # transport & map symbols
+ "\U0001f700-\U0001f77f" # alchemical symbols
+ "\U0001f780-\U0001f7ff" # Geometric Shapes Extended
+ "\U0001f800-\U0001f8ff" # Supplemental Arrows-C
+ "\U0001f900-\U0001f9ff" # Supplemental Symbols and Pictographs
+ "\U0001fa00-\U0001fa6f" # Chess Symbols
+ "\U0001fa70-\U0001faff" # Symbols and Pictographs Extended-A
+ "\U00002702-\U000027b0" # Dingbats
+ "]+",
+ flags=re.UNICODE,
+ )
+ return emoji_pattern.sub(replace_text, text)
+
+
+def check_disk_capacity(file_path: str | Path, show: bool = False) -> float:
+ absolute_path = os.path.abspath(file_path)
+ directory = os.path.dirname(absolute_path)
+ disk_usage = shutil.disk_usage(directory)
+ disk_root = Path(directory).anchor
+ free_space_gb = disk_usage.free / (1024**3)
+ if show:
+ print(
+ f"{disk_root} Total: {disk_usage.total / (1024**3): .2f} GB "
+ f"Used: {disk_usage.used / (1024**3): .2f} GB "
+ f"Free: {free_space_gb: .2f} GB\n"
+ )
+ return free_space_gb
+
+
+def handle_proxy_addr(proxy_addr):
+ if proxy_addr:
+ if not proxy_addr.startswith("http"):
+ proxy_addr = "http://" + proxy_addr
+ else:
+ proxy_addr = None
+ return proxy_addr
+
+
+def generate_random_string(length: int) -> str:
+ characters = string.ascii_uppercase + string.digits
+ random_string = "".join(random.choices(characters, k=length))
+ return random_string
+
+
+def jsonp_to_json(jsonp_str: str) -> OptionalDict:
+ pattern = r"(\w+)\((.*)\);?$"
+ match = re.search(pattern, jsonp_str)
+
+ if match:
+ _, json_str = match.groups()
+ json_obj = json.loads(json_str)
+ return json_obj
+ else:
+ raise Exception("No JSON data found in JSONP response.")
+
+
+def open_folder(directory_path):
+ if sys.platform == "win32":
+ os.startfile(directory_path)
+ elif sys.platform == "darwin":
+ subprocess.run(["open", directory_path])
+ else:
+ subprocess.run(["xdg-open", directory_path])
+
+
+def add_hours_to_time(time_str, hours_to_add):
+ time_formats = ["%H:%M:%S", "%H:%M"]
+ for time_format in time_formats:
+ try:
+ time_obj = datetime.strptime(time_str, time_format)
+ new_time_obj = time_obj + timedelta(hours=float(hours_to_add))
+ new_time_str = new_time_obj.strftime(time_formats[0])
+ return new_time_str
+ except ValueError:
+ pass
+
+
+def is_time_greater_than_now(time_str):
+ time_format = "%H:%M:%S"
+ input_time = datetime.strptime(time_str, time_format).time()
+ current_time = datetime.now().time()
+ return input_time > current_time
+
+
+def is_current_time_within_range(time_range_str):
+ """
+ Determine whether the current time is within the time range
+
+ :param time_range_str: such as "18:30:00~20:30:00"
+ :return: Return whether it is within the time range
+ """
+ start_str, end_str = time_range_str.split("~")
+ time_format = "%H:%M:%S"
+
+ start_time = datetime.strptime(start_str.strip(), time_format).time()
+ end_time = datetime.strptime(end_str.strip(), time_format).time()
+
+ now = datetime.now().time()
+ return start_time <= now <= end_time
+
+
+def is_time_interval_exceeded(last_check_time, interval_seconds=60):
+ """
+ Check if the time interval between the current time and the last check time exceeds the specified seconds.
+
+ :param last_check_time: The time of the last check. type: datetime.Time
+ :param interval_seconds: The time interval in seconds. Default is 60 seconds. type: int
+ :return: Returns True if the time interval exceeds the specified seconds, otherwise returns False.
+ """
+ now = datetime.now().time()
+ if not last_check_time or last_check_time > now:
+ return True
+ last_check_datetime = datetime.combine(datetime.today(), last_check_time)
+ time_diff = datetime.combine(datetime.today(), now) - last_check_datetime
+ return time_diff.total_seconds() > interval_seconds
+
+
+def clean_name(input_text, default=None):
+ if input_text and input_text.strip():
+ rstr = r"[\/\\\:\*\??\"\<\>\|.。,, ~!· ]"
+ cleaned_name = input_text.strip().replace("(", "(").replace(")", ")")
+ cleaned_name = re.sub(rstr, "_", cleaned_name)
+ cleaned_name = remove_emojis(cleaned_name, "_").replace("__", "_").strip("_")
+ return cleaned_name or default
+ return default
+
+
+def is_valid_url(url):
+ try:
+ result = urlparse(url)
+ if not all([result.scheme, result.netloc]):
+ return False
+ url_pattern = re.compile(
+ r"^(https?://)"
+ r"([a-zA-Z0-9-]+\.)+[a-zA-Z0-9]{1,6}"
+ r"(:\d+)?"
+ r"(/\S*)?$"
+ )
+ return bool(url_pattern.match(url))
+ except ValueError:
+ return False
+
+
+def contains_url(text):
+ url_pattern = re.compile(
+ r"(?i)\bhttps?://"
+ r"(?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9]{1,6}"
+ r"(?::\d+)?"
+ r"(?:/\S*)?"
+ )
+ try:
+ return bool(url_pattern.search(text))
+ except ValueError:
+ return False
+
+
+def get_startup_info(system_type: str | None = None):
+ """
+ Get startup info for subprocesses to hide console windows on Windows.
+ """
+ if system_type == "nt" or sys.platform == "win32":
+ startup_info = subprocess.STARTUPINFO()
+ startup_info.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ else:
+ startup_info = None
+ return startup_info
diff --git a/assets/fonts/AlibabaPuHuiTi-2/AlibabaPuHuiTi-2-45-Light-Mac.otf b/assets/fonts/AlibabaPuHuiTi-2/AlibabaPuHuiTi-2-45-Light-Mac.otf
new file mode 100644
index 0000000..7db59bd
Binary files /dev/null and b/assets/fonts/AlibabaPuHuiTi-2/AlibabaPuHuiTi-2-45-Light-Mac.otf differ
diff --git a/assets/fonts/AlibabaPuHuiTi-2/AlibabaPuHuiTi-2-45-Light.otf b/assets/fonts/AlibabaPuHuiTi-2/AlibabaPuHuiTi-2-45-Light.otf
new file mode 100644
index 0000000..7db59bd
Binary files /dev/null and b/assets/fonts/AlibabaPuHuiTi-2/AlibabaPuHuiTi-2-45-Light.otf differ
diff --git a/assets/icon.ico b/assets/icon.ico
new file mode 100644
index 0000000..8d7a3e7
Binary files /dev/null and b/assets/icon.ico differ
diff --git a/assets/images/app_icon.ico b/assets/images/app_icon.ico
new file mode 100644
index 0000000..2f55604
Binary files /dev/null and b/assets/images/app_icon.ico differ
diff --git a/assets/images/app_icon.png b/assets/images/app_icon.png
new file mode 100644
index 0000000..a04d927
Binary files /dev/null and b/assets/images/app_icon.png differ
diff --git a/assets/images/example01.png b/assets/images/example01.png
new file mode 100644
index 0000000..d41a0fa
Binary files /dev/null and b/assets/images/example01.png differ
diff --git a/assets/images/example02.jpg b/assets/images/example02.jpg
new file mode 100644
index 0000000..fb9467d
Binary files /dev/null and b/assets/images/example02.jpg differ
diff --git a/config/default_settings.json b/config/default_settings.json
new file mode 100644
index 0000000..fc36de4
--- /dev/null
+++ b/config/default_settings.json
@@ -0,0 +1,55 @@
+{
+ "language": "Chinese",
+ "live_save_path": "",
+ "filename_includes_title": false,
+ "remove_emojis": false,
+ "folder_name_author": true,
+ "folder_name_time": false,
+ "folder_name_title": false,
+ "enable_proxy": true,
+ "proxy_address": "",
+ "video_format": "TS",
+ "record_quality": "OD",
+ "loop_time_seconds": "180",
+ "segmented_recording_enabled": true,
+ "force_https_recording": true,
+ "recording_space_threshold": "2.0",
+ "video_segment_time": "1800",
+ "convert_to_mp4": true,
+ "delete_original": false,
+ "generate_time_subtitle_file": false,
+ "execute_custom_script": false,
+ "custom_script_command": "",
+ "default_platform_with_proxy": "tiktok, sooplive, pandalive, winktv, flextv, popkontv, twitch, liveme, showroom, chzzk, shopee, shp, youtu, youtube, lang",
+ "stream_start_notification_enabled": false,
+ "stream_end_notification_enabled": false,
+ "only_notify_no_record": false,
+ "custom_notification_title": "",
+ "custom_stream_start_content": "",
+ "custom_stream_end_content": "",
+ "dingtalk_enabled": false,
+ "wechat_enabled": false,
+ "bark_enabled": false,
+ "ntfy_enabled": false,
+ "telegram_enabled": false,
+ "email_enabled": false,
+ "dingtalk_webhook_url": "",
+ "dingtalk_at_objects": "",
+ "dingtalk_at_all": false,
+ "wechat_webhook_url": "",
+ "bark_webhook_url": "",
+ "bark_interrupt_level": "active",
+ "bark_sound": "",
+ "ntfy_server_url": "https://ntfy.sh/xxxxx",
+ "ntfy_tags": "tada",
+ "ntfy_email": "",
+ "ntfy_action_url": "",
+ "telegram_api_token": "",
+ "telegram_chat_id": "",
+ "smtp_server": "smtp.qq.com",
+ "email_username": "",
+ "email_password": "",
+ "sender_email": "",
+ "sender_name": "",
+ "recipient_email": ""
+}
\ No newline at end of file
diff --git a/config/language.json b/config/language.json
new file mode 100644
index 0000000..dd60b08
--- /dev/null
+++ b/config/language.json
@@ -0,0 +1,4 @@
+{
+ "Chinese": "zh_CN",
+ "English": "en"
+}
\ No newline at end of file
diff --git a/config/version.json b/config/version.json
new file mode 100644
index 0000000..72f41cc
--- /dev/null
+++ b/config/version.json
@@ -0,0 +1,21 @@
+{
+ "introduction": {
+ "en": "A free application dedicated to recording videos from online live streaming platforms. By integrating FFmpeg, it efficiently and reliably records live streams from over 40 major platforms including Douyin, TikTok, Kuaishou, Huya, Douyu, Bilibili, Rednote, Baidu, Weibo, Kuwo, Twitch, YouTube, PandaTV, SoopLive, FlexTV, PopKontv, Twitcasting, WinkTV, 17Live, Acfun, CHZZK, and Shopee.",
+ "zh_CN": "一款专注于录制在线直播平台视频的免费应用。通过集成FFmpeg,高效、稳定地录制抖音、TikTok、快手、虎牙、斗鱼、B站、小红书、百度、微博、酷狗、Twitch、YouTube、PandaTV、SoopLive、FlexTV、PopKontv、Twitcasting、WinkTV、17Live、Acfun、CHZZK 和 Shopee 等40+主流平台的直播流。"
+ },
+ "open_source_license": "Apache License 2.0",
+ "version_updates": [
+ {
+ "version": "0.0.1",
+ "kernel_version": "4.0.3",
+ "updates": {
+ "en": [
+ "None"
+ ],
+ "zh_CN": [
+ "无"
+ ]
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/locales/en.json b/locales/en.json
new file mode 100644
index 0000000..4632366
--- /dev/null
+++ b/locales/en.json
@@ -0,0 +1,324 @@
+{
+ "sidebar": {
+ "home": "Home",
+ "settings": "Settings",
+ "about": "About",
+ "light_theme": "Light theme",
+ "dark_theme": "Dark theme",
+ "theme_color": "Theme Color",
+ "toggle_day_theme": "Toggle Day Theme",
+ "toggle_night_theme": "Toggle Night Theme",
+ "colors": "Color List"
+ },
+ "home_page": {
+ "recording_list": "Recording List",
+ "search": "Search",
+ "add_record": "Add Recording",
+ "refresh": "Refresh",
+ "batch_start": "Batch Start",
+ "batch_stop": "Batch Stop",
+ "batch_delete": "Batch Delete",
+ "refresh_success_tip": "Tip: Refresh successful",
+ "batch_delete_confirm_tip": "Are you sure you want to delete all selected recordings?",
+ "clear_all_confirm_tip": "Are you sure you want to clear the recording list?",
+ "add_recording_success_tip": "Tip: Live room added successfully",
+ "delete_recording_success_tip": "Tip: Live room deleted successfully",
+ "stop_recording_success_tip": "Tip: Live stream room monitoring has been stopped successfully!",
+ "start_recording_success_tip": "Tip: Live stream room monitoring has started successfully!",
+ "not_search_result": "Tip: No results were found in the search"
+ },
+ "recording_dialog": {
+ "input_live_link": "Enter Live Room URL",
+ "example": "Example",
+ "select_resolution": "Select Recording Resolution",
+ "input_anchor_name": "Enter Broadcaster Name",
+ "default_input": "Can be left blank",
+ "input_record_format": "Enter Recording Format - Default ts",
+ "input_save_path": "Enter Save Path for Recordings",
+ "is_segment_enabled": "Enable Segmented Recording",
+ "segment_record_time": "Segment Recording Time",
+ "input_segment_time": "Enter Segment Time (in seconds)",
+ "scheduled_recording": "Enable Daily Scheduled Monitoring",
+ "scheduled_start_time": "Daily Monitoring Start Time",
+ "monitor_hours": "Daily Monitoring Hours",
+ "batch_input_tip": "Batch Input (one record per line)",
+ "single_input": "Single Input",
+ "batch_input": "Batch Input",
+ "live_room": "Live Room",
+ "platform_not_supported_tip": "⚠\uFE0F This platform does not support recording",
+ "pick_time": "Pick time",
+ "pick_time_tip": "Configure scheduled time",
+ "time_out_of_range": "Time out of range",
+ "pick_time_slot": "Pick your time slot",
+ "hour_label_text": "Hour",
+ "minute_label_text": "Minute"
+ },
+ "search_dialog": {
+ "search_keyword": "Enter search keyword"
+ },
+ "recording_manager": {
+ "monitor_stopped": "Stopped monitoring",
+ "is_live": "Live streaming in progress",
+ "live_room": "Live Room",
+ "recorded": "Recorded",
+ "push_content": "Live room status update: [room_name] is live, time: [time]",
+ "status_notify": "Live room status update notification",
+ "STOPPED_MONITORING": "Monitoring Stopped",
+ "MONITORING": "Monitoring In Progress",
+ "RECORDING": "Recording In Progress",
+ "NOT_RECORDING": "Is living, not Recording",
+ "STATUS_CHECKING": "Live Status Checking In Progres",
+ "NOT_IN_SCHEDULED_CHECK": "Not in Scheduled Check Range, Stopping Live Detection",
+ "PREPARING_RECORDING": "Preparing to Start Recording",
+ "RECORDING_ERROR": "Recording the live stream has failed",
+ "NOT_RECORDING_SPACE": "Insufficient disk space to record",
+ "not_disk_space_tip": "⚠️ Insufficient disk storage space, stop recording"
+ },
+ "stream_manager": {
+ "record_stream_error": "Live streaming source recording error"
+ },
+ "recording_card": {
+ "stop_monitor_tip": "Tip: Live monitoring has been stopped",
+ "start_monitor_tip": "Tip: Live monitoring has been started",
+ "please_stop_monitor_tip": "Tip: Please stop live monitoring first️",
+ "please_start_monitor_tip": "Tip: Please start live monitoring first",
+ "is_not_live_tip": "Tip: The live room is not broadcasting",
+ "pre_record_tip": "Tip: Preparing to start recording",
+ "stop_record_tip": "Tip: Live room recording has been stopped",
+ "no_folder_tip": "Tip: The live room folder does not exist",
+ "edit_record_config": "Edit Recording Configuration",
+ "delete_monitor": "Delete Monitoring",
+ "open_folder": "Open Folder",
+ "recording_info": "Recording Information",
+ "anchor_name": "Anchor Name",
+ "live_link": "Live Link",
+ "live_title": "Live Title",
+ "record_format": "Recording Format",
+ "record_quality": "Recording Quality",
+ "segment_record": "Segmented Recording",
+ "segment_time": "Segment Duration",
+ "monitor_status": "Monitoring Status",
+ "scheduled_recording": "Scheduled Detection",
+ "scheduled_time_range": "Scheduled Detection Range",
+ "save_path": "Save Path",
+ "recording_status": "Recording Status",
+ "start_record": "Start Recording",
+ "stop_record": "Stop Recording",
+ "start_monitor": "Start Monitoring",
+ "stop_monitor": "Stop Monitoring",
+ "seconds": "s",
+ "delete_confirm_tip": "Are you sure you want to delete this live streaming room?",
+ "no_recording_dir_tip": "No saved recording files",
+ "none": "None",
+ "recording_card_tip": "Click to select",
+ "use_proxy": "Use Proxy"
+ },
+ "settings_page": {
+ "recording_settings": "Recording Settings",
+ "push_settings": "Push Settings",
+ "basic_settings": "Basic Settings",
+ "cookies_settings": "Cookie Settings",
+ "accounts_settings": "Account Settings",
+ "select": "select",
+ "program_config": "Basic configuration of the program",
+ "restore_defaults": "Restore Default Settings",
+ "program_language": "Program Language",
+ "filename_includes_title": "Filename Includes Title",
+ "live_recording_path": "Live Recording Save Path",
+ "blank_for_default_path": "Leave blank for default path",
+ "remove_emojis": "Remove Emoji Symbols",
+ "name_rules": "File/folder name rules",
+ "author": "author",
+ "time": "time",
+ "title": "title",
+ "proxy_settings": "Proxy Settings",
+ "is_proxy_enabled": "Configuration for using proxy and related settings",
+ "enable_proxy": "Enable Proxy",
+ "proxy_address": "Proxy Address",
+ "skip_proxy_detection": "Skip Proxy Detection",
+ "recording_options": "Recording Options",
+ "advanced_config": "Advanced recording configuration",
+ "video_record_format": "Video Recording Format",
+ "recording_quality": "Recording Quality",
+ "loop_time": "Loop Time (Seconds)",
+ "is_segmented_recording_enabled": "Enable Segmented Recording",
+ "force_https": "Force HTTPS Recording",
+ "space_threshold": "Remaining Space Threshold (GB) for Recording",
+ "segment_time": "Video Segment Time (Seconds)",
+ "convert_mp4": "Convert to MP4 After Recording",
+ "delete_original": "Delete Original File After Appending Format",
+ "generate_timestamps_subtitle": "Generate Timestamp Subtitle",
+ "custom_script": "Execute Custom Script After Recording",
+ "script_command": "Custom Script Execution Command",
+ "default_platform_with_proxy": "Default Platform for Recording with Proxy",
+ "push_notifications": "Push Notifications",
+ "stream_start_notification_enabled": "Live Status Notification",
+ "open_broadcast_push_enabled": "Broadcast Start Push",
+ "close_broadcast_push_enabled": "Broadcast End Push",
+ "only_notify_no_record": "Only notify without recording",
+ "notify_loop_time": "Only notify loop time",
+ "custom_push_settings": "Custom Push Settings",
+ "personalized_notification_content_behavior": "Personalized notification content and behavior",
+ "custom_push_title": "Custom Push Title",
+ "custom_open_broadcast_content": "Custom Broadcast Start Notification Content",
+ "custom_close_broadcast_content": "Custom Broadcast End Notification Content",
+ "push_channels": "Push Channels",
+ "dingtalk": "dingtalk",
+ "wechat": "wechat",
+ "select_and_enable_channels": "Select and enable the channels you want to use",
+ "channel_configuration": "Channel Configuration",
+ "configure_enabled_channels": "Configure your enabled notification channels",
+ "dingtalk_webhook_url": "DingTalk Webhook URL",
+ "dingtalk_at_objects": "DingTalk @ Objects",
+ "dingtalk_at_all": "DingTalk Notify All",
+ "dingtalk_webhook_hint": "Enter DingTalk group webhook URL",
+ "dingtalk_phone_numbers_hint": "Enter DingTalk phone numbers",
+ "wechat_webhook_url": "WeChat Webhook URL",
+ "bark_webhook_url": "Bark Webhook URL",
+ "bark_interrupt_level": "Bark Interrupt Level",
+ "bark_sound": "Bark Sound",
+ "ntfy_server_url": "Ntfy Server URL",
+ "ntfy_tags": "Ntfy Tag",
+ "ntfy_email": "Ntfy Email",
+ "ntfy_action_url": "Ntfy Action URL",
+ "telegram_api_token": "Telegram API Token",
+ "telegram_chat_id": "Telegram Chat ID",
+ "smtp_server": "Smtp Server",
+ "email_username": "Email Username",
+ "email_password": "Email Password",
+ "sender_email": "Sender Email",
+ "sender_name": "Sender Name",
+ "recipient_email": "Recipient Email",
+ "configure_platform_cookies": "Configure platform cookies",
+ "douyin_cookie": "Douyin Cookie",
+ "tiktok_cookie": "Tiktok Cookie",
+ "kuaishou_cookie": "Kuaishou Cookie",
+ "huya_cookie": "Huya Cookie",
+ "douyu_cookie": "Douyu Cookie",
+ "yy_cookie": "YY Cookie",
+ "bilibili_cookie": "Bilibili Cookie",
+ "xhs_cookie": "Xiaohongshu Cookie",
+ "bigo_cookie": "Bigo Cookie",
+ "blued_cookie": "Blued Cookie",
+ "soop_cookie": "Soop Cookie",
+ "netease_cookie": "NeteaseCC Cookie",
+ "qiandurebo_cookie": "Qiandu Cookie",
+ "pandalive_cookie": "Pandalive Cookie",
+ "maoerfm_cookie": "MaoerFM Cookie",
+ "winktv_cookie": "Winktv Cookie",
+ "flextv_cookie": "Flextv Cookie",
+ "look_cookie": "LookLive Cookie",
+ "popkontv_cookie": "Popkontv Cookie",
+ "twitcasting_cookie": "Twitcasting Cookie",
+ "baidu_cookie": "Baidu Cookie",
+ "weibo_cookie": "Weibo Cookie",
+ "kugou_cookie": "Kugou Cookie",
+ "twitch_cookie": "Twitch Cookie",
+ "liveme_cookie": "Liveme Cookie",
+ "huajiao_cookie": "Huajiao Cookie",
+ "liuxing_cookie": "Liuxing Cookie",
+ "showroom_cookie": "Showroom Cookie",
+ "acfun_cookie": "Acfun Cookie",
+ "changliao_cookie": "Changliao Cookie",
+ "yinbo_cookie": "Yinbo Cookie",
+ "inke_cookie": "Inke Cookie",
+ "zhihu_cookie": "Zhihu Cookie",
+ "chzzk_cookie": "Chzzk Cookie",
+ "haixiu_cookie": "Haixiu Cookie",
+ "vvxq_cookie": "Vvxq Cookie",
+ "17live_cookie": "17Live Cookie",
+ "lang_cookie": "Lang Cookie",
+ "piaopiao_cookie": "Piaopiao Cookie",
+ "6room_cookie": "SixroomCookie",
+ "lehai_cookie": "Lehai Cookie",
+ "catshow_cookie": "Catshow Cookie",
+ "shopee_cookie": "Shopee Cookie",
+ "youtube_cookie": "Youtube Cookie",
+ "taobao_cookie": "Taobao Cookie",
+ "jd_cookie": "Jingdong Cookie",
+ "configure_platform_accounts": "Configure Platform Accounts",
+ "sooplive_username": "Soop Username",
+ "sooplive_password": "Soop Password",
+ "flextv_username": "FlexTV Username",
+ "flextv_password": "FlexTV Password",
+ "popkontv_username": "PopkonTV Username",
+ "popkontv_password": "PopkonTV Password",
+ "twitcasting_account_type": "Twitcasting Account Type",
+ "twitcasting_username": "Twitcasting Username",
+ "twitcasting_password": "Twitcasting Password",
+ "success_restore_tip": "Tip: Default configuration has been restored",
+ "query_restore_config_tip": "Are you sure you want to restore the default configuration?",
+ "success_save_config_tip": "Tip: Configuration has been saved",
+ "Chinese": "Simplified Chinese",
+ "English": "English",
+ "select_btn_tip": "Select the save path",
+ "switch_language": "Switch language",
+ "switch_video_format": "Switch video recording format",
+ "switch_recording_quality": "Switch video recording quality",
+ "switch_account_type": "Switch account type",
+ "switch_language_tip": "Tip: It is recommended to restart the program after switching languages"
+ },
+ "about_page": {
+ "about_project": "About This Application",
+ "ui_version": "UI Version",
+ "kernel_version": "Kernel Version",
+ "license": "Open Source License",
+ "introduction": "Software Introduction",
+ "feature": "Feature Highlights",
+ "support_platforms": "Supported Platforms",
+ "customize_recording": "Custom Params",
+ "open_source": "Open Source",
+ "automatic_transcoding": "Auto Transcode",
+ "status_push": "Live Status",
+ "developer": "Developer",
+ "author": "Author",
+ "view_update": "View Updates",
+ "view_docs": "View Documentation",
+ "update": "Version Update"
+ },
+ "base": {
+ "confirm": "Confirm",
+ "sure": "OK",
+ "cancel": "Cancel",
+ "yes": "Yes",
+ "no": "No",
+ "open": "Open",
+ "close": "Close",
+ "enabled": "Enabled",
+ "disabled": "Disabled"
+ },
+ "video_quality": {
+ "OD": "Original",
+ "UHD": "Ultra HD",
+ "HD": "High Definition",
+ "SD": "Standard Definition",
+ "LD": "Low Definition"
+ },
+ "help_dialog": {
+ "shortcut_key_help": "Shortcut Key Help",
+ "description": "Here are the supported shortcut keys:",
+ "main_page": "Main Page",
+ "search_recording": "Search Recording: Ctrl + F - Open the search dialog to find specific live streams.",
+ "refresh_list": "Refresh List: Ctrl + R - Refresh the current list of live streams.",
+ "add_new_recording": "Add Recording: Alt + N - Open the new live stream creation panel.",
+ "start_all": "Batch Start: Alt + B - Start monitoring all or selected live streams in the list.",
+ "stop_all": "Batch Stop: Alt + P - Stop monitoring all or selected live streams in the list.",
+ "delete_all": "Batch Delete: Alt + D - Delete all or selected live streams from the list.",
+ "settings_page": "Settings Page",
+ "save_configuration": "Save Configuration: Ctrl + S - Save the current configuration settings.",
+ "view_help": "View Help: Alt + H - Open this help dialog."
+ },
+ "install_manager": {
+ "complete": "Completed",
+ "error": "Error",
+ "installing": "Installing",
+ "reinstall": "Reinstall",
+ "installed": "Installed",
+ "wait_install": "Waiting for installation",
+ "install_now": "Install Now",
+ "later_on": "Later",
+ "install_guide": "Component Installation Guide",
+ "install_tip": "This program requires the following components to function properly.",
+ "lack_components": "Missing Required Components"
+ }
+}
\ No newline at end of file
diff --git a/locales/zh_CN.json b/locales/zh_CN.json
new file mode 100644
index 0000000..e58a5cb
--- /dev/null
+++ b/locales/zh_CN.json
@@ -0,0 +1,326 @@
+{
+ "sidebar": {
+ "home": "主界面",
+ "settings": "设置",
+ "about": "说明",
+ "light_theme": "白天模式",
+ "dark_theme": "黑夜模式",
+ "theme_color": "主题颜色",
+ "toggle_day_theme": "切换为白天模式",
+ "toggle_night_theme": "切换为黑夜模式",
+ "colors": "颜色列表"
+ },
+ "home_page": {
+ "recording_list": "录制列表",
+ "search": "搜索",
+ "add_record": "新增录制",
+ "refresh": "刷新",
+ "batch_start": "批量开始",
+ "batch_stop": "批量停止",
+ "batch_delete": "批量删除",
+ "refresh_success_tip": "提示:刷新成功",
+ "batch_delete_confirm_tip": "您确定要删除所有全部已选中的录制列表吗?",
+ "clear_all_confirm_tip": "您确定要清空录制列表吗?",
+ "edit_record": "编辑录制",
+ "add_recording_success_tip": "提示:直播间添加成功!",
+ "delete_recording_success_tip": "提示:直播间删除成功!",
+ "stop_recording_success_tip": "提示:直播间停止监控成功!",
+ "start_recording_success_tip": "提示:直播间开始监控成功!",
+ "not_search_result": "提示:未搜索到任何结果"
+ },
+ "recording_dialog": {
+ "input_live_link": "输入直播间地址",
+ "example": "例如",
+ "select_resolution": "选择录制清晰度",
+ "input_anchor_name": "输入主播名称",
+ "default_input": "可默认不填",
+ "input_record_format": "输入录制格式-默认ts",
+ "input_save_path": "输入录制保存路径",
+ "is_segment_enabled": "分段录制是否开启",
+ "segment_record_time": "录制分段时间",
+ "input_segment_time": "请输入分段时间(单位秒)",
+ "scheduled_recording": "每日定时监听是否开启",
+ "scheduled_start_time": "每日监听开始时间",
+ "monitor_hours": "每日监听小时数",
+ "batch_input_tip": "批量录入(每行一条记录)",
+ "single_input": "单个录入",
+ "batch_input": "批量录入",
+ "live_room": "直播间",
+ "platform_not_supported_tip": "⚠\uFE0F 暂不支持该平台录制",
+ "pick_time": "选择时间",
+ "pick_time_tip": "配置定时时间",
+ "time_out_of_range": "选择时间超出范围",
+ "pick_time_slot": "选择您的时间段",
+ "hour_label_text": "时",
+ "minute_label_text": "分"
+ },
+ "search_dialog": {
+ "search_keyword": "输入搜索关键词"
+ },
+ "recording_manager": {
+ "monitor_stopped": "停止监控中",
+ "is_live": "直播中",
+ "live_room": "直播间",
+ "recorded": "已录制",
+ "push_content": "直播间状态更新:[room_name] 正在直播中,时间:[time]",
+ "status_notify": "直播间状态更新通知",
+ "platform_not_supported_tip": "暂不支持该平台录制",
+ "STOPPED_MONITORING": "直播间已停止监控",
+ "MONITORING": "当前该直播间未开播, 持续监控中",
+ "RECORDING": "直播间正在录制中",
+ "NOT_RECORDING": "直播间正在直播, 未在录制中",
+ "STATUS_CHECKING": "直播状态正在检测中",
+ "NOT_IN_SCHEDULED_CHECK": "当前未在定时检测范围内, 停止直播检测中",
+ "PREPARING_RECORDING": "主播正在直播中, 准备开始录制",
+ "RECORDING_ERROR": "直播录制失败, 等待重试",
+ "NOT_RECORDING_SPACE": "磁盘空间不足, 无法录制",
+ "not_disk_space_tip": "⚠️ 磁盘存储空间不足, 停止录制"
+ },
+ "stream_manager": {
+ "record_stream_error": "直播源录制出错"
+ },
+ "recording_card": {
+ "stop_monitor_tip": "提示:已停止直播监控👁️",
+ "start_monitor_tip": "提示:已开启直播监控👁️",
+ "please_stop_monitor_tip": "提示:请先停止直播监控👁️🗨️",
+ "please_start_monitor_tip": "提示:请先开启直播监控👁️🗨️",
+ "is_not_live_tip": "提示:该直播间未开播",
+ "pre_record_tip": "提示:准备开始录制",
+ "stop_record_tip": "提示:已停止直播间录制️",
+ "no_folder_tip": "提示:该直播间文件夹不存在",
+ "edit_record_config": "编辑录制配置",
+ "delete_monitor": "删除监控",
+ "open_folder": "打开文件夹",
+ "recording_info": "录制信息",
+ "anchor_name": "主播名称",
+ "live_link": "直播链接",
+ "live_title": "直播标题",
+ "record_format": "录制格式",
+ "record_quality": "录制画质",
+ "segment_record": "分段录制",
+ "segment_time": "分段时长",
+ "monitor_status": "监控状态",
+ "scheduled_recording": "定时检测",
+ "scheduled_time_range": "定时检测范围",
+ "save_path": "保存路径",
+ "recording_status": "录制状态",
+ "start_record": "开始录制",
+ "stop_record": "停止录制",
+ "start_monitor": "开始监控",
+ "stop_monitor": "停止监控",
+ "seconds": "秒",
+ "delete_confirm_tip": "确定要删除该直播间吗?",
+ "no_recording_dir_tip": "该直播间暂无保存的录制文件",
+ "none": "无",
+ "recording_card_tip": "点击选中",
+ "use_proxy": "使用代理"
+ },
+ "settings_page": {
+ "recording_settings": "录制设置",
+ "push_settings": "推送设置",
+ "basic_settings": "基础设置",
+ "cookies_settings": "Cookie设置",
+ "accounts_settings": "账号设置",
+ "select": "选择",
+ "program_config": "程序的基本设置",
+ "restore_defaults": "恢复默认设置",
+ "program_language": "程序语言",
+ "filename_includes_title": "文件名包含标题",
+ "live_recording_path": "直播录制保存路径",
+ "blank_for_default_path": "不填则默认",
+ "remove_emojis": "去除emoji符号",
+ "name_rules": "文件(夹)命名规则",
+ "author": "作者",
+ "time": "时间",
+ "title": "标题",
+ "proxy_settings": "代理设置",
+ "is_proxy_enabled": "设置是否使用代理及相关配置",
+ "enable_proxy": "开启代理",
+ "proxy_address": "代理地址",
+ "skip_proxy_detection": "跳过代理检测",
+ "recording_options": "录制选项",
+ "advanced_config": "配置录制高级选项",
+ "video_record_format": "视频录制格式",
+ "recording_quality": "录制清晰度",
+ "loop_time": "循环时间(秒)",
+ "is_segmented_recording_enabled": "分段录制是否开启",
+ "force_https": "强制启用https录制",
+ "space_threshold": "录制空间剩余阈值(gb)",
+ "segment_time": "视频分段时间(秒)",
+ "convert_mp4": "录制完成后转为mp4格式",
+ "delete_original": "追加格式后删除原文件",
+ "generate_timestamps_subtitle": "生成时间字幕文件",
+ "custom_script": "录制完成后执行自定义脚本",
+ "script_command": "自定义脚本执行命令",
+ "default_platform_with_proxy": "默认使用代理录制的平台",
+ "push_notifications": "推送通知",
+ "stream_start_notification_enabled": "直播状态推送开关",
+ "open_broadcast_push_enabled": "开播推送开启",
+ "close_broadcast_push_enabled": "关播推送开启",
+ "only_notify_no_record": "仅通知不录制",
+ "notify_loop_time": "只推送循环时间",
+ "custom_push_settings": "自定义推送设置",
+ "personalized_notification_content_behavior": "个性化通知内容及行为",
+ "custom_push_title": "自定义推送标题",
+ "custom_open_broadcast_content": "自定义开播推送内容",
+ "custom_close_broadcast_content": "自定义关播推送内容",
+ "push_channels": "推送渠道",
+ "dingtalk": "钉钉",
+ "wechat": "微信",
+ "select_and_enable_channels": "选择并启用您希望使用的推送渠道",
+ "channel_configuration": "渠道配置",
+ "configure_enabled_channels": "配置您已启用的通知渠道",
+ "dingtalk_webhook_url": "钉钉推送接口地址",
+ "dingtalk_at_objects": "钉钉通知@对象",
+ "dingtalk_at_all": "钉钉通知@全体",
+ "dingtalk_webhook_hint": "填写钉钉群Webhook连接",
+ "dingtalk_phone_numbers_hint": "填写钉钉手机号",
+ "wechat_webhook_url": "微信推送接口地址",
+ "bark_webhook_url": "Bark推送接口地址",
+ "bark_interrupt_level": "Bark推送中断级别",
+ "bark_sound": "Bark推送铃声",
+ "ntfy_server_url": "Ntfy推送地址",
+ "ntfy_tags": "Ntfy推送标签",
+ "ntfy_email": "Ntfy推送邮箱",
+ "ntfy_action_url": "Ntfy动作地址",
+ "telegram_api_token": "Telegram API令牌",
+ "telegram_chat_id": "Telegram Chat ID",
+ "smtp_server": "smtp服务器",
+ "email_username": "邮箱登录账号",
+ "email_password": "发件人密码(授权码)",
+ "sender_email": "发件人邮箱",
+ "sender_name": "发件人显示昵称",
+ "recipient_email": "收件人邮箱",
+ "configure_platform_cookies": "配置各平台Cookie参数",
+ "douyin_cookie": "抖音Cookie",
+ "tiktok_cookie": "TikTok Cookie",
+ "kuaishou_cookie": "快手Cookie",
+ "huya_cookie": "虎牙Cookie",
+ "douyu_cookie": "斗鱼Cookie",
+ "yy_cookie": "YY Cookie",
+ "bilibili_cookie": "哔哩哔哩Cookie",
+ "xhs_cookie": "小红书Cookie",
+ "bigo_cookie": "Bigo Cookie",
+ "blued_cookie": "Blued Cookie",
+ "soop_cookie": "Soop Cookie",
+ "netease_cookie": "网易CC Cookie",
+ "qiandurebo_cookie": "千度热播Cookie",
+ "pandalive_cookie": "Pandalive Cookie",
+ "maoerfm_cookie": "猫耳FM Cookie",
+ "winktv_cookie": "Winktv Cookie",
+ "flextv_cookie": "Flextv Cookie",
+ "look_cookie": "LookLive Cookie",
+ "popkontv_cookie": "Popkontv Cookie",
+ "twitcasting_cookie": "Twitcasting Cookie",
+ "baidu_cookie": "百度Cookie",
+ "weibo_cookie": "微博Cookie",
+ "kugou_cookie": "酷狗Cookie",
+ "twitch_cookie": "Twitch Cookie",
+ "liveme_cookie": "Liveme Cookie",
+ "huajiao_cookie": "花椒Cookie",
+ "liuxing_cookie": "流星Cookie",
+ "showroom_cookie": "Showroom Cookie",
+ "acfun_cookie": "Acfun Cookie",
+ "changliao_cookie": "畅聊Cookie",
+ "yinbo_cookie": "音播Cookie",
+ "inke_cookie": "映客Cookie",
+ "zhihu_cookie": "知乎Cookie",
+ "chzzk_cookie": "Chzzk Cookie",
+ "haixiu_cookie": "嗨秀Cookie",
+ "vvxq_cookie": "VV星球Cookie",
+ "17live_cookie": "17Live Cookie",
+ "lang_cookie": "浪Cookie",
+ "piaopiao_cookie": "飘飘Cookie",
+ "6room_cookie": "六间房Cookie",
+ "lehai_cookie": "乐嗨 Cookie",
+ "catshow_cookie": "Catshow Cookie",
+ "shopee_cookie": "Shopee Cookie",
+ "youtube_cookie": "Youtube Cookie",
+ "taobao_cookie": "淘宝Cookie",
+ "jd_cookie": "京东Cookie",
+ "configure_platform_accounts": "配置各平台账号密码",
+ "sooplive_username": "Soop账号",
+ "sooplive_password": "Soop密码",
+ "flextv_username": "FlexTV账号",
+ "flextv_password": "FlexTV密码",
+ "popkontv_username": "PopkonTV账号",
+ "popkontv_password": "PopkonTV密码",
+ "twitcasting_account_type": "Twitcasting账号类型",
+ "twitcasting_username": "Twitcasting账号",
+ "twitcasting_password": "Twitcasting密码",
+ "success_restore_tip": "提示:已恢复默认配置",
+ "query_restore_config_tip": "您确定要恢复默认配置吗?",
+ "success_save_config_tip": "提示:当前配置已保存",
+ "Chinese": "简体中文",
+ "English": "English",
+ "select_btn_tip": "手动选择保存路径",
+ "switch_language": "切换语言",
+ "switch_video_format": "切换视频录制格式",
+ "switch_recording_quality": "切换视频录制质量",
+ "switch_account_type": "切换账号类型",
+ "switch_language_tip": "提示: 建议切换语言后重启程序"
+ },
+ "about_page": {
+ "about_project": "关于本程序",
+ "ui_version": "UI版本",
+ "kernel_version": "内核版本",
+ "license": "开源许可证",
+ "introduction": "软件简介",
+ "feature": "功能亮点",
+ "support_platforms": "支持主流平台",
+ "customize_recording": "自定义录制参数",
+ "open_source": "开源 & 高效",
+ "automatic_transcoding": "自动转码",
+ "status_push": "直播状态推送",
+ "developer": "开发者",
+ "author": "作者",
+ "view_update": "查看更新",
+ "view_docs": "查看文档",
+ "update": "版本更新"
+ },
+ "base": {
+ "confirm": "确认",
+ "sure": "确定",
+ "cancel": "取消",
+ "yes": "是",
+ "no": "否",
+ "open": "打开",
+ "close": "关闭",
+ "enabled": "已开启",
+ "disabled": "未开启"
+ },
+ "video_quality": {
+ "OD": "原画",
+ "UHD": "超清",
+ "HD": "高清",
+ "SD": "标清",
+ "LD": "流畅"
+ },
+ "help_dialog": {
+ "shortcut_key_help": "快捷键帮助",
+ "description": "以下是程序支持的快捷键说明:",
+ "main_page": "主界面",
+ "search_recording": "搜索录制: Ctrl + F - 打开搜索框以查找特定的录制直播间",
+ "refresh_list": "刷新列表: Ctrl + R - 刷新当前直播间列表",
+ "add_new_recording": "新增录制: Alt + N - 打开新建录制直播间面板",
+ "start_all": "批量开始: Alt + B - 开始监控列表所有或已选定的直播间",
+ "stop_all": "批量停止: Alt + P - 停止监控列表所有或已选定的直播间",
+ "delete_all": "批量删除: Alt + D - 删除列表所有或已选定的直播间",
+ "settings_page": "设置界面",
+ "save_configuration": "保存配置: Ctrl + S - 保存当前配置设置",
+ "view_help": "查看帮助: Alt + H - 打开此帮助对话框"
+ },
+ "install_manager": {
+ "complete": "完成",
+ "error": "错误",
+ "installing": "正在安装",
+ "reinstall": "重新安装",
+ "installed": "已安装",
+ "wait_install": "等待安装",
+ "install_now": "立即安装",
+ "later_on": "稍后再说",
+ "install_guide": "组件安装向导",
+ "install_tip": "本程序需要以下组件来完成特定功能,请保持网络连接并耐心等待安装完成。",
+ "lack_components": "缺少必要组件"
+ }
+}
\ No newline at end of file
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..7ee4570
--- /dev/null
+++ b/main.py
@@ -0,0 +1,45 @@
+
+import multiprocessing
+import os
+
+import flet as ft
+from screeninfo import get_monitors
+
+from app.app_manager import App, execute_dir
+
+
+def main(page: ft.Page):
+ page.title = "StreamCap"
+ page.theme_mode = ft.ThemeMode.LIGHT
+
+ screens = get_monitors()
+ if screens:
+ screen = screens[0]
+ screen_width = screen.width
+ screen_height = screen.height
+ page.window.width = int(screen_width * 0.65)
+ page.window.height = int(screen_height * 0.65)
+
+ page.window.icon = os.path.join(execute_dir, "assets", "icon.ico")
+ page.window.center()
+ app = App(page)
+
+ def route_change(e):
+ tr = ft.TemplateRoute(e.route)
+ if tr.match("/"):
+ page.run_task(app.switch_page, "home")
+ elif tr.match("/settings"):
+ page.run_task(app.switch_page, "settings")
+ elif tr.match("/about"):
+ page.run_task(app.switch_page, "about")
+ else:
+ page.go("/")
+
+ page.on_route_change = route_change
+ page.update()
+ route_change(ft.RouteChangeEvent(route=page.route))
+
+
+if __name__ == "__main__":
+ multiprocessing.freeze_support()
+ ft.app(target=main, assets_dir="assets")
diff --git a/poetry.toml b/poetry.toml
new file mode 100644
index 0000000..9a48dd8
--- /dev/null
+++ b/poetry.toml
@@ -0,0 +1,4 @@
+[virtualenvs]
+in-project = true
+create = true
+prefer-active-python = true
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..67c9876
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,60 @@
+[project]
+name = "StreamCap"
+version = "0.0.1"
+description = "An HTTP Stream Capture Tool."
+authors = [{ name = "Hmily" }]
+license = {text = "Apache-2.0"}
+readme = "README.md"
+url='https://github.com/ihmily/StreamCap'
+requires-python = ">=3.10,<4.0"
+
+dependencies = [
+ "flet",
+ "flet-desktop",
+ "streamget>=4.0.3",
+ "certifi>=2025.1.31",
+ "screeninfo>=0.8.1",
+ "httpx[http2]>=0.28.1",
+ "PyExecJS>=1.5.1"
+]
+
+[project.urls]
+Documentation = "https://github.com/ihmily/StreamCap"
+Homepage = "https://github.com/ihmily/StreamCap"
+Source = "https://github.com/ihmily/StreamCap"
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
+
+[tool.flet]
+org = "io.github.ihmily.streamcap"
+product = "StreamCap"
+company = "io.github.ihmily.streamcap"
+copyright = "Copyright (C) 2025 by Hmily"
+compile.app = false
+compile.packages = false
+compile.cleanup = true
+
+[tool.flet.app]
+path ="."
+
+[tool.poetry]
+package-mode = false
+packages = [
+ { include = "app", from = "." },
+]
+
+[tool.poetry.dependencies]
+flet = { version = "^0.27.6", extras = ["all"] }
+screeninfo = "~0.8.1"
+httpx = "^0.28.1"
+certifi = ">=2025.1.31"
+PyExecJS = "~1.5.1"
+streamget = ">=4.0.3"
+
+[tool.poetry.group.lint]
+optional = true
+
+[tool.poetry.group.lint.dependencies]
+ruff = "~0.9.10"
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..8e4ef5f
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,9 @@
+flet
+flet-desktop
+aiofiles>=24.1.0
+httpx>=0.28.1
+streamget>=4.0.3
+certifi>=2025.1.31
+screeninfo>=0.8.1
+PyExecJS>=1.5.1
+