From 8ce3aed3b972b04c7f7b914ea00e8dddcc148d6c Mon Sep 17 00:00:00 2001
From: ihmily <114978440+ihmily@users.noreply.github.com>
Date: Mon, 24 Mar 2025 19:55:40 +0800
Subject: [PATCH] open source
---
.github/dependabot.yml | 15 +
.github/workflows/python-lint.yml | 40 +
.github/workflows/test.yml | 23 +
.gitignore | 32 +-
.ruff.toml | 78 ++
README.md | 239 ++++
README_EN.md | 236 ++++
app/__init__.py | 8 +
app/app_manager.py | 89 ++
app/core/__init__.py | 3 +
app/core/config_manager.py | 152 +++
app/core/language_manager.py | 45 +
app/core/platform_handlers/__init__.py | 183 +++
app/core/platform_handlers/base.py | 117 ++
app/core/platform_handlers/handlers.py | 1019 ++++++++++++++++
app/core/record_manager.py | 339 ++++++
app/core/stream_manager.py | 399 +++++++
app/installation_manager.py | 160 +++
app/messages/__init__.py | 0
app/messages/message_pusher.py | 71 ++
app/messages/notification_service.py | 189 +++
app/models/__init__.py | 0
app/models/recording_model.py | 116 ++
app/models/recording_status_model.py | 17 +
app/models/video_format_model.py | 15 +
app/models/video_quality_model.py | 13 +
app/scripts/__init__.py | 0
app/scripts/node_install.py | 239 ++++
app/ui/__init__.py | 0
app/ui/base_page.py | 18 +
app/ui/components/__init__.py | 0
app/ui/components/card_dialog.py | 62 +
app/ui/components/help_dialog.py | 42 +
app/ui/components/recording_card.py | 320 +++++
app/ui/components/recording_dialog.py | 359 ++++++
app/ui/components/search_dialog.py | 57 +
app/ui/components/show_snackbar.py | 25 +
app/ui/navigation/__init__.py | 0
app/ui/navigation/sidebar.py | 183 +++
app/ui/themes/__init__.py | 4 +
app/ui/themes/theme.py | 71 ++
app/ui/themes/theme_manager.py | 44 +
app/ui/views/__init__.py | 0
app/ui/views/about_view.py | 248 ++++
app/ui/views/home_view.py | 258 +++++
app/ui/views/settings_view.py | 1027 +++++++++++++++++
app/utils/__init__.py | 1 +
app/utils/delay.py | 13 +
app/utils/logger.py | 31 +
app/utils/utils.py | 246 ++++
.../AlibabaPuHuiTi-2-45-Light-Mac.otf | Bin 0 -> 7359108 bytes
.../AlibabaPuHuiTi-2-45-Light.otf | Bin 0 -> 7359108 bytes
assets/icon.ico | Bin 0 -> 1081406 bytes
assets/images/app_icon.ico | Bin 0 -> 1081406 bytes
assets/images/app_icon.png | Bin 0 -> 82521 bytes
assets/images/example01.png | Bin 0 -> 49958 bytes
assets/images/example02.jpg | Bin 0 -> 51836 bytes
config/default_settings.json | 55 +
config/language.json | 4 +
config/version.json | 21 +
locales/en.json | 324 ++++++
locales/zh_CN.json | 326 ++++++
main.py | 45 +
poetry.toml | 4 +
pyproject.toml | 60 +
requirements.txt | 9 +
66 files changed, 7678 insertions(+), 16 deletions(-)
create mode 100644 .github/dependabot.yml
create mode 100644 .github/workflows/python-lint.yml
create mode 100644 .github/workflows/test.yml
create mode 100644 .ruff.toml
create mode 100644 README.md
create mode 100644 README_EN.md
create mode 100644 app/__init__.py
create mode 100644 app/app_manager.py
create mode 100644 app/core/__init__.py
create mode 100644 app/core/config_manager.py
create mode 100644 app/core/language_manager.py
create mode 100644 app/core/platform_handlers/__init__.py
create mode 100644 app/core/platform_handlers/base.py
create mode 100644 app/core/platform_handlers/handlers.py
create mode 100644 app/core/record_manager.py
create mode 100644 app/core/stream_manager.py
create mode 100644 app/installation_manager.py
create mode 100644 app/messages/__init__.py
create mode 100644 app/messages/message_pusher.py
create mode 100644 app/messages/notification_service.py
create mode 100644 app/models/__init__.py
create mode 100644 app/models/recording_model.py
create mode 100644 app/models/recording_status_model.py
create mode 100644 app/models/video_format_model.py
create mode 100644 app/models/video_quality_model.py
create mode 100644 app/scripts/__init__.py
create mode 100644 app/scripts/node_install.py
create mode 100644 app/ui/__init__.py
create mode 100644 app/ui/base_page.py
create mode 100644 app/ui/components/__init__.py
create mode 100644 app/ui/components/card_dialog.py
create mode 100644 app/ui/components/help_dialog.py
create mode 100644 app/ui/components/recording_card.py
create mode 100644 app/ui/components/recording_dialog.py
create mode 100644 app/ui/components/search_dialog.py
create mode 100644 app/ui/components/show_snackbar.py
create mode 100644 app/ui/navigation/__init__.py
create mode 100644 app/ui/navigation/sidebar.py
create mode 100644 app/ui/themes/__init__.py
create mode 100644 app/ui/themes/theme.py
create mode 100644 app/ui/themes/theme_manager.py
create mode 100644 app/ui/views/__init__.py
create mode 100644 app/ui/views/about_view.py
create mode 100644 app/ui/views/home_view.py
create mode 100644 app/ui/views/settings_view.py
create mode 100644 app/utils/__init__.py
create mode 100644 app/utils/delay.py
create mode 100644 app/utils/logger.py
create mode 100644 app/utils/utils.py
create mode 100644 assets/fonts/AlibabaPuHuiTi-2/AlibabaPuHuiTi-2-45-Light-Mac.otf
create mode 100644 assets/fonts/AlibabaPuHuiTi-2/AlibabaPuHuiTi-2-45-Light.otf
create mode 100644 assets/icon.ico
create mode 100644 assets/images/app_icon.ico
create mode 100644 assets/images/app_icon.png
create mode 100644 assets/images/example01.png
create mode 100644 assets/images/example02.jpg
create mode 100644 config/default_settings.json
create mode 100644 config/language.json
create mode 100644 config/version.json
create mode 100644 locales/en.json
create mode 100644 locales/zh_CN.json
create mode 100644 main.py
create mode 100644 poetry.toml
create mode 100644 pyproject.toml
create mode 100644 requirements.txt
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 0000000000000000000000000000000000000000..7db59bdd5eae844e6f08a2a73f9f9093953a2d4a
GIT binary patch
literal 7359108
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