Repository: Borber/SBtream Branch: master Commit: 9c8ccdd04788 Files: 142 Total size: 198.2 KB Directory structure: gitextract_1s2z3e_6/ ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── release_cli.yml │ ├── release_gui.yml │ └── rust-clippy.yml ├── .gitignore ├── Cargo.toml ├── LICENSE-MIT ├── LICENSE-UNLICENSE ├── README.md ├── TODO.md ├── build/ │ ├── build-host-release │ ├── build-host-release.ps1 │ ├── build-release │ └── build-release-zigbuild ├── config.toml ├── crates/ │ ├── cli/ │ │ ├── CHANGELOG.md │ │ ├── Cargo.toml │ │ ├── README.md │ │ └── src/ │ │ ├── common.rs │ │ ├── config.rs │ │ ├── main.rs │ │ └── util.rs │ ├── core/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── common.rs │ │ ├── error.rs │ │ ├── lib.rs │ │ ├── live/ │ │ │ ├── afreeca.rs │ │ │ ├── bili.rs │ │ │ ├── cc.rs │ │ │ ├── douyin.rs │ │ │ ├── douyu.rs │ │ │ ├── flex.rs │ │ │ ├── huajiao.rs │ │ │ ├── huya.rs │ │ │ ├── inke.rs │ │ │ ├── kk.rs │ │ │ ├── ks.rs │ │ │ ├── mht.rs │ │ │ ├── mod.rs │ │ │ ├── now.rs │ │ │ ├── panda.rs │ │ │ ├── qf.rs │ │ │ ├── twitch.rs │ │ │ ├── wink.rs │ │ │ └── yqs.rs │ │ └── util.rs │ ├── danmu/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── danmu/ │ │ │ ├── afreeca.rs │ │ │ ├── bili.rs │ │ │ ├── cc.rs │ │ │ ├── douyin.rs │ │ │ ├── douyu.rs │ │ │ ├── flex.rs │ │ │ ├── huajiao.rs │ │ │ ├── huya.rs │ │ │ ├── inke.rs │ │ │ ├── kk.rs │ │ │ ├── ks.rs │ │ │ ├── mht.rs │ │ │ ├── mod.rs │ │ │ ├── now.rs │ │ │ ├── panda.rs │ │ │ ├── qf.rs │ │ │ ├── wink.rs │ │ │ └── yqs.rs │ │ ├── error.rs │ │ └── lib.rs │ ├── gui/ │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── .vscode/ │ │ │ └── extensions.json │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.css │ │ │ ├── App.tsx │ │ │ ├── components/ │ │ │ │ ├── Control.tsx │ │ │ │ ├── GoodItem.tsx │ │ │ │ ├── Live.tsx │ │ │ │ ├── Panel.tsx │ │ │ │ ├── SideBar.tsx │ │ │ │ ├── SideItem.tsx │ │ │ │ └── TopBar.tsx │ │ │ ├── css/ │ │ │ │ ├── Chart.css │ │ │ │ ├── Control.css │ │ │ │ ├── Good.css │ │ │ │ ├── GoodItem.css │ │ │ │ ├── Home.css │ │ │ │ ├── Live.css │ │ │ │ ├── Panel.css │ │ │ │ ├── Setting.css │ │ │ │ ├── SideBar.css │ │ │ │ ├── SideItem.css │ │ │ │ └── TopBar.css │ │ │ ├── icon/ │ │ │ │ └── icon.tsx │ │ │ ├── index.tsx │ │ │ ├── model/ │ │ │ │ ├── Live.tsx │ │ │ │ ├── Record.tsx │ │ │ │ └── Resp.tsx │ │ │ ├── pages/ │ │ │ │ ├── Chart.tsx │ │ │ │ ├── Good.tsx │ │ │ │ ├── Home.tsx │ │ │ │ └── Setting.tsx │ │ │ └── styles.css │ │ ├── src-tauri/ │ │ │ ├── .gitignore │ │ │ ├── Cargo.toml │ │ │ ├── build.rs │ │ │ ├── icons/ │ │ │ │ └── icon.icns │ │ │ ├── src/ │ │ │ │ ├── command/ │ │ │ │ │ ├── live.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── refresh.rs │ │ │ │ │ └── subscribe.rs │ │ │ │ ├── common.rs │ │ │ │ ├── config.rs │ │ │ │ ├── database/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── subscribe.rs │ │ │ │ ├── main.rs │ │ │ │ ├── manager/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── refresh.rs │ │ │ │ ├── model.rs │ │ │ │ ├── resp.rs │ │ │ │ ├── service/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── subscribe.rs │ │ │ │ ├── setup.rs │ │ │ │ └── util.rs │ │ │ └── tauri.conf.json │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── marcos/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── lib.rs │ └── status/ │ ├── Cargo.toml │ └── src/ │ ├── common.rs │ ├── error.rs │ ├── lib.rs │ └── status/ │ ├── bili.rs │ ├── cc.rs │ ├── douyin.rs │ └── mod.rs ├── doc/ │ ├── 配置说明.md │ └── 额外安装.md ├── justfile └── script/ └── gui_version.lua ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: daily - package-ecosystem: cargo directory: / schedule: interval: daily ================================================ FILE: .github/workflows/release_cli.yml ================================================ name: Build CLI Releases permissions: contents: write on: push: tags: - v_cli.* env: CARGO_TERM_COLOR: always jobs: create_release: runs-on: ubuntu-latest outputs: changes: ${{ steps.changelog_reader.outputs.changes }} version: ${{ steps.changelog_reader.outputs.VERSION }} steps: - uses: actions/checkout@v4 - name: Get version number id: get_version run: | VERSION=${GITHUB_REF#refs/tags/} VERSION=${VERSION/v_cli./} echo "::set-output name=version::$VERSION" - name: Changelog Reader id: changelog_reader uses: mindsers/changelog-reader-action@v2.2.2 with: path: './crates/cli/CHANGELOG.md' version: ${{ steps.get_version.outputs.version }} build-cross: needs: create_release runs-on: ubuntu-latest env: RUST_BACKTRACE: full strategy: fail-fast: false matrix: target: - i686-unknown-linux-musl - x86_64-pc-windows-gnu - x86_64-unknown-linux-gnu - x86_64-unknown-linux-musl - armv7-unknown-linux-musleabihf - armv7-unknown-linux-gnueabihf - arm-unknown-linux-gnueabi - arm-unknown-linux-gnueabihf - arm-unknown-linux-musleabi - arm-unknown-linux-musleabihf - aarch64-unknown-linux-gnu - aarch64-unknown-linux-musl steps: - uses: actions/checkout@v4 - name: Install Rust uses: actions-rs/toolchain@v1 with: profile: minimal target: ${{ matrix.target }} toolchain: stable default: true override: true - name: Install cross run: cargo install cross - name: Build ${{ matrix.target }} timeout-minutes: 120 run: | compile_target=${{ matrix.target }} # if [[ "$compile_target" == *"-linux-"* || "$compile_target" == *"-apple-"* ]]; then # compile_features="-f local-redir -f local-tun" # fi if [[ "$compile_target" == "mips-"* || "$compile_target" == "mipsel-"* || "$compile_target" == "mips64-"* || "$compile_target" == "mips64el-"* ]]; then sudo apt-get update -y && sudo apt-get install -y upx; if [[ "$?" == "0" ]]; then compile_compress="-u" fi fi cd build chmod +x build-release ./build-release -t ${{ matrix.target }} $compile_features $compile_compress - name: Upload Github Assets uses: softprops/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: name: Seam CLI ${{ needs.create_release.outputs.version }} body: | ${{ needs.create_release.outputs.changes }} files: build/release/* draft: false prerelease: false build-unix: needs: create_release runs-on: ${{ matrix.os }} env: BUILD_EXTRA_FEATURES: '' RUST_BACKTRACE: full strategy: matrix: os: [macos-latest] target: - x86_64-apple-darwin - aarch64-apple-darwin steps: - uses: actions/checkout@v4 - name: Install GNU tar if: runner.os == 'macOS' run: | brew install gnu-tar echo "/usr/local/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH - name: Install Rust uses: actions-rs/toolchain@v1 with: profile: minimal target: ${{ matrix.target }} toolchain: stable default: true override: true - name: Build release shell: bash run: | chmod +x ./build/build-host-release ./build/build-host-release -t ${{ matrix.target }} - name: Upload Github Assets uses: softprops/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: name: Seam CLI ${{ needs.create_release.outputs.version }} body: | ${{ needs.create_release.outputs.changes }} files: build/release/* draft: false prerelease: false build-windows: needs: create_release runs-on: windows-latest env: RUSTFLAGS: '-C target-feature=+crt-static' RUST_BACKTRACE: full steps: - uses: actions/checkout@v4 - name: Install Rust uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: stable default: true override: true - name: Build release run: | pwsh ./build/build-host-release.ps1 - name: Upload Github Assets uses: softprops/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: name: Seam CLI ${{ needs.create_release.outputs.version }} body: | ${{ needs.create_release.outputs.changes }} files: build/release/* draft: false prerelease: false ================================================ FILE: .github/workflows/release_gui.yml ================================================ name: Build GUI Release permissions: contents: write on: push: tags: - v_gui.* jobs: create_release: runs-on: ubuntu-latest outputs: changes: ${{ steps.changelog_reader.outputs.changes }} version: ${{ steps.changelog_reader.outputs.VERSION }} steps: - uses: actions/checkout@v4 - name: Get version number id: get_version run: | VERSION=${GITHUB_REF#refs/tags/} VERSION=${VERSION/v_gui./} echo "::set-output name=version::$VERSION" - name: Changelog Reader id: changelog_reader uses: mindsers/changelog-reader-action@v2.2.2 with: path: './crates/gui/CHANGELOG.md' version: ${{ steps.get_version.outputs.version }} build_seam: needs: create_release strategy: fail-fast: false matrix: platform: [macos-latest, ubuntu-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v4 - name: Set up node uses: actions/setup-node@v4 with: node-version: 18 cache: 'yarn' cache-dependency-path: 'crates/gui/yarn.lock' - name: Install Rust stable uses: dtolnay/rust-toolchain@stable - name: install dependencies (ubuntu only) if: matrix.platform == 'ubuntu-latest' run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf - name: install frontend dependencies run: | cd crates/gui yarn install --frozen-lockfile - uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: projectPath: 'crates/gui' tagName: '${{github.ref_name}}' releaseName: 'Seam GUI ${{ needs.create_release.outputs.version }}' releaseBody: '${{ needs.create_release.outputs.changes }}' releaseDraft: false prerelease: false ================================================ FILE: .github/workflows/rust-clippy.yml ================================================ # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by # separate terms of service, privacy policy, and support # documentation. # rust-clippy is a tool that runs a bunch of lints to catch common # mistakes in your Rust code and help improve your Rust code. # More details at https://github.com/rust-lang/rust-clippy # and https://rust-lang.github.io/rust-clippy/ name: rust-clippy analyze on: push: branches: [ "master" ] pull_request: # The branches below must be a subset of the branches above branches: [ "master" ] schedule: - cron: '31 23 * * 3' jobs: rust-clippy-analyze: name: Run rust-clippy analyzing runs-on: ubuntu-latest permissions: contents: read security-events: write actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status steps: - name: Checkout code uses: actions/checkout@v4 - name: Install Rust toolchain uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af #@v1 with: profile: minimal toolchain: stable components: clippy override: true - name: Install required cargo run: cargo install clippy-sarif sarif-fmt - name: Run rust-clippy run: cargo clippy --all-features --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt continue-on-error: true - name: Upload analysis results to GitHub uses: github/codeql-action/upload-sarif@v2 with: sarif_file: rust-clippy-results.sarif wait-for-processing: true ================================================ FILE: .gitignore ================================================ /target /.idea /bak ================================================ FILE: Cargo.toml ================================================ [workspace] members = [ "crates/cli", "crates/gui/src-tauri", "crates/core", "crates/danmu", "crates/marcos", "crates/status", ] resolver = "2" [profile.release] opt-level = "z" lto = true codegen-units = 1 panic = "abort" strip = "symbols" ================================================ FILE: LICENSE-MIT ================================================ MIT License Copyright (c) 2023 Borber Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: LICENSE-UNLICENSE ================================================ This is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to ================================================ FILE: README.md ================================================

Seam

Seam

Github Downloads LICENSE

> 原 `SBtream` 项目, 经历 python 不成熟的模仿, Java 重构烂尾, 目前使用 rust 进行重构开发 多平台直播源地址获取 # 待办 欢迎各位大佬 PR , 积极响应, 友善沟通, 快速 CR, 给您最好的开源体验 - [ ] GUI 从获取模式切换为订阅模式 - [ ] 添加日志模块, 以便于用户反馈问题 - [ ] 输出日志文件 - [ ] 链接识别 - 规定每个平台都需要实现判断一个链接是否是自己的, 并返回正确的 rid - [ ] 提取 CLI GUI 公共模块 - [ ] util - [ ] config - 即使 cli 和 gui 有部分不重叠的部分, 但应该还是重叠部分更多 - [ ] I18N - [ ] GUI action 添加便携版本, 方便已经安装了 WebView2 的用户使用 # GUI ![GUI](assets/gui.png) ## [详情](crates/gui/README.md) # CLI ```bash ❯ .\seam.exe -l douyu -i 88080 http://url1 http://url2 ``` ## [详情](crates/cli/README.md) # 下载 [Releases · seam](https://github.com/Borber/seam/releases) 下载 `GUI`/`CLI`可执行文件 # 使用 | **平台** | **代号** | **`` 位置** | **详情** | **弹幕** | | ------------------------------------- | -------- | ------------------------------------------------------------------------ | ------------ | -------- | | [B 站](https://live.bilibili.com/) | bili | `https://live.bilibili.com/` | ✅ | ✅ | | [斗鱼](https://www.douyu.com/) | douyu | `https://www.douyu.com/` 或 `https://www.douyu.com/xx/xx?rid=` | ✅ | | | [抖音](https://live.douyin.com/) | douyin | `https://live.douyin.com/` | ✅ | | | [虎牙](https://huya.com/) | huya | `https://huya.com/` | | | | [快手](https://live.kuaishou.com/) | ks | `https://live.kuaishou.com/u/` | | | | [CC](https://cc.163.com/) | cc | `https://cc.163.com/` | | | | [花椒](https://www.huajiao.com/) | huajiao | `https://www.huajiao.com/l/` | | | | [艺气山](https://www.173.com/) | yqs | `https://www.173.com/` | | | | [棉花糖](https://www.2cq.com/) | mht | `https://www.2cq.com/` | | | | [kk](https://www.kktv5.com/) | kk | `https://www.kktv5.com/show/` | | | | [千帆](https://qf.56.com/) | qf | `https://qf.56.com/` | | | | [Now](https://now.qq.com/) | now | `https://now.qq.com/pcweb/story.html?roomid=` | | | | [映客](https://www.inke.cn/) | inke | `https://www.inke.cn/liveroom/index.html?uid=` | | | | [afreeca](https://afreecatv.com/) | afreeca | `https://bj.afreecatv.com/` | | | | [panda](https://www.pandalive.co.kr/) | panda | `https://www.pandalive.co.kr/channel/` | | | | [flex](https://www.flextv.co.kr/) | flex | `https://www.flextv.co.kr/channels/` | | | | [wink](https://www.winktv.co.kr/) | wink | `https://www.winktv.co.kr/channel/` | | | # 配置 `config.toml` 放置在 `seam` 可执行文件所在目录下 ```toml # 播放器路径或命令 # 请自行安装播放器, 请确认它可以通过命令行+链接打开视频文件 [play] # potplayer 样例 # bin = "C:\\Program Files (x86)\\Pure Codec\\x64\\PotPlayerMini64.exe" bin = "mpv" # 播放器参数 args = [] # headers 支持所有合法 http 请求头字段 # global 为全局请求头, 会被各平台请求头覆盖 # 请注意 不要覆盖虎牙的 user-agent, 否则会导致获取失败 [headers.global] # user-agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.200" # 抖音 cookie 必须 [headers.douyin] cookie = "xxxx" # 快手 cookie 必须 [headers.ks] cookie = "xxxx" # 斗鱼设置登录账户 cookie 情况下可以获取到备用线路高清源 [headers.douyu] cookie = "xxxx" # [rid]: 房间号 # [title]: 标题 # [time]: 时间戳 # [date]: 日期 [file_name] # 录制文件标题 video = "[rid]-[title]-[date]-[time]" # danmu文件标题 danmu = "[rid]-[title]-[date]-[time]" ``` > cookie 获取方法: [额外说明](./doc/配置说明.md) # 路线 [seam](https://github.com/users/Borber/projects/4/views/1) # 相关项目 - [seamui](https://github.com/kirito41dd/seamui) 由 [kirito41dd](https://github.com/kirito41dd) 开发的`seam`图形化界面 - [SeamPotPlayer](https://github.com/chen310/SeamPotPlayer/) 由[chen310](https://github.com/chen310) 开发, 直接在 PotPlayer 中调用 seam 播放直播 ## 贡献者 [![GitHub Contributors](https://contrib.rocks/image?repo=Borber/seam)](https://github.com/Borber/seam/graphs/contributors) # 感谢 - [wbt5/real-url](https://github.com/wbt5/real-url/) - [banner](https://textkool.com/en/ascii-art-generator?hl=default&vl=default&font=Chunky&text=SEAM) - [手把手教你破解斗鱼 sign 算法](https://zhuanlan.zhihu.com/p/107330805) ## Star History Star History Chart ================================================ FILE: TODO.md ================================================ - 给 Client 添加函数返回其函数调用信息, 命令, 平台名称 ================================================ FILE: build/build-host-release ================================================ #!/bin/bash BUILD_TARGET="" BUILD_FEATURES=() while getopts "t:f:" opt; do case $opt in t) BUILD_TARGET=$OPTARG ;; f) BUILD_FEATURES+=($OPTARG) ;; ?) echo "Usage: $(basename $0) [-t ] [-f ]" ;; esac done BUILD_FEATURES+=${BUILD_EXTRA_FEATURES} ROOT_DIR=$(cd $(dirname $0) && pwd) VERSION=$(grep -E '^version' "${ROOT_DIR}/../crates/cli/Cargo.toml" | awk '{print $3}' | sed 's/"//g') HOST_TRIPLE=$(rustc -Vv | grep 'host:' | awk '{print $2}') echo "Started build release ${VERSION} for ${HOST_TRIPLE} (target: ${BUILD_TARGET}) with features \"${BUILD_FEATURES}\"..." if [[ "${BUILD_TARGET}" != "" ]]; then if [[ "${BUILD_FEATURES}" != "" ]]; then cargo build --package seam --release --features "${BUILD_FEATURES}" --target "${BUILD_TARGET}" else cargo build --package seam --release --target "${BUILD_TARGET}" fi else if [[ "${BUILD_FEATURES}" != "" ]]; then cargo build --package seam --release --features "${BUILD_FEATURES}" else cargo build --package seam --release fi fi if [[ "$?" != "0" ]]; then exit 1 fi if [[ "${BUILD_TARGET}" == "" ]]; then BUILD_TARGET=$HOST_TRIPLE fi TARGET_SUFFIX="" if [[ "${BUILD_TARGET}" == *"-windows-"* ]]; then TARGET_SUFFIX=".exe" fi TARGETS=("seam${TARGET_SUFFIX}" ) RELEASE_FOLDER="${ROOT_DIR}/release" RELEASE_PACKAGE_NAME="seam-v${VERSION}.${BUILD_TARGET}" mkdir -p "${RELEASE_FOLDER}" # Into release folder if [[ "${BUILD_TARGET}" != "" ]]; then cd "${ROOT_DIR}/../target/${BUILD_TARGET}/release" else cd "${ROOT_DIR}/../target/release" fi cp "${ROOT_DIR}/../config.toml" ./ if [[ "${BUILD_TARGET}" == *"-windows-"* ]]; then # For Windows, use zip RELEASE_PACKAGE_FILE_NAME="${RELEASE_PACKAGE_NAME}.zip" RELEASE_PACKAGE_FILE_PATH="${RELEASE_FOLDER}/${RELEASE_PACKAGE_FILE_NAME}" zip "${RELEASE_PACKAGE_FILE_PATH}" "${TARGETS[@]}" "config.toml" if [[ $? != "0" ]]; then exit 1 fi # Checksum cd "${RELEASE_FOLDER}" shasum -a 256 "${RELEASE_PACKAGE_FILE_NAME}" >"${RELEASE_PACKAGE_FILE_NAME}.sha256" else # For others, Linux, OS X, uses tar.xz # For Darwin, .DS_Store and other related files should be ignored if [[ "$(uname -s)" == "Darwin" ]]; then export COPYFILE_DISABLE=1 fi RELEASE_PACKAGE_FILE_NAME="${RELEASE_PACKAGE_NAME}.tar.xz" RELEASE_PACKAGE_FILE_PATH="${RELEASE_FOLDER}/${RELEASE_PACKAGE_FILE_NAME}" tar -cJf "${RELEASE_PACKAGE_FILE_PATH}" "${TARGETS[@]}" "config.toml" if [[ $? != "0" ]]; then exit 1 fi # Checksum cd "${RELEASE_FOLDER}" shasum -a 256 "${RELEASE_PACKAGE_FILE_NAME}" >"${RELEASE_PACKAGE_FILE_NAME}.sha256" fi echo "Finished build release ${RELEASE_PACKAGE_FILE_PATH}" ================================================ FILE: build/build-host-release.ps1 ================================================ #!pwsh <# OpenSSL is already installed on windows-latest virtual environment. If you need OpenSSL, consider install it by: choco install openssl #> param( [Parameter(HelpMessage = "extra features")] [Alias('f')] [string]$Features ) $ErrorActionPreference = "Stop" $TargetTriple = (rustc -Vv | Select-String -Pattern "host: (.*)" | ForEach-Object { $_.Matches.Value }).split()[-1] Write-Host "Started building release for ${TargetTriple} ..." if ([string]::IsNullOrEmpty($Features)) { cargo build --package seam --release } else { cargo build --package seam --release --features "${Features}" } if (!$?) { exit $LASTEXITCODE } $Version = (Select-String -Pattern '^version *= *"([^"]*)"$' -Path "${PSScriptRoot}\..\crates\cli\Cargo.toml" | ForEach-Object { $_.Matches.Value }).split()[-1] $Version = $Version -replace '"' $PackageReleasePath = "${PSScriptRoot}\release" $PackageName = "seam-v${Version}.${TargetTriple}.zip" $PackagePath = "${PackageReleasePath}\${PackageName}" Write-Host $Version Write-Host $PackageReleasePath Write-Host $PackageName Write-Host $PackagePath Push-Location "${PSScriptRoot}\..\target\release" Copy-Item "${PSScriptRoot}\..\config.toml" -Destination "${PSScriptRoot}\..\target\release" $ProgressPreference = "SilentlyContinue" New-Item "${PackageReleasePath}" -ItemType Directory -ErrorAction SilentlyContinue $CompressParam = @{ LiteralPath = "seam.exe", "config.toml" DestinationPath = "${PackagePath}" } Compress-Archive @CompressParam Write-Host "Created release packet ${PackagePath}" $PackageChecksumPath = "${PackagePath}.sha256" $PackageHash = (Get-FileHash -Path "${PackagePath}" -Algorithm SHA256).Hash "${PackageHash} ${PackageName}" | Out-File -FilePath "${PackageChecksumPath}" Write-Host "Created release packet checksum ${PackageChecksumPath}" ================================================ FILE: build/build-release ================================================ #!/bin/bash # Path: build\release CUR_DIR=$(cd $(dirname $0) && pwd) VERSION=$(grep -E '^version' ${CUR_DIR}/../crates/cli/Cargo.toml | awk '{print $3}' | sed 's/"//g') ## Disable macos ACL file if [[ "$(uname -s)" == "Darwin" ]]; then export COPYFILE_DISABLE=1 fi targets=() features=() use_upx=false while getopts "t:f:u" opt; do case $opt in t) targets+=($OPTARG) ;; f) features+=($OPTARG) ;; u) use_upx=true ;; ?) echo "Usage: $(basename $0) [-t ] [-f features] [-u]" ;; esac done features+=${EXTRA_FEATURES} if [[ "${#targets[@]}" == "0" ]]; then echo "Specifying compile target with -t " exit 1 fi if [[ "${use_upx}" = true ]]; then if [[ -z "$upx" ]] && command -v upx &>/dev/null; then upx="upx -9" fi if [[ "x$upx" == "x" ]]; then echo "Couldn't find upx in PATH, consider specifying it with variable \$upx" exit 1 fi fi function build() { cd "$CUR_DIR/.." TARGET=$1 RELEASE_DIR="target/${TARGET}/release" TARGET_FEATURES="${features[@]}" if [[ "${TARGET_FEATURES}" != "" ]]; then echo "* Building ${TARGET} package ${VERSION} with features \"${TARGET_FEATURES}\" ..." cross build --package seam --target "${TARGET}" \ --features "${TARGET_FEATURES}" \ --release else echo "* Building ${TARGET} package ${VERSION} ..." cross build --package seam --target "${TARGET}" \ --release fi if [[ $? != "0" ]]; then exit 1 fi PKG_DIR="${CUR_DIR}/release" mkdir -p "${PKG_DIR}" cp "${CUR_DIR}/../config.toml" "${RELEASE_DIR}" if [[ "$TARGET" == *"-linux-"* ]]; then PKG_NAME="seam-v${VERSION}.${TARGET}.tar.xz" PKG_PATH="${PKG_DIR}/${PKG_NAME}" cd ${RELEASE_DIR} if [[ "${use_upx}" = true ]]; then # Enable upx for MIPS. $upx seam #>/dev/null fi echo "* Packaging XZ in ${PKG_PATH} ..." tar -cJf ${PKG_PATH} \ "seam" \ "config.toml" if [[ $? != "0" ]]; then exit 1 fi cd "${PKG_DIR}" shasum -a 256 "${PKG_NAME}" >"${PKG_NAME}.sha256" elif [[ "$TARGET" == *"-windows-"* ]]; then PKG_NAME="seam-v${VERSION}.${TARGET}.zip" PKG_PATH="${PKG_DIR}/${PKG_NAME}" echo "* Packaging ZIP in ${PKG_PATH} ..." cd ${RELEASE_DIR} zip ${PKG_PATH} \ "seam.exe" \ "config.toml" if [[ $? != "0" ]]; then exit 1 fi cd "${PKG_DIR}" shasum -a 256 "${PKG_NAME}" >"${PKG_NAME}.sha256" fi echo "* Done build package ${PKG_NAME}" } for target in "${targets[@]}"; do build "$target" done ================================================ FILE: build/build-release-zigbuild ================================================ #!/bin/bash CUR_DIR=$(cd $(dirname $0) && pwd) VERSION=$(grep -E '^version' ${CUR_DIR}/../crates/cli/Cargo.toml | awk '{print $3}' | sed 's/"//g') ## Disable macos ACL file if [[ "$(uname -s)" == "Darwin" ]]; then export COPYFILE_DISABLE=1 fi targets=() features=() use_upx=false while getopts "t:f:u" opt; do case $opt in t) targets+=($OPTARG) ;; f) features+=($OPTARG) ;; u) use_upx=true ;; ?) echo "Usage: $(basename $0) [-t ] [-f features] [-u]" ;; esac done features+=${EXTRA_FEATURES} if [[ "${#targets[@]}" == "0" ]]; then echo "Specifying compile target with -t " exit 1 fi if [[ "${use_upx}" = true ]]; then if [[ -z "$upx" ]] && command -v upx &>/dev/null; then upx="upx -9" fi if [[ "x$upx" == "x" ]]; then echo "Couldn't find upx in PATH, consider specifying it with variable \$upx" exit 1 fi fi function build() { cd "$CUR_DIR/.." TARGET=$1 RELEASE_DIR="target/${TARGET}/release" TARGET_FEATURES="${features[@]}" cp "config.toml" "${RELEASE_DIR}" if [[ "${TARGET_FEATURES}" != "" ]]; then echo "* Building ${TARGET} package ${VERSION} with features \"${TARGET_FEATURES}\" ..." cargo zigbuild --package seam --target "${TARGET}" \ --features "${TARGET_FEATURES}" \ --release else echo "* Building ${TARGET} package ${VERSION} ..." cargo zigbuild --package seam --target "${TARGET}" \ --release fi if [[ $? != "0" ]]; then exit 1 fi PKG_DIR="${CUR_DIR}/release" mkdir -p "${PKG_DIR}" if [[ "$TARGET" == *"-linux-"* ]]; then PKG_NAME="seam-v${VERSION}.${TARGET}.tar.xz" PKG_PATH="${PKG_DIR}/${PKG_NAME}" cd ${RELEASE_DIR} if [[ "${use_upx}" = true ]]; then # Enable upx for MIPS. $upx sslocal ssserver ssurl ssmanager ssservice #>/dev/null fi echo "* Packaging XZ in ${PKG_PATH} ..." tar -cJf ${PKG_PATH} \ "seam" \ "config.toml" if [[ $? != "0" ]]; then exit 1 fi cd "${PKG_DIR}" shasum -a 256 "${PKG_NAME}" >"${PKG_NAME}.sha256" elif [[ "$TARGET" == *"-windows-"* ]]; then PKG_NAME="seam-v${VERSION}.${TARGET}.zip" PKG_PATH="${PKG_DIR}/${PKG_NAME}" echo "* Packaging ZIP in ${PKG_PATH} ..." cd ${RELEASE_DIR} zip ${PKG_PATH} \ "seam.exe" \ "config.toml" if [[ $? != "0" ]]; then exit 1 fi cd "${PKG_DIR}" shasum -a 256 "${PKG_NAME}" >"${PKG_NAME}.sha256" fi echo "* Done build package ${PKG_NAME}" } for target in "${targets[@]}"; do build "$target" done ================================================ FILE: config.toml ================================================ # 播放器路径或命令 # 请自行安装播放器, 请确认它可以通过命令行+链接打开视频文件 [play] # potplayer 样例 # bin = "C:\\Program Files (x86)\\Pure Codec\\x64\\PotPlayerMini64.exe" bin = "mpv" # 播放器参数 args = [] # headers 支持所有合法 http 请求头字段 # global 为全局请求头, 会被各平台请求头覆盖 # 请注意 不要覆盖虎牙的 user-agent, 否则会导致获取失败 [headers.global] # user-agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.200" # 抖音 cookie 必须 [headers.douyin] cookie = "xxxx" # 快手 cookie 必须 [headers.ks] cookie = "xxxx" # 斗鱼设置登录账户 cookie 情况下可以获取到备用线路高清源 [headers.douyu] cookie = "xxxx" # [rid]: 房间号 # [title]: 标题 # [time]: 时间戳 # [date]: 日期 [file_name] # 录制文件标题 video = "[rid]-[title]-[date]-[time]" # danmu文件标题 danmu = "[rid]-[title]-[date]-[time]" ================================================ FILE: crates/cli/CHANGELOG.md ================================================ # Changelog ## [0.1.39] 修复 bili 无法获取原画 [#277](https://github.com/Borber/seam/issues/277) ## [0.1.38] 修复 斗鱼部分房间获取不到 [#255](https://github.com/Borber/seam/issues/255) ## [0.1.37] 清除调试代码 ## [0.1.36] **请重新获取最新的快手 cookie** ### 修复 - 修复 kuaishou 直播源获取 ## [0.1.35] **请重新获取最新的抖音 cookie** ### 修复 - 修复 douyin 直播源获取 ### 更新 - douyu 支持 直播间封面, 主播名,主播头像获取 ## [0.1.34] ### 更新 - 修复抖音, 获取最高清晰度 ## [0.1.33] ### 更新 - 调整 cli 默认显示,仅显示直播源 - 新增 all 参数 ## [0.1.32] ### 更新 - 目前所有支持平台,房间名获取均可用 ## [0.1.31] ### 修复 - 斗鱼获取最清晰线路 ### 更新 - 斗鱼在设置 cookie 的情况下尝试获取 备用线路 ## [0.1.30] ### 更新 - 调整子命令 - 拆分未开播与解析错误 ## [0.1.29] ### 更新 - 捕获不支持的平台 - 直播名获取 - 千帆直播 - 映客直播 - 更新依赖 - 引入 cookie , 但目前还不能设置 - 调整报错信息输出 ### 更改 - 动态派发 Client - 调整代码结构 - js 运行时重新引入 douyu 不用再额外下载 jin 了 ## [0.1.28] ### Changed - 拆分 core danmu status 模块 - 调整 clap 结构 - 添加 过程宏模块 - 优化代码结构 - 暂时移除弹幕文件输出功能 - 暂时移除录播功能, 待后续重构完整版功能 - 更新依赖 ## [0.1.27] ### Fixed - 修复抖音提前获取直播标题名 ## [0.1.26] ### Fixed - 修复快手直播源获取 ## [0.1.25] ### Fixed - 修复斗鱼直播源获取 ### Changed - js runtime 拆分, 优化体积 ## [0.1.24] ### Fixed - 修复抖音错误输出 ## [0.1.23] ### Fixed - 修复斗鱼 CDN ## [0.1.22] ### Fixed - 修复虎牙 gzip 解压 ## [0.1.21] ### Fixed - 修复抖音 m3u 获取 full_hd 资源 ## [0.1.20] ### Fixed - 修复抖音直播源格式 ## [0.1.19] ### Fixed - 修复抖音直播源获取 ## [0.1.18] ### Added - 映客直播源获取 - 抖音加入即时 cookie, 避免 cookie 检测 ## [0.1.17] ### Added - 添加斗鱼直播间标题获取 ### Changed - 更改 js 运行时为 boa_engine 去除在线 js 运行接口 - 添加编译参数, 减小二进制体积 - 斗鱼使用移动端接口获取直播源 ## [0.1.16] ### Added - (尝试支持) 视频录制, 但目前需要自行放置 ffmpeg 文件到 `seam` 可执行文件所在目录下 - 弹幕录制 CSV 支持 - 开始支持设置 - 弹幕, 视频 目前支持 rid title time 字段替换 ### Changed - 改进 CI 脚本, 提供更多平台/版本支持 ### Fixed - 修复抖音直播源获取 - B 站弹幕解压情况下的顺序问题 ## [0.1.15] ### Added - 虎牙直播间标题字段支持 ### Fixed - 删除虎牙多余信息输出 ## [0.1.14] ### Added - 添加 bili 直播间 标题获取, 标题字段初步支持 - 支持 抖音, cc 直播标题获取 ### Fixd - 修复抖音直播源获取 ### Changed - 抖音去除画质标签 ### Changed - 弹幕功能调整 ## [0.1.13] ### Added - 添加 kk 直播源获取 - 添加 千帆直播源获取 - bili 直播弹幕获取-预览版 ## [0.1.12] ### Added - 添加 now 直播源获取 ### Changed - Format 添加 rtmp 格式 - 删除斗鱼, 虎牙多余打印信息 ### Fixed - 修复斗鱼, 虎牙平台直播源获取 ## [0.1.11] ### Added - 添加 winktv 直播源获取 ### Changed - 修改 Node 结构 - 规范化 format 判定, 规范化输出方法 - 简化代码 ## [0.1.10] ### Added - 添加 flex 直播源获取 ## [0.1.9] ### Added - 添加 pandalive 直播源获取 ## [0.1.8] ### Added - 添加 afreeca 直播源获取 ### Changed - 引入宏简化代码 感谢 [@eweca-d](https://github.com/eweca-d) - 删除部分注释及说明信息 ### Fixed - model 拼写错误修正 ## [0.1.7] ### Added - 支持网易 CC 直播源获取 ### Changed - 使用 `super` 替代绝对位置 ## [0.1.6] ### Added - 支持快手直播源获取 ## [0.1.5] - 2023-01-11 ### Fixed - 修复斗鱼直播源获取 ## [0.1.4] - 2023-01-11 ### Added - 添加全平台自动编译发布工作流 - 支持花椒直播源获取 ### Changed - 后续 tag 将采用 vX.X.X 的格式 ### Fixed - 修改代码格式 ## [0.1.3] - 2023-01-9 ### Added - 原创虎牙直播源获取 ### Changed - 同时输出阿里 腾讯 华为 CDN 和 flv hls 两种直播源 ### Fixed - 从预览接口转换为标准接口, 修复部分直播间无法获取链接而显示未开播的问题 ================================================ FILE: crates/cli/Cargo.toml ================================================ [package] name = "seam" authors = ["Borber"] version = "0.1.39" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] seam_core = { path = "../core" } seam_danmu = { path = "../danmu" } seam_status = { path = "../status" } anyhow = "1.0" tokio = { version = "1", features = ["full"] } clap = { version = "4", features = ["derive"] } once_cell = "1" serde = { version = "1", features = ["derive"] } basic-toml = "0.1" parking_lot = { version = "0.12", features = ["nightly"] } ================================================ FILE: crates/cli/README.md ================================================ ``` _______ _______ _______ _______ | __| ___| _ | | | |__ | ___| | | |_______|_______|___|___|__|_|__| ``` ```bash ❯ .\seam.exe -l douyu -i 88080 http://url1 http://url2 ``` - `-l` 代表平台, 目前支持的平台见下表 - `-i` 代表直播间号, 也就是直播间链接中的 `rid` - `-a` 显示全部信息, 包括直播间标题, 主播名, 封面图等 > 因为数据具有时效性, 所以具体链接使用 `url` 进行替换 **注意事项: 目前抖音和快手因为 cookie 模块的加入进行了较大修改, 所以目前不支持获取这两个平台的直播源** ================================================ FILE: crates/cli/src/common.rs ================================================ use std::{collections::HashMap, sync::Arc}; use once_cell::sync::Lazy; use seam_core::live::{self, Live}; pub static GLOBAL_CLIENT: Lazy>> = Lazy::new(live::all); #[cfg(test)] mod tests { #[tokio::test] async fn test() { println!( "{:#?}", super::GLOBAL_CLIENT .get("bili") .unwrap() .get("6", None) .await .unwrap() ); } } ================================================ FILE: crates/cli/src/config.rs ================================================ use std::collections::HashMap; use once_cell::sync::Lazy; use serde::Deserialize; use crate::util::bin_dir; #[derive(Deserialize, Debug)] pub struct ConfigOption { pub file_name: Option, pub headers: Option>>, } #[derive(Debug)] pub struct Config { pub file_name: FileNameConfig, pub headers: HashMap>, } #[derive(Debug)] pub struct FileNameConfig { pub video: String, pub danmu: String, } #[derive(Deserialize, Debug)] pub struct FileNameOption { pub video: Option, pub danmu: Option, } /// 配置文件 pub static CONFIG: Lazy = Lazy::new(|| { let config = std::fs::read_to_string(format!("{}config.toml", bin_dir(),)).unwrap_or("".to_owned()); let config_file = basic_toml::from_str::(&config).unwrap(); Config { file_name: { let FileNameOption { video, danmu } = config_file.file_name.unwrap_or(FileNameOption { video: None, danmu: None, }); let video = video.unwrap_or("[rid]-[title]-[date]-[time]".to_string()); let danmu = danmu.unwrap_or("[rid]-[title]-[date]-[time]".to_string()); FileNameConfig { video, danmu } }, headers: config_file.headers.unwrap_or_default(), } }); pub fn headers(live: &str) -> HashMap { let global = CONFIG .headers .get("global") .unwrap_or(&HashMap::new()) .clone(); let mut live = CONFIG.headers.get(live).unwrap_or(&HashMap::new()).clone(); live.extend(global); live } #[cfg(test)] mod tests { use super::*; #[test] fn test_config() { // 初始化 CONFIG let _ = CONFIG.file_name.video; println!("{:#?}", CONFIG); } } ================================================ FILE: crates/cli/src/main.rs ================================================ mod common; mod config; mod util; use crate::common::GLOBAL_CLIENT; use anyhow::{anyhow, Result}; use clap::Parser; use seam_core::error::SeamError; /// 获取直播源 #[derive(Parser)] #[command(name = "seam")] #[command(about =" ________ _______ _______ _______ | __| ___| _ | | | |__ | ___| | | |_______|_______|___|___|__|_|__|", long_about = None)] #[command(subcommand_negates_reqs = true)] #[command(arg_required_else_help = true)] struct Cli { /// 平台名称 #[arg(short = 'l', required = true)] live: Option, /// 直播间号 #[arg(short = 'i', required = true)] rid: Option, /// 显示详细信息 #[arg(short = 'a')] all: bool, /// 直接录播功能 #[arg(short = 'r')] record: bool, /// 自动监控录播功能 #[arg(short = 'R')] auto_record: bool, /// 输出到终端的弹幕功能 #[arg(short = 'd')] danmu: bool, /// 根据参数指定的文件地址输出弹幕 #[arg(short = 'D')] config_danmu: bool, #[command(subcommand)] command: Option, } #[derive(Parser, Debug)] enum Commands { /// 显示所有支持的平台 List, } // 获取直播源的实现 pub async fn cli() -> Result<()> { let Cli { live, rid, all, record, auto_record, danmu, config_danmu, command, } = Cli::parse(); // 获取参数 let live = live.ok_or(anyhow!("请指定平台名称"))?; let rid = rid.ok_or(anyhow!("请指定直播间号"))?; // 处理子命令 if let Some(command) = command { return match command { Commands::List => { println!( "可用平台:{}", GLOBAL_CLIENT.keys().cloned().collect::>().join(", ") ); Ok(()) } }; } let node = match GLOBAL_CLIENT.get(&live) { Some(client) => client.get(&rid, Some(config::headers(&live))).await, None => { return Err(anyhow!( "请检查 {} 是否为可用平台, 或前往 https://github.com/Borber/seam/issues 申请支持", live )) } }; // 无参数情况下,直接输出直播源信息 if !(danmu || config_danmu || record || auto_record) { match node { Ok(node) => { if all { println!("{}", node.json()) } else { println!( "{}", node.urls .into_iter() .map(|item| item.url) .collect::>() .join("\n\n") ) } } Err(SeamError::None) => println!("未开播"), Err(e) => println!("{}", e), } return Ok(()); } // 收集不同参数功能的异步线程 handler let mut thread_handlers = vec![]; // 处理参数-d,直接输出弹幕。 // 由于该函数为cli层,所以出错可以直接panic。 if danmu { let h = tokio::spawn(async move { // args.command // .danmu(vec![&Terminal::try_new(None).unwrap()]) // .await // .unwrap(); println!("弹幕功能正在重构中,敬请期待") // TODO }); thread_handlers.push(h); } tokio::select! { _ = async { for h in thread_handlers { h.await.unwrap(); } } => {} } Ok(()) } #[tokio::main] async fn main() -> Result<()> { cli().await?; Ok(()) } ================================================ FILE: crates/cli/src/util.rs ================================================ #[cfg(windows)] const SEPARATOR: &str = "\\"; #[cfg(not(windows))] const SEPARATOR: &str = "/"; pub fn bin_dir() -> String { let p = std::env::current_exe() .unwrap() .parent() .unwrap() .to_str() .unwrap() .to_owned(); format!("{p}{SEPARATOR}") } ================================================ FILE: crates/core/Cargo.toml ================================================ [package] name = "seam_core" version = "0.1.20" edition = "2021" [dependencies] macros = { package = "seam_marcos", path = "../marcos" } thiserror = "1" once_cell = "1" async-trait = "0.1" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" regex = "1" urlencoding = "2" chrono = "0.4" md-5 = "0.10" hex = "0.4" rand = "0.8" base64 = "0.21" reqwest = { version = "0.11", features = ["json", "gzip", "deflate"] } boa_engine = { version = "0.17", features = ["annex-b"] } [target.'cfg(unix)'.dependencies] openssl = { version = '0.10', features = ["vendored"] } ================================================ FILE: crates/core/src/common.rs ================================================ use once_cell::sync::Lazy; use reqwest::Client; // TODO 这玩意也应该额外传入 pub const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 Edg/108.0.1462.54"; pub static CLIENT: Lazy = Lazy::new(Client::new); ================================================ FILE: crates/core/src/error.rs ================================================ use thiserror::Error; pub type Result = std::result::Result; // 需要添加 #[derive(Error, Debug)] pub enum SeamError { #[error("Request error: {0}")] Request(#[from] reqwest::Error), #[error("Type error: {0}")] Type(String), #[error("Serde json error: {0}")] Json(#[from] serde_json::Error), #[error("Regex error: {0}")] Regex(#[from] regex::Error), #[error("Urlencoding error: {0}")] Decode(#[from] std::string::FromUtf8Error), #[error("InvalidHeaderValue error: {0}")] InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue), #[error("ParseInt error: {0}")] ParseInt(#[from] std::num::ParseIntError), #[error("Base64 error: {0}")] Base64Error(#[from] base64::DecodeError), #[error("SystemTime error: {0}")] SystemTimeError(#[from] std::time::SystemTimeError), #[error("{0}")] Plugin(String), #[error("Need fix {0}")] NeedFix(&'static str), #[error("Not on")] None, #[error("Error msg: {0}")] Unknown(String), } ================================================ FILE: crates/core/src/lib.rs ================================================ pub mod common; pub mod error; pub mod live; pub mod util; ================================================ FILE: crates/core/src/live/afreeca.rs ================================================ use std::collections::HashMap; use async_trait::async_trait; use regex::Regex; use crate::{ common::CLIENT, error::{Result, SeamError}, util::{hash2header, parse_url}, }; use super::{Live, Node}; const URL: &str = "https://play.afreecatv.com/"; const PLAY_URL: &str = "https://live.afreecatv.com/afreeca/player_live_api.php?bjid="; const CDN: &str = "https://live-global-cdn-v02.afreecatv.com/live-stmc-32/auth_playlist.m3u8?aid="; /// afreecatv直播 /// /// https://www.afreecatv.com/ pub struct Client; #[async_trait] impl Live for Client { async fn get(&self, rid: &str, headers: Option>) -> Result { let text = CLIENT .get(format!("{URL}{rid}")) .headers(hash2header(headers)) .send() .await? .text() .await?; let re = Regex::new(r#"var nBroadNo = ([0-9]{9})"#)?; let bno = match re.captures(&text) { Some(rap) => rap.get(1).ok_or(SeamError::NeedFix("bno regex"))?.as_str(), None => { return Err(SeamError::None); } }; let re = Regex::new(r#"var szBroadTitle = "([\s\S]*?)";"#)?; let title = match re.captures(&text) { Some(rap) => match rap.get(1) { Some(rap) => rap.as_str(), None => "获取失败", }, None => "获取失败", }; let mut form = HashMap::new(); form.insert("bid", rid); form.insert("bno", bno); form.insert("mode", "landing"); form.insert("player_type", "html5"); form.insert("stream_type", "common"); form.insert("from_api", "0"); form.insert("type", "aid"); form.insert("quality", "original"); let json: serde_json::Value = CLIENT .post(format!("{PLAY_URL}{rid}")) .form(&form) .send() .await? .json() .await?; let urls = vec![parse_url(format!( "{CDN}{}", json["CHANNEL"]["AID"] .as_str() .ok_or(SeamError::NeedFix("channel aid"))? ))]; Ok(Node { rid: rid.to_owned(), title: title.to_owned(), cover: "".to_owned(), anchor: "".to_owned(), head: "".to_owned(), urls, }) } } #[cfg(test)] macros::gen_test!(suji0624); ================================================ FILE: crates/core/src/live/bili.rs ================================================ use std::collections::HashMap; use async_trait::async_trait; use reqwest::header::HeaderValue; use serde_json::Value; use super::{Live, Node}; use crate::error::{Result, SeamError}; use crate::util::hash2header; use crate::{ common::{CLIENT, USER_AGENT}, util::parse_url, }; const INIT_URL: &str = "https://api.live.bilibili.com/room/v1/Room/room_init"; const INFO_URL: &str = "https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom?room_id="; const PLAY_URL: &str = "https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo"; /// bilibili直播 /// /// https://live.bilibili.com/ pub struct Client; #[async_trait] impl Live for Client { async fn get(&self, rid: &str, headers: Option>) -> Result { let resp = CLIENT .get(INIT_URL) .query(&[("id", rid)]) .headers(hash2header(headers.clone())) .send() .await? .json::() .await?; // 获取真实房间号 let rid = match resp["data"]["live_status"].as_i64() { Some(1) => resp["data"]["room_id"] .as_u64() .ok_or(SeamError::NeedFix("room_id"))? .to_string(), _ => return Err(SeamError::None), }; let mut stream_info = get_bili_stream_info(&rid, 10000, headers.clone()).await?; let max = stream_info .as_array() .ok_or(SeamError::NeedFix("stream_info"))? .iter() .map(|data| { data["format"][0]["codec"][0]["accept_qn"] .as_array() .unwrap() .iter() .map(|item| item.as_u64().unwrap()) .max() .unwrap() }) .max() .ok_or(SeamError::NeedFix("max"))?; if max != 10000 { stream_info = get_bili_stream_info(&rid, max, headers.clone()).await?; } let mut urls = vec![]; for obj in stream_info.as_array().ok_or(SeamError::NeedFix("obj"))? { for format in obj["format"] .as_array() .ok_or(SeamError::NeedFix("format"))? { for codec in format["codec"] .as_array() .ok_or(SeamError::NeedFix("codec"))? { let base_url = codec["base_url"] .as_str() .ok_or(SeamError::NeedFix("base_url"))?; for url_info in codec["url_info"] .as_array() .ok_or(SeamError::NeedFix("url_info"))? { let host = url_info["host"] .as_str() .ok_or(SeamError::NeedFix("host"))?; let extra = url_info["extra"] .as_str() .ok_or(SeamError::NeedFix("extra"))?; urls.push(parse_url(format!("{host}{base_url}{extra}"))); } } } } let json = CLIENT .get(format!("{}{}", INFO_URL, rid)) .send() .await? .json::() .await?; let title = json["data"]["room_info"]["title"] .as_str() .unwrap_or("获取失败") .to_owned(); let cover = json["data"]["room_info"]["cover"] .as_str() .unwrap_or("") .to_owned(); let anchor = json["data"]["anchor_info"]["base_info"]["uname"] .as_str() .unwrap_or("") .to_owned(); let head = json["data"]["anchor_info"]["base_info"]["face"] .as_str() .unwrap_or("") .to_owned(); Ok(Node { rid, title, cover, anchor, head, urls, }) } } /// 通过真实房间号获取直播源信息 /// 不带 cookie 只给 480P, 带 cookie 才给原画画质 pub async fn get_bili_stream_info( rid: &str, qn: u64, headers: Option>, ) -> Result { let mut headers = hash2header(headers); headers.append("User-Agent", HeaderValue::from_static(USER_AGENT)); Ok(CLIENT .get(PLAY_URL) .headers(headers) .query(&[ ("room_id", rid), ("protocol", "0,1"), ("format", "0,1,2"), ("codec", "0,1"), ("qn", qn.to_string().as_str()), ("platform", "h5"), ("ptype", "8"), ]) .send() .await? .json::() .await?["data"]["playurl_info"]["playurl"]["stream"] .to_owned()) } #[cfg(test)] macros::gen_test!(6); ================================================ FILE: crates/core/src/live/cc.rs ================================================ use std::collections::HashMap; use super::{Live, Node}; use crate::{ common::CLIENT, error::{Result, SeamError}, util::{hash2header, parse_url}, }; use async_trait::async_trait; use regex::Regex; const URL: &str = "https://cc.163.com/"; /// 网易CC直播 /// /// https://cc.163.com/ pub struct Client; #[async_trait] impl Live for Client { async fn get(&self, rid: &str, headers: Option>) -> Result { let text = CLIENT .get(format!("{URL}{rid}")) .headers(hash2header(headers)) .send() .await? .text() .await?; let re = Regex::new( r#""#, )?; let json = match re.captures(&text) { Some(rap) => rap.get(1).ok_or(SeamError::NeedFix("json re"))?.as_str(), None => { return Err(SeamError::None); } }; let json: serde_json::Value = serde_json::from_str(json)?; let resolution = match &json["props"]["pageProps"]["roomInfoInitData"]["live"]["quickplay"] ["resolution"] { serde_json::Value::Null => return Err(SeamError::NeedFix("resolution")), v => v, }; let title = json["props"]["pageProps"]["roomInfoInitData"]["live"]["title"] .as_str() .unwrap_or("获取失败") .to_owned(); let mut urls = vec![]; for vbr in ["blueray", "ultra", "high", "standard"] { if resolution[vbr] != serde_json::Value::Null { if resolution[vbr]["cdn"]["ali"] != serde_json::Value::Null { urls.push(parse_url( resolution[vbr]["cdn"]["ali"] .as_str() .ok_or(SeamError::NeedFix("cdn ali"))? .to_string(), )); } if resolution[vbr]["cdn"]["ks"] != serde_json::Value::Null { urls.push(parse_url( resolution[vbr]["cdn"]["ks"] .as_str() .ok_or(SeamError::NeedFix("cdn ks"))? .to_string(), )); } break; } } Ok(Node { rid: rid.to_owned(), title, cover: "".to_owned(), anchor: "".to_owned(), head: "".to_owned(), urls, }) } } #[cfg(test)] macros::gen_test!(361433); ================================================ FILE: crates/core/src/live/douyin.rs ================================================ use std::collections::HashMap; use async_trait::async_trait; use reqwest::header::HeaderValue; use serde_json::Value; use crate::{ common::CLIENT, error::{Result, SeamError}, util::{hash2header, parse_url}, }; use super::{Live, Node}; const URL: &str = "https://live.douyin.com/"; const ENTER_URL: &str = "https://live.douyin.com/webcast/room/web/enter/?aid=6383&live_id=1&device_platform=web&language=zh-CN&enter_from=web_live&cookie_enabled=true&screen_width=1536&screen_height=864&browser_language=zh-CN&browser_platform=Win32&browser_name=Chrome&browser_version=94.0.4606.81&room_id_str=&enter_source=&web_rid="; /// 抖音直播 /// /// https://live.douyin.com/ pub struct Client; #[async_trait] impl Live for Client { /// `headers`: cookie 必须, 但不需要是登录状态 async fn get(&self, rid: &str, headers: Option>) -> Result { let mut headers = hash2header(headers); headers.append("referer", HeaderValue::from_static(URL)); // 通过网页内容获取直播地址 let json = CLIENT .get(format!("{ENTER_URL}{rid}")) .headers(headers) .send() .await? .json::() .await?; let data = &json["data"]["data"][0]; let status = &data["status"]; if status.as_i64().unwrap_or(0) != 2 { return Err(SeamError::None); } let title = data["title"].as_str().unwrap_or("获取失败").to_string(); let cover = data["cover"]["url_list"][0] .as_str() .unwrap_or("") .to_string(); let anchor = data["owner"]["nickname"].as_str().unwrap_or("").to_string(); let head = data["owner"]["avatar_thumb"]["url_list"][0] .as_str() .unwrap_or("") .to_string(); let stream_data = data["stream_url"]["live_core_sdk_data"]["pull_data"]["stream_data"] .as_str() .ok_or(SeamError::NeedFix("stream_data"))?; let new_json = serde_json::from_str::(stream_data)?; // 返回最高清晰度的直播地址 flv 和 hls let urls = vec![ parse_url( new_json["data"]["origin"]["main"]["flv"] .as_str() .ok_or(SeamError::NeedFix("flv_pull_url"))? .to_owned(), ), parse_url( new_json["data"]["origin"]["main"]["hls"] .as_str() .ok_or(SeamError::NeedFix("hls_pull_url_map"))? .to_owned(), ), ]; Ok(Node { rid: rid.to_string(), title, cover, anchor, head, urls, }) } } #[cfg(test)] macros::gen_test!(7274955926023686967); ================================================ FILE: crates/core/src/live/douyu.rs ================================================ use std::collections::{HashMap, HashSet}; use crate::{ common::CLIENT, error::{Result, SeamError}, util::{eval, hash2header, parse_url}, }; use async_trait::async_trait; use chrono::prelude::*; use md5::{Digest, Md5}; use regex::Regex; use reqwest::header::HeaderMap; use serde_json::Value; use super::{Live, Node}; const URL: &str = "https://www.douyu.com/"; const PLAY_URL: &str = "https://www.douyu.com/lapi/live/getH5Play/"; const BETARD_URL: &str = "https://www.douyu.com/betard/"; const DID: &str = "10000000000000000000000000001501"; /// 斗鱼直播 /// /// https://www.douyu.com/ pub struct Client; #[async_trait] impl Live for Client { /// `headers`: cookie 不必须, 登录状态下可以获取备用路线高清源 async fn get(&self, rid: &str, headers: Option>) -> Result { let headers = hash2header(headers); // 构造时间戳 let binding = Local::now().timestamp_millis().to_string(); let dt = &binding.as_str()[0..10]; // 获取指定直播间的首页源代码, 认证的sign和直播间是绑定的 let text = CLIENT .get(format!("{URL}{rid}")) .headers(headers.clone()) .send() .await? .text() .await?; // 获取直播间的真实ID let re = Regex::new(r"\$ROOM\.room_id\s?=\s?(\d+);")?; let rid = match re.captures(&text) { Some(cap) => cap .get(1) .ok_or(SeamError::NeedFix("room_id capture"))? .as_str(), None => return Err(SeamError::NeedFix("room_id")), }; // 正则匹配固定位置的js代码 let re = Regex::new(r#""#)?; let mut func = String::new(); let mut v = ""; for cap in re.captures_iter(&text) { let script = cap.get(1).ok_or(SeamError::NeedFix("script"))?.as_str(); let re2 = Regex::new("\"([0-9]{12})\"")?; match re2.captures(script) { Some(t_cap) => { v = t_cap .get(1) .ok_or(SeamError::NeedFix("script captures"))? .as_str(); func = script.to_owned(); } None => continue, } } // 将eval运行字符串更改为直接返回字符串 let re3 = Regex::new(r"eval\(strc\)[\s\S]*?\)")?; let func = re3.replace_all(&func, "strc").to_string(); let func = format!("{func}ub98484234(0,0,0)"); // 获取eval实际运行的字符串 let res = eval(&func); let res = res.trim_matches('"'); // 构建函数, 替换数值 let res = res.replace("(function", "let ccc = function"); let res = res.replace( "rt;})", format!("rt;}}; ccc({rid}, \"{DID}\", {dt})").as_str(), ); // 替换md5值避免js依赖 let cb = format!("{rid}{DID}{dt}{v}"); let rb = { let mut h = Md5::new(); h.update(cb); hex::encode(h.finalize()) }; let res = res.replace( "CryptoJS.MD5(cb).toString();", format!("\"{}\";", &rb).as_str(), ); // 运行js获取签名值 let sign = eval(&res); let sign = sign.trim_matches('"'); let sign = sign.rsplit_once('=').ok_or(SeamError::NeedFix("sign"))?.1; let mut params = HashMap::new(); params.insert("v", v); params.insert("did", DID); params.insert("tt", dt); params.insert("sign", sign); let json = CLIENT .post(format!("{PLAY_URL}{rid}")) .form(¶ms) .headers(headers.clone()) .send() .await? .json::() .await?; match json["error"] .as_i64() .ok_or(SeamError::NeedFix("error code"))? { 0 => { let info = get_info(rid, headers.clone()).await?; let cdns = json["data"]["cdnsWithName"] .as_array() .ok_or(SeamError::NeedFix("cdns"))?; let cdns = cdns .iter() .map(|x| x["cdn"].as_str().unwrap_or("").to_owned()) .collect::>(); let rtmp_cdn = json["data"]["rtmp_cdn"] .as_str() .ok_or(SeamError::NeedFix("rtmp_cdn"))? .to_owned(); let mut jsons = vec![json]; if headers.get("cookie").is_some() { for cdn in cdns { if cdn == rtmp_cdn { continue; } let mut tmp = params.clone(); let headers_tmp = headers.clone(); tmp.insert("cdn", &cdn); let json = CLIENT .post(format!("{PLAY_URL}{rid}")) .form(&tmp) .headers(headers_tmp) .send() .await? .json::() .await?; jsons.push(json); } } let nodes = jsons .iter() .map(|json| { let key = json["data"]["rtmp_live"].as_str().unwrap_or("需要修复"); let url = json["data"]["rtmp_url"].as_str().unwrap_or("需要修复"); parse_url(format!("{url}/{key}")) }) .collect::>(); Ok(Node { rid: rid.to_owned(), title: info.title, cover: info.cover, anchor: info.anchor, head: info.head, urls: nodes, }) } _ => Err(SeamError::None), } } } struct DouyuInfo { title: String, cover: String, anchor: String, head: String, } async fn get_info(rid: &str, headers: HeaderMap) -> Result { let json = CLIENT .get(format!("{BETARD_URL}{rid}")) .headers(headers) .send() .await? .json::() .await?; let title = json["room"]["room_name"] .as_str() .unwrap_or("获取失败") .to_string(); let cover = json["room"]["room_pic"].as_str().unwrap_or("").to_string(); let anchor = json["room"]["owner_name"] .as_str() .unwrap_or("") .to_string(); let head = json["room"]["owner_avatar"] .as_str() .unwrap_or("") .to_string(); Ok(DouyuInfo { title, cover, anchor, head, }) } #[cfg(test)] macros::gen_test!(100); ================================================ FILE: crates/core/src/live/flex.rs ================================================ use std::collections::HashMap; use async_trait::async_trait; use crate::{ common::CLIENT, error::{Result, SeamError}, util::{hash2header, parse_url}, }; use super::{Live, Node}; const URL: &str = "https://api.flextv.co.kr/api/channels/rid/stream?option=all"; /// flextv /// /// https://www.flextv.co.kr/ pub struct Client; #[async_trait] impl Live for Client { async fn get(&self, rid: &str, headers: Option>) -> Result { let json: serde_json::Value = CLIENT .get(URL.replace("rid", rid)) .headers(hash2header(headers)) .send() .await? .json() .await?; match &json["sources"][0]["url"] { serde_json::Value::Null => Err(SeamError::None), url => { let urls = vec![parse_url( url.as_str().ok_or(SeamError::NeedFix("url"))?.to_string(), )]; let title = match &json["stream"]["title"] { serde_json::Value::Null => "获取失败", title => title.as_str().unwrap_or("获取失败"), }; Ok(Node { rid: rid.to_owned(), title: title.to_owned(), cover: "".to_owned(), anchor: "".to_owned(), head: "".to_owned(), urls, }) } } } } #[cfg(test)] macros::gen_test!(437149); ================================================ FILE: crates/core/src/live/huajiao.rs ================================================ use std::collections::HashMap; use async_trait::async_trait; use regex::Regex; use crate::{ common::CLIENT, error::{Result, SeamError}, util::{hash2header, parse_url}, }; use super::{Live, Node}; const URL: &str = "https://www.huajiao.com/l/"; /// 花椒直播 /// /// https://www.huajiao.com/ pub struct Client; #[async_trait] impl Live for Client { async fn get(&self, rid: &str, headers: Option>) -> Result { let text = CLIENT .get(format!("{URL}{rid}")) .headers(hash2header(headers)) .send() .await? .text() .await?; let re1 = Regex::new(r#"sn":"([\s\S]*?)""#)?; let re2 = Regex::new(r#""replay_status":([0-9]*)"#)?; let sn = match re1.captures(&text) { Some(cap) => cap.get(1).ok_or(SeamError::NeedFix("sn"))?.as_str(), None => return Err(SeamError::None), }; let re_title = Regex::new(r#"content="【(.+)】"#)?; let title = match re_title.captures(&text) { Some(cap) => match cap.get(1) { Some(title) => title.as_str().to_owned(), None => "获取失败".to_owned(), }, None => "获取失败".to_owned(), }; let pls: Vec<&str> = sn.split('_').collect(); let pl = pls[2].to_lowercase(); let captures = re2.captures(&text).ok_or(SeamError::None)?; let code = captures.get(1).ok_or(SeamError::NeedFix("code"))?.as_str(); if code == "0" { Ok(Node { rid: rid.to_owned(), title, cover: "".to_owned(), anchor: "".to_owned(), head: "".to_owned(), urls: vec![parse_url(format!( "https://{pl}-flv.live.huajiao.com/live_huajiao_v2/{sn}.m3u8" ))], }) } else { Err(SeamError::None) } } } #[cfg(test)] macros::gen_test!(337633032); ================================================ FILE: crates/core/src/live/huya.rs ================================================ use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; use async_trait::async_trait; use base64::{engine::general_purpose, Engine as _}; use md5::{Digest, Md5}; use rand::Rng; use regex::Regex; use serde_json::json; use crate::{ common::CLIENT, error::{Result, SeamError}, util::{hash2header, parse_url}, }; use super::{Live, Node}; const URL: &str = "https://m.huya.com/"; /// 虎牙直播 /// /// https://huya.com/ pub struct Client; #[async_trait] impl Live for Client { async fn get(&self, rid: &str, headers: Option>) -> Result { let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); let rand = rand::thread_rng().gen_range(0..1000); let uid = get_anonymous_uid().await?; let text = CLIENT .get(format!("{URL}{rid}")) .header("Content-Type", "application/x-www-form-urlencoded") .header("User-Agent", "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Mobile Safari/537.36") .headers(hash2header(headers)) .send() .await? .text() .await?; let re = Regex::new(r"")?; let stream = match re.captures(&text) { Some(caps) => caps.get(1).ok_or(SeamError::NeedFix("stream"))?.as_str(), None => return Err(SeamError::NeedFix("stream none")), }; let json: serde_json::Value = serde_json::from_str(stream)?; let status = json["roomInfo"]["eLiveStatus"] .as_i64() .ok_or(SeamError::NeedFix("eLiveStatus"))?; if status != 2 { return Err(SeamError::None); } let title = match json["roomInfo"]["tLiveInfo"]["sIntroduction"] { serde_json::Value::String(ref title) => title.as_str(), _ => "获取失败", }; let mut urls = vec![]; let streams = json["roomInfo"]["tLiveInfo"]["tLiveStreamInfo"]["vStreamInfo"]["value"] .as_array() .ok_or(SeamError::NeedFix("vStreamInfo"))?; for stream in streams { let flv = stream["sFlvUrl"] .as_str() .ok_or(SeamError::NeedFix("sFlvUrl"))?; let hls = stream["sHlsUrl"] .as_str() .ok_or(SeamError::NeedFix("sHlsUrl"))?; let anti_code = process_anticode( stream["sFlvAntiCode"] .as_str() .ok_or(SeamError::NeedFix("sFlvAntiCode"))?, stream["sStreamName"] .as_str() .ok_or(SeamError::NeedFix("sStreamName"))?, uid, now, rand, )?; urls.push(parse_url(format!( "{}/{}.{}?{}", flv, stream["sStreamName"] .as_str() .ok_or(SeamError::NeedFix("sStreamName"))?, stream["sFlvUrlSuffix"] .as_str() .ok_or(SeamError::NeedFix("sFlvUrlSuffix"))?, anti_code ))); let anti_code = process_anticode( stream["sHlsAntiCode"] .as_str() .ok_or(SeamError::NeedFix("sHlsAntiCode"))?, stream["sStreamName"] .as_str() .ok_or(SeamError::NeedFix("sStreamName"))?, uid, now, rand, )?; urls.push(parse_url(format!( "{}/{}.{}?{}", hls, stream["sStreamName"] .as_str() .ok_or(SeamError::NeedFix("sStreamName"))?, stream["sHlsUrlSuffix"] .as_str() .ok_or(SeamError::NeedFix("sHlsUrlSuffix"))?, anti_code ))); } Ok(Node { rid: rid.to_owned(), title: title.to_owned(), cover: "".to_owned(), anchor: "".to_owned(), head: "".to_owned(), urls, }) } } fn get_uuid(now: u128, rand: u32) -> u128 { (now % 10000000000 * 1000 + rand as u128) % 4294967295 } async fn get_anonymous_uid() -> Result { let resp: HashMap = CLIENT .post("https://udblgn.huya.com/web/anonymousLogin") .json(&json!({ "appId": 5002, "byPass": 3, "context": "", "version": "2.4", "data": {} })) .send() .await? .json() .await?; let uid = resp["data"]["uid"] .as_str() .ok_or(SeamError::NeedFix("uid"))? .to_string() .parse()?; Ok(uid) } fn process_anticode( anticode: &str, stream_name: &str, uid: u128, now: u128, rand: u32, ) -> Result { let anticode = urlencoding::decode(anticode)?.to_string(); let mut anti_map = anticode.split('&').fold(HashMap::new(), |mut map, s| { let (k, v) = s.split_once('=').unwrap_or_default(); map.insert(k.to_owned(), v.to_owned()); map }); anti_map.insert("ver".to_string(), "1".to_string()); anti_map.insert("sv".to_string(), "2110211124".to_string()); anti_map.insert("seqid".to_string(), format!("{}", uid + now * 1_000)); anti_map.insert("uid".to_string(), uid.to_string()); anti_map.insert("uuid".to_string(), get_uuid(now, rand).to_string()); let seqid = anti_map["seqid"].as_str(); let ctype = anti_map["ctype"].as_str(); let t = anti_map["t"].as_str(); let result = { let mut h = Md5::new(); h.update(format!("{}|{}|{}", seqid, ctype, t)); hex::encode(h.finalize()) }; let fm = anti_map["fm"].as_str(); let fm = general_purpose::STANDARD.decode(fm)?; let fm = String::from_utf8(fm)?; let fm = fm.replace("$0", &anti_map["uid"]); let fm = fm.replace("$1", stream_name); let fm = fm.replace("$2", &result); let fm = fm.replace("$3", &anti_map["wsTime"]); let secret = { let mut h = Md5::new(); h.update(fm); hex::encode(h.finalize()) }; anti_map .insert("wsSecret".to_string(), secret) .ok_or(SeamError::NeedFix("wsSecret"))?; anti_map.remove("fm"); let mut s = String::new(); for (k, v) in anti_map { s = format!("{}&{}={}", s, k, v); } Ok(s) } #[cfg(test)] macros::gen_test!(660000); ================================================ FILE: crates/core/src/live/inke.rs ================================================ use std::collections::HashMap; use async_trait::async_trait; use crate::{ common::CLIENT, error::{Result, SeamError}, util::{hash2header, parse_url}, }; use super::{Live, Node}; const URL: &str = "https://webapi.busi.inke.cn/web/live_share_pc?uid="; /// 映客直播 /// /// https://www.inke.cn/ pub struct Client; #[async_trait] impl Live for Client { async fn get(&self, rid: &str, headers: Option>) -> Result { let json: serde_json::Value = CLIENT .get(format!("{URL}{rid}")) .headers(hash2header(headers)) .send() .await? .json() .await?; match &json["data"]["status"].as_i64() { Some(1) => { let title = json["data"]["live_name"] .as_str() .unwrap_or("inke") .to_string(); let mut urls = vec![]; for s in ["stream_addr", "hls_stream_addr", "rtmp_stream_addr"] { if !json["data"]["live_addr"][0][s].is_null() { urls.push(parse_url( json["data"]["live_addr"][0][s] .as_str() .ok_or(SeamError::NeedFix("live_addr"))? .to_string(), )); } } Ok(Node { rid: rid.to_owned(), title, cover: "".to_owned(), anchor: "".to_owned(), head: "".to_owned(), urls, }) } _ => Err(SeamError::None), } } } #[cfg(test)] macros::gen_test!(713935849); ================================================ FILE: crates/core/src/live/kk.rs ================================================ use std::collections::HashMap; use async_trait::async_trait; use regex::Regex; use crate::{ common::CLIENT, error::{Result, SeamError}, util::{hash2header, parse_url}, }; use super::{Live, Node}; const URL: &str = "https://www.kktv5.com/show/"; /// kk直播 /// /// https://www.kktv5.com/ pub struct Client; // TODO 简化后半部分逻辑, 仅判断开播与标题, 最后拼接node #[async_trait] impl Live for Client { async fn get(&self, rid: &str, headers: Option>) -> Result { let text = CLIENT .get(format!("{URL}{rid}")) .headers(hash2header(headers)) .send() .await? .text() .await?; let re = Regex::new(r"window.acotor_simple_info = ([\s\S]*?);")?; let re2 = Regex::new(r"var __actor_info__ = ([\s\S]*?) var")?; let node1 = match re.captures(&text) { Some(cap) => { let json = cap.get(1).ok_or(SeamError::NeedFix("captures"))?.as_str(); let json = serde_json::from_str::(json)?; let title = json["roomTheme"].as_str().unwrap_or("获取失败").to_owned(); let live = json["liveType"] .as_i64() .ok_or(SeamError::NeedFix("liveType"))?; match live { 1 => { let urls = vec![parse_url(format!( "https://pull.kktv8.com/livekktv/{}.flv", rid ))]; Some(Node { rid: rid.to_owned(), title, cover: "".to_owned(), anchor: "".to_owned(), head: "".to_owned(), urls, }) } _ => None, } } None => None, }; let node2 = match re2.captures(&text) { Some(cap) => { let json = cap.get(1).ok_or(SeamError::NeedFix("captures"))?.as_str(); let json = serde_json::from_str::(json)?; let title = json["roomTheme"].as_str().unwrap_or("获取失败").to_owned(); let live = json["liveType"] .as_i64() .ok_or(SeamError::NeedFix("liveType"))?; match live { 1 => { let urls = vec![parse_url(format!( "https://pull.kktv8.com/livekktv/{}.flv", rid ))]; Some(Node { rid: rid.to_owned(), title, cover: "".to_owned(), anchor: "".to_owned(), head: "".to_owned(), urls, }) } _ => None, } } None => None, }; if let Some(node) = node1 { return Ok(node); } if let Some(node) = node2 { return Ok(node); } Err(SeamError::None) } } #[cfg(test)] macros::gen_test!(521); ================================================ FILE: crates/core/src/live/ks.rs ================================================ use std::collections::HashMap; use async_trait::async_trait; use regex::Regex; use crate::{ common::CLIENT, error::{Result, SeamError}, util::{hash2header, parse_url}, }; use super::{Live, Node}; const URL: &str = "https://live.kuaishou.com/u/"; /// 快手直播 /// /// https://live.kuaishou.com/ pub struct Client; #[async_trait] impl Live for Client { // TODO 说明所需 cookie async fn get(&self, rid: &str, headers: Option>) -> Result { let text = CLIENT .get(format!("{URL}{rid}")) .headers(hash2header(headers)) .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36 Edg/117.0.2045.31") .send() .await? .text() .await?; let re = Regex::new(r" ================================================ FILE: crates/gui/package.json ================================================ { "name": "seam", "version": "0.1.4", "description": "", "scripts": { "start": "vite", "dev": "vite", "build": "vite build", "serve": "vite preview", "tauri": "tauri", "fix": "eslint --fix src/**" }, "license": "MIT", "dependencies": { "@solidjs/router": "^0.8.3", "@tauri-apps/api": "^1.5.1", "solid-js": "^1.8.3", "solid-spinner": "^0.2.0", "solid-toast": "^0.5.0", "solid-transition-group": "^0.2.3" }, "devDependencies": { "@tauri-apps/cli": "^1.5.6", "@types/node": "^20.8.8", "@typescript-eslint/eslint-plugin": "^6.9.0", "@typescript-eslint/parser": "^6.9.0", "eslint": "^8.52.0", "eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-solid": "^0.13.0", "typescript": "^5.2.2", "vite": "^4.5.0", "vite-plugin-solid": "^2.7.2" } } ================================================ FILE: crates/gui/src/App.css ================================================ .not-draggable { user-select: none; } .container { width: 100%; height: calc(100% - 40px); display: flex; flex-direction: row; } input, button { text-align: center; border-radius: 8px; border: 1px solid transparent; font-weight: 500; font-family: inherit; color: #ffffff; background-color: #0f0f0fc9; transition: all 0.3s ease; box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6), 2px 2px 5px 0px rgba(0, 0, 0, 0.7); } button { cursor: pointer; } input:hover, button:hover { filter: brightness(1.4); } .content { flex: 1; height: 100%; background-color: #373737; overflow: auto; } /* 美化滚动条 */ ::-webkit-scrollbar { width: 0; } ::-webkit-scrollbar-track { background-color: #3f3f3f; } ::-webkit-scrollbar-thumb { background-color: #555; } ================================================ FILE: crates/gui/src/App.tsx ================================================ import "./App.css" import "./css/TopBar.css" import { useRoutes } from "@solidjs/router" import { lazy, onMount } from "solid-js" import { Toaster } from "solid-toast" import Control from "./components/Control" import SideBar from "./components/SideBar" import TopBar from "./components/TopBar" const App = () => { onMount(async () => { // 生产环境, 全局取消右键菜单; if (!import.meta.env.DEV) { document.oncontextmenu = (event) => { event.preventDefault() } } // 避免窗口闪烁, 等待500ms再显示窗口 // 这个该死的bug什么时候才能修 setTimeout(() => { setupWindow() }, 500) }) const setupWindow = async () => { const appWindow = (await import("@tauri-apps/api/window")).appWindow appWindow.show() } const routes = [ { path: "/", component: lazy(() => import("./pages/Home")) }, { path: "/good", component: lazy(() => import("./pages/Good")) }, { path: "/chart", component: lazy(() => import("./pages/Chart")) }, { path: "/setting", component: lazy(() => import("./pages/Setting")) }, ] const Routes = useRoutes(routes) return ( <>
) } export default App ================================================ FILE: crates/gui/src/components/Control.tsx ================================================ import "../css/Control.css" import { appWindow } from "@tauri-apps/api/window" import { Show } from "solid-js" // TODO 修复最小化, 隐藏时的渲染bug const Minimize = () => { return ( ) } const Maximize = () => { return ( ) } const Close = () => { return ( ) } export interface BarProps { minimize?: boolean maximize?: boolean close?: boolean } const defaultProps: BarProps = { minimize: true, maximize: true, close: true, } const Control = (props: BarProps) => { const setting = { ...defaultProps, ...props } return ( <>
appWindow.minimize()}> {Minimize()}
{Maximize()}
appWindow.close()} title="关闭"> {Close()}
) } export default Control ================================================ FILE: crates/gui/src/components/GoodItem.tsx ================================================ import "../css/GoodItem.css" import { AddIcon, CopyIcon, PlayIcon } from "../icon/icon" interface Url { format: string; url: string; } interface GoodItemProps { live: string; rid: string; title: string; anchor: string; urls: Url[]; img?: string; } const GoodItem = (props: GoodItemProps) => { return (
{props.title}
快来看
) } export default GoodItem ================================================ FILE: crates/gui/src/components/Live.tsx ================================================ import "../css/Live.css" import { CopyIcon, PlayIcon } from "../icon/icon" interface Url { format: string; url: string; } interface LiveProps { live: string; rid: string; title: string; anchor: string; urls: Url[]; img?: string; } // TODO 减少内阴影的使用, 按钮的样式应该简洁一些 const Live = (props: LiveProps) => { return (
{props.title}
) } export default Live ================================================ FILE: crates/gui/src/components/Panel.tsx ================================================ import "../css/Panel.css" import { Accessor, For, Setter } from "solid-js" import allLives from "../model/Live" interface LiveProps { flag: Setter; live: Accessor; setLive: Setter; } const Panel = (props: LiveProps) => { return (
props.flag(true)} onMouseLeave={() => props.flag(false)}>
{(item) => (
props.setLive(item.cmd)}> {item.name}
)}
) } export default Panel ================================================ FILE: crates/gui/src/components/SideBar.tsx ================================================ import "../css/SideBar.css" import { useLocation } from "@solidjs/router" import { createMemo } from "solid-js" import { ChartIcon, GoodIcon, HomeIcon, SettingIcon } from "../icon/icon" import SideItem from "./SideItem" const SideBar = () => { const pathname = createMemo(() => { return useLocation().pathname }) return ( ) } export default SideBar ================================================ FILE: crates/gui/src/components/SideItem.tsx ================================================ import "../css/SideItem.css" import { A } from "@solidjs/router" import { JSX } from "solid-js/jsx-runtime" const SideItem = (props: { children: JSX.Element; path: string; bottom?: boolean; pathname: () => string; }) => { return (
{props.children}
) } export default SideItem ================================================ FILE: crates/gui/src/components/TopBar.tsx ================================================ import { invoke } from "@tauri-apps/api" import { createSignal } from "solid-js" import { Spinner, SpinnerType } from "solid-spinner" import toast from "solid-toast" import { Transition } from "solid-transition-group" import { AddIcon, SyncIcon } from "../icon/icon" import { Resp } from "../model/Resp" import Panel from "./Panel" const TopBar = () => { const [refresh, setRefresh] = createSignal(false) const [rid, setRid] = createSignal("") const [onInput, setInput] = createSignal(false) const [onPanel, setPanel] = createSignal(false) const [live, setLive] = createSignal("bili") // TODO 添加成功后应该发布事件, 让 Chart 页面刷新, // 当然如果Chart没有接收到消息,说明当前并没有打开Chart页面, 那么就不需要刷新了 const add = async () => { await invoke>("subscribe_add", { live: live(), rid: rid(), }).then((p) => { if (p.code === 0) { console.log(p.data) toast.success("添加成功") } else { toast.error(p.msg) } }) } return (
{ setInput(true) }} onFocusOut={() => { setInput(false) }} onInput={async (e) => { setRid(e.target.value) }} /> {(onInput() || onPanel()) && ( )}
) } export default TopBar ================================================ FILE: crates/gui/src/css/Chart.css ================================================ .chart { width: 60%; height: 100%; margin: 0 auto; color: #fff; padding: 20px; display: flex; flex-direction: column; align-items: center; } .chart-kind-container { flex-shrink: 0; display: flex; flex-wrap: wrap; border-radius: 8px; align-content: space-between; overflow: hidden; border-radius: 8px; background-color: #0f0f0faf; backdrop-filter: blur(10px); box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6), 2px 2px 5px 0px rgba(0, 0, 0, 0.7); } .chart-kind-item { flex: 1; font-size: 16px; font-weight: bold; color: #fff; padding: 5px 8px; cursor: pointer; text-align: center; box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6); white-space: nowrap; } .chart-kind-item:not(.chart-kind-item-activate):hover { background-color: #5f5f5f; } .chart-kind-item-activate { background-color: #ff8c00; box-shadow: inset 0.3px 0.3px 3px 0.9px rgba(260, 260, 260, 0.9); } .chart-table { margin: 20px 0; border-radius: 8px; overflow: hidden; background-color: #0f0f0faf; backdrop-filter: blur(10px); box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6), 2px 2px 5px 0px rgba(0, 0, 0, 0.7); } .chart-table th { padding: 8px; text-align: center; font-size: 16px; font-weight: bold; box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6); white-space: nowrap; } .chart-table td { padding: 5px; text-align: center; box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6); white-space: nowrap; } .chart-table tr:hover { background-color: #5f5f5f; } .chart-table-title-1 { text-align: center; width: 80px; } .chart-table-title-2 { text-align: center; width: 140px; } .chart-table-title-3 { text-align: center; width: 140px; } .chart-table-title-4 { text-align: center; width: 100px; } .chart-table button { padding: 5px 8px; } .chart-separator { flex-shrink: 0; width: 100%; height: 10px; } ================================================ FILE: crates/gui/src/css/Control.css ================================================ .control { position: absolute; right: 0; display: flex; text-align: center; justify-content: center; } .control-item { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; color: #5b595c; box-sizing: border-box; padding: 10px; } .control-item:hover { color: #fff; background-color: #373737; } .control-item.control-item-close:hover { color: #fff; background-color: #cd1a2b; } ================================================ FILE: crates/gui/src/css/Good.css ================================================ .good { color: #fff; display: flex; flex-wrap: wrap; } ================================================ FILE: crates/gui/src/css/GoodItem.css ================================================ .good-item { width: 460px; height: 300px; position: relative; background-color: aliceblue; } .good-img { width: 100%; height: 100%; object-fit: cover; object-position: center; } .good-panel { position: absolute; bottom: 0; width: 100%; height: 100px; background: #1d1d1d6d; backdrop-filter: blur(10px); /* box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6); */ } .good-title { padding: 10px 10px 0 10px; font-size: 16px; font-weight: bold; } .good-info { font-size: 14px; padding: 5px 0 0 10px; } .good-control { position: absolute; bottom: 0; right: 0; margin-bottom: 5px; } .good-control-btn { width: 30px; height: 30px; margin-right: 5px; } ================================================ FILE: crates/gui/src/css/Home.css ================================================ .home { color: #fff; display: flex; flex-wrap: wrap; } ================================================ FILE: crates/gui/src/css/Live.css ================================================ .live { width: 230px; height: 150px; position: relative; } .live-img { width: 100%; height: 100%; object-fit: cover; object-position: center; } .live-panel { position: absolute; bottom: 0; width: 100%; height: 60px; background: #1d1d1d6d; backdrop-filter: blur(10px); /* box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6); */ } .live-title { padding: 5px; font-size: 14px; font-weight: bold; } .live-control { text-align: right; } .live-control-btn { width: 30px; height: 30px; margin-right: 5px; } ================================================ FILE: crates/gui/src/css/Panel.css ================================================ .panel { width: 400px; position: absolute; z-index: 10; top: 40px; left: 300px; background-color: #0f0f0fa0; backdrop-filter: blur(10px); border-radius: 8px; box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6); } .panel-container { display: flex; flex-wrap: wrap; border-radius: 8px; align-content: space-between; overflow: hidden; } .panel-item { flex: 1; font-size: 14px; font-weight: bold; color: #fff; padding: 6px 10px; cursor: pointer; text-align: center; box-shadow: inset 0.3px 0.3px 3px 0.1px rgba(160, 160, 160, 0.6); white-space: nowrap; } .panel-item:not(.panel-item-activate):hover { background-color: #5d5d5d5d; } .panel-item-activate { background-color: #ff8c00; box-shadow: inset 0.3px 0.3px 3px 0.9px rgba(260, 260, 260, 0.9); } ================================================ FILE: crates/gui/src/css/Setting.css ================================================ .setting { width: 60%; height: 100%; margin: 0 auto; color: #fff; padding: 20px; display: flex; flex-direction: column; align-items: center; } .setting-title { width: 100%; font-size: 20px; font-weight: bold; padding: 5px; border-bottom: 1px solid #fff; margin-bottom: 5px; } .setting-item { width: 100%; display: flex; justify-content: space-between; padding: 10px 5px; font-size: 16px; font-weight: bold; } .setting-item:hover { background-color: #525252; } .setting-item-title { line-height: 30px; } .setting-input { width: 150px; height: 30px; } .setting-btn { width: 50px; height: 30px; padding: 5px; margin-left: 10px; } .setting-arg { width: 210px; } .setting-textarea { width: 100%; min-height: 100px; border-radius: 8px; border: 1px solid transparent; font-weight: 500; font-family: inherit; color: #ffffff; background-color: #0f0f0fc9; padding: 10px; transition: all 0.3s ease; box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6), 2px 2px 5px 0px rgba(0, 0, 0, 0.7); } .setting-save { width: 100px; height: 30px; padding: 5px; margin-top: 30px; } ================================================ FILE: crates/gui/src/css/SideBar.css ================================================ .side-bar { width: 80px; height: 100%; background-color: #1e1e1e; } ================================================ FILE: crates/gui/src/css/SideItem.css ================================================ .side-link { cursor: default; } .side-item { width: 80px; height: 75px; display: flex; justify-content: center; align-items: center; } .side-item:not(.side-item-selected):hover { background-color: #373737; } .side-item-selected { background-color: #575757; } .side-item-bottom { position: absolute; bottom: 0; } ================================================ FILE: crates/gui/src/css/TopBar.css ================================================ .top-bar { display: flex; justify-content: center; align-items: center; } .refresh { height: 20px; width: 20px; display: flex; justify-content: center; align-items: center; } .top-bar-input { width: 400px; height: 30px; margin: 5px; font-weight: bold; font-size: 14px; } .top-bar-btn { width: 30px; height: 30px; padding: 0; display: flex; justify-content: center; align-items: center; } .slide-fade-enter-active, .slide-fade-exit-active { transition: opacity 0.3s, transform 0.3s; } .slide-fade-enter, .slide-fade-exit-to { opacity: 0; } ================================================ FILE: crates/gui/src/icon/icon.tsx ================================================ interface IconProps { size: number } const HomeIcon = (props: IconProps) => { return ( ) } const GoodIcon = (props: IconProps) => { return ( ) } const SettingIcon = (props: IconProps) => { return ( ) } const SyncIcon = (props: IconProps) => { return ( ) } const AddIcon = (props: IconProps) => { return ( ) } const CopyIcon = (props: IconProps) => { return ( ) } const PlayIcon = (props: IconProps) => { return ( ) } const ChartIcon = (props: IconProps) => { return ( ) } export { AddIcon, ChartIcon, CopyIcon, GoodIcon, HomeIcon, PlayIcon, SettingIcon, SyncIcon, } ================================================ FILE: crates/gui/src/index.tsx ================================================ /* @refresh reload */ import "./styles.css" import { Router } from "@solidjs/router" import { render } from "solid-js/web" import App from "./App" render( () => ( ), document.getElementById("root") as HTMLElement ) ================================================ FILE: crates/gui/src/model/Live.tsx ================================================ export interface LiveItem { name: string; cmd: string; } const allLives = (): LiveItem[] => { const lives: LiveItem[] = [ { name: "B站", cmd: "bili", }, { name: "斗鱼", cmd: "douyu", }, { name: "抖音", cmd: "douyin", }, { name: "虎牙", cmd: "huya", }, { name: "快手", cmd: "ks", }, { name: "CC", cmd: "cc", }, { name: "花椒", cmd: "huajiao", }, { name: "艺气山", cmd: "yqs", }, { name: "棉花糖", cmd: "mht", }, { name: "KK", cmd: "kk", }, { name: "千帆", cmd: "qf", }, { name: "Now", cmd: "now", }, { name: "映客", cmd: "inke", }, { name: "afreeca", cmd: "afreeca", }, { name: "pandalive", cmd: "panda", }, { name: "flex", cmd: "flex", }, { name: "wink", cmd: "wink", }, ] return lives } export default allLives ================================================ FILE: crates/gui/src/model/Record.tsx ================================================ export interface SubscribeRecord { live: string; rid: string; } ================================================ FILE: crates/gui/src/model/Resp.tsx ================================================ export interface Resp { code: number; msg: string; data: T; } ================================================ FILE: crates/gui/src/pages/Chart.tsx ================================================ import "../css/Chart.css" import { invoke } from "@tauri-apps/api" import { createMemo, createSignal, For, onMount } from "solid-js" import toast from "solid-toast" import allLives from "../model/Live" import { Resp } from "../model/Resp" interface Record { index: number live: string rid: string anchor: string } interface Subscribe { live: string rid: string } const Chart = () => { const [selected, setSelect] = createSignal("all") const [records, setRecords] = createSignal([]) // 开启页面获取 records 数据 onMount(async () => { const subscribes = await invoke>("subscribe_all") console.log(subscribes) const map = subscribes.data.map((item, index) => { return { index: index, live: item.live, rid: item.rid, anchor: "未知", } setRecords(map) }) setRecords(map) }) const filterRecords = createMemo(() => { if (selected() === "all") { return records() } else { return records().filter((item) => item.live === selected()) } }) const deleteRecord = async (index: number) => { const item = records().find((item) => item.index === index) setRecords(records().filter((item) => item.index !== index)) await invoke>("subscribe_remove", { live: item?.live, rid: item?.rid, }).then((resp) => { if (resp.code === 0) { toast.success("删除成功") } else { toast.error(resp.msg) } }) } const liveName = (live: string) => { const lives = allLives() for (let i = 0; i < lives.length; i++) { if (lives[i].cmd === live) { return lives[i].name } } return "未知" } return (
setSelect("all")}> ALL
{(item) => (
setSelect(item.cmd)}> {item.name}
)}
{(item) => ( )}
平台 房间号 主播 操作
{liveName(item.live)} {item.rid} {item.anchor}
) } export default Chart ================================================ FILE: crates/gui/src/pages/Good.tsx ================================================ import "../css/Good.css" import GoodItem from "../components/GoodItem" const Good = () => { const goodDemo = { live: "douyu", rid: "123", title: "恭喜你发现了我~", anchor: "我是谁", urls: [], img: undefined, } return (
) } export default Good ================================================ FILE: crates/gui/src/pages/Home.tsx ================================================ import "../css/Home.css" import Live from "../components/Live" const Home = () => { const liveDemo = { live: "douyu", rid: "123", title: "恭喜你发现了我~", anchor: "我是谁", urls: [], img: undefined, } return (
) } export default Home ================================================ FILE: crates/gui/src/pages/Setting.tsx ================================================ import "../css/Setting.css" import { open } from "@tauri-apps/api/dialog" import toast from "solid-toast" // TODO headers 设置分离, 取消 textarea, 列表, // 顶部添加, 选择平台, 输入字段, 值, 点击添加 // 列表最右边添加删除按钮, 点击删除 // 列表的值可以修改, 点击修改 const Setting = () => { const save = () => { toast.success("保存成功") } return (
播放
播放器
参数
{/* TODO 将目前已知需要额外配置的 cookie 写入此处, 让用户知道哪些需要额外配置 */}
Headers