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 +
+

+ Python version + + Supported Platforms + + Latest Release + + Downloads +

+
+ 简体中文 / English +

+ + +StreamCap 是一个多平台直播流录制客户端,覆盖 40+ 国内外主流直播平台,支持批量录制、循环监控、定时监控和自动转码等功能。 + +## ✨功能特性 + +- **循环监控**:实时监控直播间状态,开播即录。 +- **定时任务**:根据设定时间范围检查直播间状态。 +- **多种输出格式**:支持 ts、flv、mkv、mov、mp4、mp3、m4a 等格式。 +- **自动转码**:录制完成后自动转码为 mp4 格式。 +- **消息推送**:支持直播状态推送,及时获取开播通知。 + +## 📸录制界面 + +![StreamCap Interface](D:\Git仓库\Github\StreamCap\assets\images\example01.png) + +## 🛠️快速开始 + +### 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 +
+

+ Python version + + Supported Platforms + + Latest Release + + Downloads +

+
+ English / Chinese +

+ + +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 + +![StreamCap Interface](D:\Git仓库\Github\StreamCap\assets\images\example02.jpg) + +## 🛠️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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%0AWHyQ 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

~CFg@7CVpTetVl>aXmw0NWO|EpWY;`V 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