[
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: github-actions\n    directory: /\n    schedule:\n      interval: daily\n  - package-ecosystem: cargo\n    directory: /\n    schedule:\n      interval: daily\n"
  },
  {
    "path": ".github/workflows/release_cli.yml",
    "content": "name: Build CLI Releases\n\npermissions:\n  contents: write\n\non:\n  push:\n    tags:\n      - v_cli.*\n\nenv:\n  CARGO_TERM_COLOR: always\n\njobs:\n  create_release:\n    runs-on: ubuntu-latest\n    outputs:\n      changes: ${{ steps.changelog_reader.outputs.changes }}\n      version: ${{ steps.changelog_reader.outputs.VERSION }}\n    steps:\n      - uses: actions/checkout@v4\n      - name: Get version number\n        id: get_version\n        run: |\n          VERSION=${GITHUB_REF#refs/tags/}\n          VERSION=${VERSION/v_cli./}\n          echo \"::set-output name=version::$VERSION\"\n      - name: Changelog Reader\n        id: changelog_reader\n        uses: mindsers/changelog-reader-action@v2.2.2\n        with:\n          path: './crates/cli/CHANGELOG.md'\n          version: ${{ steps.get_version.outputs.version }}\n\n  build-cross:\n    needs: create_release\n    runs-on: ubuntu-latest\n    env:\n      RUST_BACKTRACE: full\n    strategy:\n      fail-fast: false\n      matrix:\n        target:\n          - i686-unknown-linux-musl\n          - x86_64-pc-windows-gnu\n          - x86_64-unknown-linux-gnu\n          - x86_64-unknown-linux-musl\n          - armv7-unknown-linux-musleabihf\n          - armv7-unknown-linux-gnueabihf\n          - arm-unknown-linux-gnueabi\n          - arm-unknown-linux-gnueabihf\n          - arm-unknown-linux-musleabi\n          - arm-unknown-linux-musleabihf\n          - aarch64-unknown-linux-gnu\n          - aarch64-unknown-linux-musl\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Install Rust\n        uses: actions-rs/toolchain@v1\n        with:\n          profile: minimal\n          target: ${{ matrix.target }}\n          toolchain: stable\n          default: true\n          override: true\n\n      - name: Install cross\n        run: cargo install cross\n\n      - name: Build ${{ matrix.target }}\n        timeout-minutes: 120\n        run: |\n          compile_target=${{ matrix.target }}\n\n          # if [[ \"$compile_target\" == *\"-linux-\"* || \"$compile_target\" == *\"-apple-\"* ]]; then\n          #   compile_features=\"-f local-redir -f local-tun\"\n          # fi\n\n          if [[ \"$compile_target\" == \"mips-\"* || \"$compile_target\" == \"mipsel-\"* || \"$compile_target\" == \"mips64-\"* || \"$compile_target\" == \"mips64el-\"* ]]; then\n            sudo apt-get update -y && sudo apt-get install -y upx;\n            if [[ \"$?\" == \"0\" ]]; then\n              compile_compress=\"-u\"\n            fi\n          fi\n\n          cd build\n          chmod +x build-release\n          ./build-release -t ${{ matrix.target }} $compile_features $compile_compress\n\n      - name: Upload Github Assets\n        uses: softprops/action-gh-release@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          name: Seam CLI ${{ needs.create_release.outputs.version }}\n          body: |\n            ${{ needs.create_release.outputs.changes }}\n          files: build/release/*\n          draft: false\n          prerelease: false\n\n  build-unix:\n    needs: create_release\n    runs-on: ${{ matrix.os }}\n    env:\n      BUILD_EXTRA_FEATURES: ''\n      RUST_BACKTRACE: full\n    strategy:\n      matrix:\n        os: [macos-latest]\n        target:\n          - x86_64-apple-darwin\n          - aarch64-apple-darwin\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Install GNU tar\n        if: runner.os == 'macOS'\n        run: |\n          brew install gnu-tar\n          echo \"/usr/local/opt/gnu-tar/libexec/gnubin\" >> $GITHUB_PATH\n\n      - name: Install Rust\n        uses: actions-rs/toolchain@v1\n        with:\n          profile: minimal\n          target: ${{ matrix.target }}\n          toolchain: stable\n          default: true\n          override: true\n\n      - name: Build release\n        shell: bash\n        run: |\n          chmod +x ./build/build-host-release\n          ./build/build-host-release -t ${{ matrix.target }}\n\n      - name: Upload Github Assets\n        uses: softprops/action-gh-release@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          name: Seam CLI ${{ needs.create_release.outputs.version }}\n          body: |\n            ${{ needs.create_release.outputs.changes }}\n          files: build/release/*\n          draft: false\n          prerelease: false\n\n  build-windows:\n    needs: create_release\n    runs-on: windows-latest\n    env:\n      RUSTFLAGS: '-C target-feature=+crt-static'\n      RUST_BACKTRACE: full\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Install Rust\n        uses: actions-rs/toolchain@v1\n        with:\n          profile: minimal\n          toolchain: stable\n          default: true\n          override: true\n\n      - name: Build release\n        run: |\n          pwsh ./build/build-host-release.ps1\n\n      - name: Upload Github Assets\n        uses: softprops/action-gh-release@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          name: Seam CLI ${{ needs.create_release.outputs.version }}\n          body: |\n            ${{ needs.create_release.outputs.changes }}\n          files: build/release/*\n          draft: false\n          prerelease: false\n"
  },
  {
    "path": ".github/workflows/release_gui.yml",
    "content": "name: Build GUI Release\n\npermissions:\n  contents: write\n\non:\n  push:\n    tags:\n      - v_gui.*\n\njobs:\n  create_release:\n    runs-on: ubuntu-latest\n    outputs:\n      changes: ${{ steps.changelog_reader.outputs.changes }}\n      version: ${{ steps.changelog_reader.outputs.VERSION }}\n    steps:\n      - uses: actions/checkout@v4\n      - name: Get version number\n        id: get_version\n        run: |\n          VERSION=${GITHUB_REF#refs/tags/}\n          VERSION=${VERSION/v_gui./}\n          echo \"::set-output name=version::$VERSION\"\n      - name: Changelog Reader\n        id: changelog_reader\n        uses: mindsers/changelog-reader-action@v2.2.2\n        with:\n          path: './crates/gui/CHANGELOG.md'\n          version: ${{ steps.get_version.outputs.version }}\n\n  build_seam:\n    needs: create_release\n    strategy:\n      fail-fast: false\n      matrix:\n        platform: [macos-latest, ubuntu-latest, windows-latest]\n    runs-on: ${{ matrix.platform }}\n    steps:\n      - uses: actions/checkout@v4\n      - name: Set up node\n        uses: actions/setup-node@v4\n        with:\n          node-version: 18\n          cache: 'yarn'\n          cache-dependency-path: 'crates/gui/yarn.lock'\n      - name: Install Rust stable\n        uses: dtolnay/rust-toolchain@stable\n      - name: install dependencies (ubuntu only)\n        if: matrix.platform == 'ubuntu-latest'\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf\n      - name: install frontend dependencies\n        run: |\n          cd crates/gui\n          yarn install --frozen-lockfile\n      - uses: tauri-apps/tauri-action@v0\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          projectPath: 'crates/gui'\n          tagName: '${{github.ref_name}}'\n          releaseName: 'Seam GUI ${{ needs.create_release.outputs.version }}'\n          releaseBody: '${{ needs.create_release.outputs.changes }}'\n          releaseDraft: false\n          prerelease: false\n"
  },
  {
    "path": ".github/workflows/rust-clippy.yml",
    "content": "# This workflow uses actions that are not certified by GitHub.\n# They are provided by a third-party and are governed by\n# separate terms of service, privacy policy, and support\n# documentation.\n# rust-clippy is a tool that runs a bunch of lints to catch common\n# mistakes in your Rust code and help improve your Rust code.\n# More details at https://github.com/rust-lang/rust-clippy\n# and https://rust-lang.github.io/rust-clippy/\n\nname: rust-clippy analyze\n\non:\n  push:\n    branches: [ \"master\" ]\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches: [ \"master\" ]\n  schedule:\n    - cron: '31 23 * * 3'\n\njobs:\n  rust-clippy-analyze:\n    name: Run rust-clippy analyzing\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      security-events: write\n      actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Install Rust toolchain\n        uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af #@v1\n        with:\n          profile: minimal\n          toolchain: stable\n          components: clippy\n          override: true\n\n      - name: Install required cargo\n        run: cargo install clippy-sarif sarif-fmt\n\n      - name: Run rust-clippy\n        run:\n          cargo clippy\n          --all-features\n          --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt\n        continue-on-error: true\n\n      - name: Upload analysis results to GitHub\n        uses: github/codeql-action/upload-sarif@v2\n        with:\n          sarif_file: rust-clippy-results.sarif\n          wait-for-processing: true\n"
  },
  {
    "path": ".gitignore",
    "content": "/target\n/.idea\n/bak"
  },
  {
    "path": "Cargo.toml",
    "content": "[workspace]\nmembers = [\n    \"crates/cli\",\n    \"crates/gui/src-tauri\",\n    \"crates/core\",\n    \"crates/danmu\",\n    \"crates/marcos\",\n    \"crates/status\",\n]\nresolver = \"2\"\n\n[profile.release]\nopt-level = \"z\"\nlto = true\ncodegen-units = 1\npanic = \"abort\"\nstrip = \"symbols\"\n"
  },
  {
    "path": "LICENSE-MIT",
    "content": "MIT License\n\nCopyright (c) 2023 Borber\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "LICENSE-UNLICENSE",
    "content": "This is free and unencumbered software released into the public domain.\n\nAnyone is free to copy, modify, publish, use, compile, sell, or\ndistribute this software, either in source code form or as a compiled\nbinary, for any purpose, commercial or non-commercial, and by any\nmeans.\n\nIn jurisdictions that recognize copyright laws, the author or authors\nof this software dedicate any and all copyright interest in the\nsoftware to the public domain. We make this dedication for the benefit\nof the public at large and to the detriment of our heirs and\nsuccessors. We intend this dedication to be an overt act of\nrelinquishment in perpetuity of all present and future rights to this\nsoftware under copyright law.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\nIN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR\nOTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,\nARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE SOFTWARE.\n\nFor more information, please refer to <http://unlicense.org>\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n    <img src=\"./assets/icon.png\" style=\"width: 150px;\" alt=\"Seam\" />\n</p>\n\n<h2 align=\"center\">\n  Seam\n</h2>\n\n<h2 align=\"center\">\n  <a href=\"https://github.com/Borber/seam\">\n    <img src=\"https://img.shields.io/badge/github-Borber/seam-8da0cb.svg?style=for-the-badge&logo=github\" alt=\"Github\"/>\n  </a>\n  <a href=\"https://github.com/Borber/seam/releases/latest\">\n    <img src=\"https://img.shields.io/github/downloads/Borber/seam/total.svg?style=for-the-badge&color=82E0AA&logo=github\" alt=\"Downloads\"/>\n  </a>\n  <img src=\"https://img.shields.io/github/license/borber/seam?color=%2398cbed&logo=rust&style=for-the-badge\" alt=\"LICENSE\"/>\n</h2>\n\n> 原 `SBtream` 项目, 经历 python 不成熟的模仿, Java 重构烂尾, 目前使用 rust 进行重构开发\n\n多平台直播源地址获取\n\n# 待办\n\n欢迎各位大佬 PR , 积极响应, 友善沟通, 快速 CR, 给您最好的开源体验\n\n-   [ ] GUI 从获取模式切换为订阅模式\n-   [ ] 添加日志模块, 以便于用户反馈问题\n    -   [ ] 输出日志文件\n-   [ ] 链接识别\n    -   规定每个平台都需要实现判断一个链接是否是自己的, 并返回正确的 rid\n-   [ ] 提取 CLI GUI 公共模块\n    -   [ ] util\n    -   [ ] config\n        -   即使 cli 和 gui 有部分不重叠的部分, 但应该还是重叠部分更多\n-   [ ] I18N\n-   [ ] GUI action 添加便携版本, 方便已经安装了 WebView2 的用户使用\n\n# GUI\n\n![GUI](assets/gui.png)\n\n## [详情](crates/gui/README.md)\n\n# CLI\n\n```bash\n❯ .\\seam.exe -l douyu -i 88080\nhttp://url1\n\nhttp://url2\n```\n\n## [详情](crates/cli/README.md)\n\n# 下载\n\n[Releases · seam](https://github.com/Borber/seam/releases) 下载 `GUI`/`CLI`可执行文件\n\n# 使用\n\n| **平台**                              | **代号** | **`<RID>` 位置**                                                         | **详情** | **弹幕** |\n| ------------------------------------- | -------- | ------------------------------------------------------------------------ | ------------ | -------- |\n| [B 站](https://live.bilibili.com/)    | bili     | `https://live.bilibili.com/<RID>`                                        | ✅           | ✅       |\n| [斗鱼](https://www.douyu.com/)        | douyu    | `https://www.douyu.com/<RID>` 或 `https://www.douyu.com/xx/xx?rid=<RID>` | ✅           |          |\n| [抖音](https://live.douyin.com/)      | douyin   | `https://live.douyin.com/<RID>`                                          | ✅           |          |\n| [虎牙](https://huya.com/)             | huya     | `https://huya.com/<RID>`                                                 |              |          |\n| [快手](https://live.kuaishou.com/)    | ks       | `https://live.kuaishou.com/u/<RID>`                                      |              |          |\n| [CC](https://cc.163.com/)             | cc       | `https://cc.163.com/<RID>`                                               |              |          |\n| [花椒](https://www.huajiao.com/)      | huajiao  | `https://www.huajiao.com/l/<RID>`                                        |              |          |\n| [艺气山](https://www.173.com/)        | yqs      | `https://www.173.com/<RID>`                                              |              |          |\n| [棉花糖](https://www.2cq.com/)        | mht      | `https://www.2cq.com/<RID>`                                              |              |          |\n| [kk](https://www.kktv5.com/)          | kk       | `https://www.kktv5.com/show/<RID>`                                       |              |          |\n| [千帆](https://qf.56.com/)            | qf       | `https://qf.56.com/<RID>`                                                |              |          |\n| [Now](https://now.qq.com/)            | now      | `https://now.qq.com/pcweb/story.html?roomid=<RID>`                       |              |          |\n| [映客](https://www.inke.cn/)          | inke     | `https://www.inke.cn/liveroom/index.html?uid=<RID>`                      |              |          |\n| [afreeca](https://afreecatv.com/)     | afreeca  | `https://bj.afreecatv.com/<RID>`                                         |              |          |\n| [panda](https://www.pandalive.co.kr/) | panda    | `https://www.pandalive.co.kr/channel/<RID>`                              |              |          |\n| [flex](https://www.flextv.co.kr/)     | flex     | `https://www.flextv.co.kr/channels/<RID>`                                |              |          |\n| [wink](https://www.winktv.co.kr/)     | wink     | `https://www.winktv.co.kr/channel/<RID>`                                 |              |          |\n\n# 配置\n\n`config.toml` 放置在 `seam` 可执行文件所在目录下\n\n```toml\n# 播放器路径或命令\n# 请自行安装播放器, 请确认它可以通过命令行+链接打开视频文件\n[play]\n# potplayer 样例\n# bin = \"C:\\\\Program Files (x86)\\\\Pure Codec\\\\x64\\\\PotPlayerMini64.exe\"\nbin = \"mpv\"\n# 播放器参数\nargs = []\n\n# headers 支持所有合法 http 请求头字段\n# global 为全局请求头, 会被各平台请求头覆盖\n# 请注意 不要覆盖虎牙的 user-agent, 否则会导致获取失败\n[headers.global]\n# 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\"\n\n# 抖音 cookie 必须\n[headers.douyin]\ncookie = \"xxxx\"\n\n# 快手 cookie 必须\n[headers.ks]\ncookie = \"xxxx\"\n\n# 斗鱼设置登录账户 cookie 情况下可以获取到备用线路高清源\n[headers.douyu]\ncookie = \"xxxx\"\n\n# [rid]: 房间号\n# [title]: 标题\n# [time]: 时间戳\n# [date]: 日期\n[file_name]\n# 录制文件标题\nvideo = \"[rid]-[title]-[date]-[time]\"\n# danmu文件标题\ndanmu = \"[rid]-[title]-[date]-[time]\"\n\n\n```\n\n> cookie 获取方法: [额外说明](./doc/配置说明.md)\n\n# 路线\n\n[seam](https://github.com/users/Borber/projects/4/views/1)\n\n# 相关项目\n\n-   [seamui](https://github.com/kirito41dd/seamui) 由 [kirito41dd](https://github.com/kirito41dd) 开发的`seam`图形化界面\n-   [SeamPotPlayer](https://github.com/chen310/SeamPotPlayer/) 由[chen310](https://github.com/chen310) 开发, 直接在 PotPlayer 中调用 seam 播放直播\n\n## 贡献者\n\n[![GitHub Contributors](https://contrib.rocks/image?repo=Borber/seam)](https://github.com/Borber/seam/graphs/contributors)\n\n# 感谢\n\n-   [wbt5/real-url](https://github.com/wbt5/real-url/)\n-   [banner](https://textkool.com/en/ascii-art-generator?hl=default&vl=default&font=Chunky&text=SEAM)\n-   [手把手教你破解斗鱼 sign 算法](https://zhuanlan.zhihu.com/p/107330805)\n\n## Star History\n\n<a href=\"https://github.com/Borber/seam/stargazers\">\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=Borber/seam&type=Date&theme=dark\" />\n    <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=Borber/seam&type=Date\" />\n    <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=Borber/seam&type=Date\" />\n  </picture>\n</a>\n"
  },
  {
    "path": "TODO.md",
    "content": "-   给 Client 添加函数返回其函数调用信息, 命令, 平台名称\n"
  },
  {
    "path": "build/build-host-release",
    "content": "#!/bin/bash\n\nBUILD_TARGET=\"\"\nBUILD_FEATURES=()\nwhile getopts \"t:f:\" opt; do\n    case $opt in\n    t)\n        BUILD_TARGET=$OPTARG\n        ;;\n    f)\n        BUILD_FEATURES+=($OPTARG)\n        ;;\n    ?)\n        echo \"Usage: $(basename $0) [-t <target-triple>] [-f <feature>]\"\n        ;;\n    esac\ndone\n\nBUILD_FEATURES+=${BUILD_EXTRA_FEATURES}\n\nROOT_DIR=$(cd $(dirname $0) && pwd)\nVERSION=$(grep -E '^version' \"${ROOT_DIR}/../crates/cli/Cargo.toml\" | awk '{print $3}' | sed 's/\"//g')\nHOST_TRIPLE=$(rustc -Vv | grep 'host:' | awk '{print $2}')\n\necho \"Started build release ${VERSION} for ${HOST_TRIPLE} (target: ${BUILD_TARGET}) with features \\\"${BUILD_FEATURES}\\\"...\"\n\nif [[ \"${BUILD_TARGET}\" != \"\" ]]; then\n    if [[ \"${BUILD_FEATURES}\" != \"\" ]]; then\n        cargo build --package seam --release --features \"${BUILD_FEATURES}\" --target \"${BUILD_TARGET}\"\n    else\n        cargo build --package seam --release --target \"${BUILD_TARGET}\"\n    fi\nelse\n    if [[ \"${BUILD_FEATURES}\" != \"\" ]]; then\n        cargo build --package seam --release --features \"${BUILD_FEATURES}\"\n    else\n        cargo build --package seam --release\n    fi\nfi\n\nif [[ \"$?\" != \"0\" ]]; then\n    exit 1\nfi\n\nif [[ \"${BUILD_TARGET}\" == \"\" ]]; then\n    BUILD_TARGET=$HOST_TRIPLE\nfi\n\nTARGET_SUFFIX=\"\"\nif [[ \"${BUILD_TARGET}\" == *\"-windows-\"* ]]; then\n    TARGET_SUFFIX=\".exe\"\nfi\n\nTARGETS=(\"seam${TARGET_SUFFIX}\" )\n\nRELEASE_FOLDER=\"${ROOT_DIR}/release\"\nRELEASE_PACKAGE_NAME=\"seam-v${VERSION}.${BUILD_TARGET}\"\n\nmkdir -p \"${RELEASE_FOLDER}\"\n\n# Into release folder\nif [[ \"${BUILD_TARGET}\" != \"\" ]]; then\n    cd \"${ROOT_DIR}/../target/${BUILD_TARGET}/release\"\nelse\n    cd \"${ROOT_DIR}/../target/release\"\nfi\n\ncp \"${ROOT_DIR}/../config.toml\" ./\n\nif [[ \"${BUILD_TARGET}\" == *\"-windows-\"* ]]; then\n    # For Windows, use zip\n\n    RELEASE_PACKAGE_FILE_NAME=\"${RELEASE_PACKAGE_NAME}.zip\"\n    RELEASE_PACKAGE_FILE_PATH=\"${RELEASE_FOLDER}/${RELEASE_PACKAGE_FILE_NAME}\"\n    zip \"${RELEASE_PACKAGE_FILE_PATH}\" \"${TARGETS[@]}\" \"config.toml\"\n\n    if [[ $? != \"0\" ]]; then\n        exit 1\n    fi\n\n    # Checksum\n    cd \"${RELEASE_FOLDER}\"\n    shasum -a 256 \"${RELEASE_PACKAGE_FILE_NAME}\" >\"${RELEASE_PACKAGE_FILE_NAME}.sha256\"\nelse\n    # For others, Linux, OS X, uses tar.xz\n\n    # For Darwin, .DS_Store and other related files should be ignored\n    if [[ \"$(uname -s)\" == \"Darwin\" ]]; then\n        export COPYFILE_DISABLE=1\n    fi\n\n    RELEASE_PACKAGE_FILE_NAME=\"${RELEASE_PACKAGE_NAME}.tar.xz\"\n    RELEASE_PACKAGE_FILE_PATH=\"${RELEASE_FOLDER}/${RELEASE_PACKAGE_FILE_NAME}\"\n    tar -cJf \"${RELEASE_PACKAGE_FILE_PATH}\" \"${TARGETS[@]}\" \"config.toml\"\n\n    if [[ $? != \"0\" ]]; then\n        exit 1\n    fi\n\n    # Checksum\n    cd \"${RELEASE_FOLDER}\"\n    shasum -a 256 \"${RELEASE_PACKAGE_FILE_NAME}\" >\"${RELEASE_PACKAGE_FILE_NAME}.sha256\"\nfi\n\necho \"Finished build release ${RELEASE_PACKAGE_FILE_PATH}\"\n"
  },
  {
    "path": "build/build-host-release.ps1",
    "content": "#!pwsh\n<#\n    OpenSSL is already installed on windows-latest virtual environment.\n    If you need OpenSSL, consider install it by:\n\n    choco install openssl\n#>\nparam(\n    [Parameter(HelpMessage = \"extra features\")]\n    [Alias('f')]\n    [string]$Features\n)\n\n$ErrorActionPreference = \"Stop\"\n\n$TargetTriple = (rustc -Vv | Select-String -Pattern \"host: (.*)\" | ForEach-Object { $_.Matches.Value }).split()[-1]\n\nWrite-Host \"Started building release for ${TargetTriple} ...\"\n\nif ([string]::IsNullOrEmpty($Features)) {\n    cargo build --package seam --release\n}\nelse {\n    cargo build --package seam --release --features \"${Features}\"\n}\n\nif (!$?) {\n    exit $LASTEXITCODE\n}\n\n$Version = (Select-String -Pattern '^version *= *\"([^\"]*)\"$' -Path \"${PSScriptRoot}\\..\\crates\\cli\\Cargo.toml\" | ForEach-Object { $_.Matches.Value }).split()[-1]\n$Version = $Version -replace '\"'\n\n$PackageReleasePath = \"${PSScriptRoot}\\release\"\n$PackageName = \"seam-v${Version}.${TargetTriple}.zip\"\n$PackagePath = \"${PackageReleasePath}\\${PackageName}\"\n\nWrite-Host $Version\nWrite-Host $PackageReleasePath\nWrite-Host $PackageName\nWrite-Host $PackagePath\n\nPush-Location \"${PSScriptRoot}\\..\\target\\release\"\nCopy-Item \"${PSScriptRoot}\\..\\config.toml\" -Destination \"${PSScriptRoot}\\..\\target\\release\"\n\n$ProgressPreference = \"SilentlyContinue\"\nNew-Item \"${PackageReleasePath}\" -ItemType Directory -ErrorAction SilentlyContinue\n$CompressParam = @{\n    LiteralPath     = \"seam.exe\", \"config.toml\"\n    DestinationPath = \"${PackagePath}\"\n}\nCompress-Archive @CompressParam\n\nWrite-Host \"Created release packet ${PackagePath}\"\n\n$PackageChecksumPath = \"${PackagePath}.sha256\"\n$PackageHash = (Get-FileHash -Path \"${PackagePath}\" -Algorithm SHA256).Hash\n\"${PackageHash}  ${PackageName}\" | Out-File -FilePath \"${PackageChecksumPath}\"\n\nWrite-Host \"Created release packet checksum ${PackageChecksumPath}\""
  },
  {
    "path": "build/build-release",
    "content": "#!/bin/bash\n# Path: build\\release\n\nCUR_DIR=$(cd $(dirname $0) && pwd)\nVERSION=$(grep -E '^version' ${CUR_DIR}/../crates/cli/Cargo.toml | awk '{print $3}' | sed 's/\"//g')\n\n## Disable macos ACL file\nif [[ \"$(uname -s)\" == \"Darwin\" ]]; then\n    export COPYFILE_DISABLE=1\nfi\n\ntargets=()\nfeatures=()\nuse_upx=false\n\nwhile getopts \"t:f:u\" opt; do\n    case $opt in\n    t)\n        targets+=($OPTARG)\n        ;;\n    f)\n        features+=($OPTARG)\n        ;;\n    u)\n        use_upx=true\n        ;;\n    ?)\n        echo \"Usage: $(basename $0) [-t <target-triple>] [-f features] [-u]\"\n        ;;\n    esac\ndone\n\nfeatures+=${EXTRA_FEATURES}\n\nif [[ \"${#targets[@]}\" == \"0\" ]]; then\n    echo \"Specifying compile target with -t <target-triple>\"\n    exit 1\nfi\n\nif [[ \"${use_upx}\" = true ]]; then\n    if [[ -z \"$upx\" ]] && command -v upx &>/dev/null; then\n        upx=\"upx -9\"\n    fi\n\n    if [[ \"x$upx\" == \"x\" ]]; then\n        echo \"Couldn't find upx in PATH, consider specifying it with variable \\$upx\"\n        exit 1\n    fi\nfi\n\nfunction build() {\n    cd \"$CUR_DIR/..\"\n\n    TARGET=$1\n\n    RELEASE_DIR=\"target/${TARGET}/release\"\n    TARGET_FEATURES=\"${features[@]}\"\n\n    if [[ \"${TARGET_FEATURES}\" != \"\" ]]; then\n        echo \"* Building ${TARGET} package ${VERSION} with features \\\"${TARGET_FEATURES}\\\" ...\"\n\n        cross build --package seam --target \"${TARGET}\" \\\n            --features \"${TARGET_FEATURES}\" \\\n            --release\n    else\n        echo \"* Building ${TARGET} package ${VERSION} ...\"\n\n        cross build --package seam --target \"${TARGET}\" \\\n            --release\n    fi\n\n    if [[ $? != \"0\" ]]; then\n        exit 1\n    fi\n\n    PKG_DIR=\"${CUR_DIR}/release\"\n    mkdir -p \"${PKG_DIR}\"\n    cp \"${CUR_DIR}/../config.toml\" \"${RELEASE_DIR}\"\n    if [[ \"$TARGET\" == *\"-linux-\"* ]]; then\n        PKG_NAME=\"seam-v${VERSION}.${TARGET}.tar.xz\"\n        PKG_PATH=\"${PKG_DIR}/${PKG_NAME}\"\n\n        cd ${RELEASE_DIR}\n\n        if [[ \"${use_upx}\" = true ]]; then\n            # Enable upx for MIPS.\n            $upx seam #>/dev/null\n        fi\n\n        echo \"* Packaging XZ in ${PKG_PATH} ...\"\n        tar -cJf ${PKG_PATH} \\\n            \"seam\" \\\n            \"config.toml\"\n\n        if [[ $? != \"0\" ]]; then\n            exit 1\n        fi\n\n        cd \"${PKG_DIR}\"\n        shasum -a 256 \"${PKG_NAME}\" >\"${PKG_NAME}.sha256\"\n    elif [[ \"$TARGET\" == *\"-windows-\"* ]]; then\n        PKG_NAME=\"seam-v${VERSION}.${TARGET}.zip\"\n        PKG_PATH=\"${PKG_DIR}/${PKG_NAME}\"\n\n        echo \"* Packaging ZIP in ${PKG_PATH} ...\"\n        cd ${RELEASE_DIR}\n        zip ${PKG_PATH} \\\n            \"seam.exe\" \\\n            \"config.toml\"\n\n        if [[ $? != \"0\" ]]; then\n            exit 1\n        fi\n\n        cd \"${PKG_DIR}\"\n        shasum -a 256 \"${PKG_NAME}\" >\"${PKG_NAME}.sha256\"\n    fi\n\n    echo \"* Done build package ${PKG_NAME}\"\n}\n\nfor target in \"${targets[@]}\"; do\n    build \"$target\"\ndone\n"
  },
  {
    "path": "build/build-release-zigbuild",
    "content": "#!/bin/bash\n\nCUR_DIR=$(cd $(dirname $0) && pwd)\nVERSION=$(grep -E '^version' ${CUR_DIR}/../crates/cli/Cargo.toml | awk '{print $3}' | sed 's/\"//g')\n\n## Disable macos ACL file\nif [[ \"$(uname -s)\" == \"Darwin\" ]]; then\n    export COPYFILE_DISABLE=1\nfi\n\ntargets=()\nfeatures=()\nuse_upx=false\n\nwhile getopts \"t:f:u\" opt; do\n    case $opt in\n    t)\n        targets+=($OPTARG)\n        ;;\n    f)\n        features+=($OPTARG)\n        ;;\n    u)\n        use_upx=true\n        ;;\n    ?)\n        echo \"Usage: $(basename $0) [-t <target-triple>] [-f features] [-u]\"\n        ;;\n    esac\ndone\n\nfeatures+=${EXTRA_FEATURES}\n\nif [[ \"${#targets[@]}\" == \"0\" ]]; then\n    echo \"Specifying compile target with -t <target-triple>\"\n    exit 1\nfi\n\nif [[ \"${use_upx}\" = true ]]; then\n    if [[ -z \"$upx\" ]] && command -v upx &>/dev/null; then\n        upx=\"upx -9\"\n    fi\n\n    if [[ \"x$upx\" == \"x\" ]]; then\n        echo \"Couldn't find upx in PATH, consider specifying it with variable \\$upx\"\n        exit 1\n    fi\nfi\n\nfunction build() {\n    cd \"$CUR_DIR/..\"\n\n    TARGET=$1\n\n    RELEASE_DIR=\"target/${TARGET}/release\"\n    TARGET_FEATURES=\"${features[@]}\"\n\n    cp \"config.toml\" \"${RELEASE_DIR}\"\n\n    if [[ \"${TARGET_FEATURES}\" != \"\" ]]; then\n        echo \"* Building ${TARGET} package ${VERSION} with features \\\"${TARGET_FEATURES}\\\" ...\"\n\n        cargo zigbuild --package seam --target \"${TARGET}\" \\\n        --features \"${TARGET_FEATURES}\" \\\n        --release\n    else\n        echo \"* Building ${TARGET} package ${VERSION} ...\"\n\n        cargo zigbuild --package seam --target \"${TARGET}\" \\\n        --release\n    fi\n\n    if [[ $? != \"0\" ]]; then\n        exit 1\n    fi\n\n    PKG_DIR=\"${CUR_DIR}/release\"\n    mkdir -p \"${PKG_DIR}\"\n\n    if [[ \"$TARGET\" == *\"-linux-\"* ]]; then\n        PKG_NAME=\"seam-v${VERSION}.${TARGET}.tar.xz\"\n        PKG_PATH=\"${PKG_DIR}/${PKG_NAME}\"\n\n        cd ${RELEASE_DIR}\n\n        if [[ \"${use_upx}\" = true ]]; then\n            # Enable upx for MIPS.\n            $upx sslocal ssserver ssurl ssmanager ssservice #>/dev/null\n        fi\n\n        echo \"* Packaging XZ in ${PKG_PATH} ...\"\n        tar -cJf ${PKG_PATH} \\\n        \"seam\" \\\n        \"config.toml\"\n\n        if [[ $? != \"0\" ]]; then\n            exit 1\n        fi\n\n        cd \"${PKG_DIR}\"\n        shasum -a 256 \"${PKG_NAME}\" >\"${PKG_NAME}.sha256\"\n    elif [[ \"$TARGET\" == *\"-windows-\"* ]]; then\n        PKG_NAME=\"seam-v${VERSION}.${TARGET}.zip\"\n        PKG_PATH=\"${PKG_DIR}/${PKG_NAME}\"\n\n        echo \"* Packaging ZIP in ${PKG_PATH} ...\"\n        cd ${RELEASE_DIR}\n        zip ${PKG_PATH} \\\n        \"seam.exe\" \\\n        \"config.toml\"\n\n        if [[ $? != \"0\" ]]; then\n            exit 1\n        fi\n\n        cd \"${PKG_DIR}\"\n        shasum -a 256 \"${PKG_NAME}\" >\"${PKG_NAME}.sha256\"\n    fi\n\n    echo \"* Done build package ${PKG_NAME}\"\n}\n\nfor target in \"${targets[@]}\"; do\n    build \"$target\"\ndone\n"
  },
  {
    "path": "config.toml",
    "content": "# 播放器路径或命令\n# 请自行安装播放器, 请确认它可以通过命令行+链接打开视频文件\n[play]\n# potplayer 样例\n# bin = \"C:\\\\Program Files (x86)\\\\Pure Codec\\\\x64\\\\PotPlayerMini64.exe\"\nbin = \"mpv\"\n# 播放器参数\nargs = []\n\n# headers 支持所有合法 http 请求头字段\n# global 为全局请求头, 会被各平台请求头覆盖\n# 请注意 不要覆盖虎牙的 user-agent, 否则会导致获取失败\n[headers.global]\n# 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\"\n\n# 抖音 cookie 必须\n[headers.douyin]\ncookie = \"xxxx\"\n\n# 快手 cookie 必须\n[headers.ks]\ncookie = \"xxxx\"\n\n# 斗鱼设置登录账户 cookie 情况下可以获取到备用线路高清源\n[headers.douyu]\ncookie = \"xxxx\"\n\n\n# [rid]: 房间号\n# [title]: 标题\n# [time]: 时间戳\n# [date]: 日期\n[file_name]\n# 录制文件标题\nvideo = \"[rid]-[title]-[date]-[time]\"\n# danmu文件标题\ndanmu = \"[rid]-[title]-[date]-[time]\"\n"
  },
  {
    "path": "crates/cli/CHANGELOG.md",
    "content": "# Changelog\n\n## [0.1.39]\n\n修复 bili 无法获取原画 [#277](https://github.com/Borber/seam/issues/277)\n\n## [0.1.38]\n\n修复 斗鱼部分房间获取不到 [#255](https://github.com/Borber/seam/issues/255)\n\n## [0.1.37]\n\n清除调试代码\n\n## [0.1.36]\n\n**请重新获取最新的快手 cookie**\n\n### 修复\n\n-   修复 kuaishou 直播源获取\n\n## [0.1.35]\n\n**请重新获取最新的抖音 cookie**\n\n### 修复\n\n-   修复 douyin 直播源获取\n\n### 更新\n\n-   douyu 支持 直播间封面, 主播名,主播头像获取\n\n## [0.1.34]\n\n### 更新\n\n-   修复抖音, 获取最高清晰度\n\n## [0.1.33]\n\n### 更新\n\n-   调整 cli 默认显示，仅显示直播源\n-   新增 all 参数\n\n## [0.1.32]\n\n### 更新\n\n-   目前所有支持平台,房间名获取均可用\n\n## [0.1.31]\n\n### 修复\n\n-   斗鱼获取最清晰线路\n\n### 更新\n\n-   斗鱼在设置 cookie 的情况下尝试获取 备用线路\n\n## [0.1.30]\n\n### 更新\n\n-   调整子命令\n-   拆分未开播与解析错误\n\n## [0.1.29]\n\n### 更新\n\n-   捕获不支持的平台\n-   直播名获取\n    -   千帆直播\n    -   映客直播\n-   更新依赖\n-   引入 cookie , 但目前还不能设置\n-   调整报错信息输出\n\n### 更改\n\n-   动态派发 Client\n-   调整代码结构\n-   js 运行时重新引入 douyu 不用再额外下载 jin 了\n\n## [0.1.28]\n\n### Changed\n\n-   拆分 core danmu status 模块\n-   调整 clap 结构\n-   添加 过程宏模块\n-   优化代码结构\n-   暂时移除弹幕文件输出功能\n-   暂时移除录播功能, 待后续重构完整版功能\n-   更新依赖\n\n## [0.1.27]\n\n### Fixed\n\n-   修复抖音提前获取直播标题名\n\n## [0.1.26]\n\n### Fixed\n\n-   修复快手直播源获取\n\n## [0.1.25]\n\n### Fixed\n\n-   修复斗鱼直播源获取\n\n### Changed\n\n-   js runtime 拆分, 优化体积\n\n## [0.1.24]\n\n### Fixed\n\n-   修复抖音错误输出\n\n## [0.1.23]\n\n### Fixed\n\n-   修复斗鱼 CDN\n\n## [0.1.22]\n\n### Fixed\n\n-   修复虎牙 gzip 解压\n\n## [0.1.21]\n\n### Fixed\n\n-   修复抖音 m3u 获取 full_hd 资源\n\n## [0.1.20]\n\n### Fixed\n\n-   修复抖音直播源格式\n\n## [0.1.19]\n\n### Fixed\n\n-   修复抖音直播源获取\n\n## [0.1.18]\n\n### Added\n\n-   映客直播源获取\n-   抖音加入即时 cookie, 避免 cookie 检测\n\n## [0.1.17]\n\n### Added\n\n-   添加斗鱼直播间标题获取\n\n### Changed\n\n-   更改 js 运行时为 boa_engine 去除在线 js 运行接口\n-   添加编译参数, 减小二进制体积\n-   斗鱼使用移动端接口获取直播源\n\n## [0.1.16]\n\n### Added\n\n-   (尝试支持) 视频录制, 但目前需要自行放置 ffmpeg 文件到 `seam` 可执行文件所在目录下\n-   弹幕录制 CSV 支持\n-   开始支持设置\n    -   弹幕, 视频 目前支持 rid title time 字段替换\n\n### Changed\n\n-   改进 CI 脚本, 提供更多平台/版本支持\n\n### Fixed\n\n-   修复抖音直播源获取\n-   B 站弹幕解压情况下的顺序问题\n\n## [0.1.15]\n\n### Added\n\n-   虎牙直播间标题字段支持\n\n### Fixed\n\n-   删除虎牙多余信息输出\n\n## [0.1.14]\n\n### Added\n\n-   添加 bili 直播间 标题获取, 标题字段初步支持\n-   支持 抖音, cc 直播标题获取\n\n### Fixd\n\n-   修复抖音直播源获取\n\n### Changed\n\n-   抖音去除画质标签\n\n### Changed\n\n-   弹幕功能调整\n\n## [0.1.13]\n\n### Added\n\n-   添加 kk 直播源获取\n-   添加 千帆直播源获取\n-   bili 直播弹幕获取-预览版\n\n## [0.1.12]\n\n### Added\n\n-   添加 now 直播源获取\n\n### Changed\n\n-   Format 添加 rtmp 格式\n-   删除斗鱼, 虎牙多余打印信息\n\n### Fixed\n\n-   修复斗鱼, 虎牙平台直播源获取\n\n## [0.1.11]\n\n### Added\n\n-   添加 winktv 直播源获取\n\n### Changed\n\n-   修改 Node 结构\n-   规范化 format 判定, 规范化输出方法\n-   简化代码\n\n## [0.1.10]\n\n### Added\n\n-   添加 flex 直播源获取\n\n## [0.1.9]\n\n### Added\n\n-   添加 pandalive 直播源获取\n\n## [0.1.8]\n\n### Added\n\n-   添加 afreeca 直播源获取\n\n### Changed\n\n-   引入宏简化代码 感谢 [@eweca-d](https://github.com/eweca-d)\n-   删除部分注释及说明信息\n\n### Fixed\n\n-   model 拼写错误修正\n\n## [0.1.7]\n\n### Added\n\n-   支持网易 CC 直播源获取\n\n### Changed\n\n-   使用 `super` 替代绝对位置\n\n## [0.1.6]\n\n### Added\n\n-   支持快手直播源获取\n\n## [0.1.5] - 2023-01-11\n\n### Fixed\n\n-   修复斗鱼直播源获取\n\n## [0.1.4] - 2023-01-11\n\n### Added\n\n-   添加全平台自动编译发布工作流\n-   支持花椒直播源获取\n\n### Changed\n\n-   后续 tag 将采用 vX.X.X 的格式\n\n### Fixed\n\n-   修改代码格式\n\n## [0.1.3] - 2023-01-9\n\n### Added\n\n-   原创虎牙直播源获取\n\n### Changed\n\n-   同时输出阿里 腾讯 华为 CDN 和 flv hls 两种直播源\n\n### Fixed\n\n-   从预览接口转换为标准接口, 修复部分直播间无法获取链接而显示未开播的问题\n"
  },
  {
    "path": "crates/cli/Cargo.toml",
    "content": "[package]\nname = \"seam\"\nauthors = [\"Borber\"]\nversion = \"0.1.39\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\nseam_core = { path = \"../core\" }\nseam_danmu = { path = \"../danmu\" }\nseam_status = { path = \"../status\" }\n\nanyhow = \"1.0\"\ntokio = { version = \"1\", features = [\"full\"] }\nclap = { version = \"4\", features = [\"derive\"] }\nonce_cell = \"1\"\nserde = { version = \"1\", features = [\"derive\"] }\nbasic-toml = \"0.1\"\nparking_lot = { version = \"0.12\", features = [\"nightly\"] }\n"
  },
  {
    "path": "crates/cli/README.md",
    "content": "```\n _______ _______ _______ _______\n|     __|    ___|   _   |   |   |\n|__     |    ___|       |       |\n|_______|_______|___|___|__|_|__|\n```\n\n```bash\n❯ .\\seam.exe -l douyu -i 88080\nhttp://url1\n\nhttp://url2\n```\n\n-   `-l` 代表平台, 目前支持的平台见下表\n-   `-i` 代表直播间号, 也就是直播间链接中的 `rid`\n-   `-a` 显示全部信息, 包括直播间标题, 主播名, 封面图等\n\n> 因为数据具有时效性, 所以具体链接使用 `url` 进行替换\n\n**注意事项: 目前抖音和快手因为 cookie 模块的加入进行了较大修改, 所以目前不支持获取这两个平台的直播源**\n"
  },
  {
    "path": "crates/cli/src/common.rs",
    "content": "use std::{collections::HashMap, sync::Arc};\n\nuse once_cell::sync::Lazy;\nuse seam_core::live::{self, Live};\n\npub static GLOBAL_CLIENT: Lazy<HashMap<String, Arc<dyn Live>>> = Lazy::new(live::all);\n\n#[cfg(test)]\nmod tests {\n    #[tokio::test]\n    async fn test() {\n        println!(\n            \"{:#?}\",\n            super::GLOBAL_CLIENT\n                .get(\"bili\")\n                .unwrap()\n                .get(\"6\", None)\n                .await\n                .unwrap()\n        );\n    }\n}\n"
  },
  {
    "path": "crates/cli/src/config.rs",
    "content": "use std::collections::HashMap;\n\nuse once_cell::sync::Lazy;\nuse serde::Deserialize;\n\nuse crate::util::bin_dir;\n\n#[derive(Deserialize, Debug)]\npub struct ConfigOption {\n    pub file_name: Option<FileNameOption>,\n    pub headers: Option<HashMap<String, HashMap<String, String>>>,\n}\n\n#[derive(Debug)]\npub struct Config {\n    pub file_name: FileNameConfig,\n    pub headers: HashMap<String, HashMap<String, String>>,\n}\n\n#[derive(Debug)]\npub struct FileNameConfig {\n    pub video: String,\n    pub danmu: String,\n}\n\n#[derive(Deserialize, Debug)]\npub struct FileNameOption {\n    pub video: Option<String>,\n    pub danmu: Option<String>,\n}\n\n/// 配置文件\npub static CONFIG: Lazy<Config> = Lazy::new(|| {\n    let config =\n        std::fs::read_to_string(format!(\"{}config.toml\", bin_dir(),)).unwrap_or(\"\".to_owned());\n    let config_file = basic_toml::from_str::<ConfigOption>(&config).unwrap();\n    Config {\n        file_name: {\n            let FileNameOption { video, danmu } = config_file.file_name.unwrap_or(FileNameOption {\n                video: None,\n                danmu: None,\n            });\n            let video = video.unwrap_or(\"[rid]-[title]-[date]-[time]\".to_string());\n            let danmu = danmu.unwrap_or(\"[rid]-[title]-[date]-[time]\".to_string());\n            FileNameConfig { video, danmu }\n        },\n        headers: config_file.headers.unwrap_or_default(),\n    }\n});\n\npub fn headers(live: &str) -> HashMap<String, String> {\n    let global = CONFIG\n        .headers\n        .get(\"global\")\n        .unwrap_or(&HashMap::new())\n        .clone();\n    let mut live = CONFIG.headers.get(live).unwrap_or(&HashMap::new()).clone();\n    live.extend(global);\n    live\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_config() {\n        // 初始化 CONFIG\n        let _ = CONFIG.file_name.video;\n        println!(\"{:#?}\", CONFIG);\n    }\n}\n"
  },
  {
    "path": "crates/cli/src/main.rs",
    "content": "mod common;\nmod config;\nmod util;\n\nuse crate::common::GLOBAL_CLIENT;\nuse anyhow::{anyhow, Result};\nuse clap::Parser;\nuse seam_core::error::SeamError;\n\n/// 获取直播源\n#[derive(Parser)]\n#[command(name = \"seam\")]\n#[command(about =\"\n________ _______ _______ _______\n|     __|    ___|   _   |   |   |\n|__     |    ___|       |       |\n|_______|_______|___|___|__|_|__|\", long_about = None)]\n#[command(subcommand_negates_reqs = true)]\n#[command(arg_required_else_help = true)]\nstruct Cli {\n    /// 平台名称\n    #[arg(short = 'l', required = true)]\n    live: Option<String>,\n    /// 直播间号\n    #[arg(short = 'i', required = true)]\n    rid: Option<String>,\n    /// 显示详细信息\n    #[arg(short = 'a')]\n    all: bool,\n    /// 直接录播功能\n    #[arg(short = 'r')]\n    record: bool,\n    /// 自动监控录播功能\n    #[arg(short = 'R')]\n    auto_record: bool,\n    /// 输出到终端的弹幕功能\n    #[arg(short = 'd')]\n    danmu: bool,\n    /// 根据参数指定的文件地址输出弹幕\n    #[arg(short = 'D')]\n    config_danmu: bool,\n\n    #[command(subcommand)]\n    command: Option<Commands>,\n}\n\n#[derive(Parser, Debug)]\nenum Commands {\n    /// 显示所有支持的平台\n    List,\n}\n\n// 获取直播源的实现\npub async fn cli() -> Result<()> {\n    let Cli {\n        live,\n        rid,\n        all,\n        record,\n        auto_record,\n        danmu,\n        config_danmu,\n        command,\n    } = Cli::parse();\n\n    // 获取参数\n    let live = live.ok_or(anyhow!(\"请指定平台名称\"))?;\n    let rid = rid.ok_or(anyhow!(\"请指定直播间号\"))?;\n\n    // 处理子命令\n    if let Some(command) = command {\n        return match command {\n            Commands::List => {\n                println!(\n                    \"可用平台：{}\",\n                    GLOBAL_CLIENT.keys().cloned().collect::<Vec<_>>().join(\", \")\n                );\n                Ok(())\n            }\n        };\n    }\n\n    let node = match GLOBAL_CLIENT.get(&live) {\n        Some(client) => client.get(&rid, Some(config::headers(&live))).await,\n        None => {\n            return Err(anyhow!(\n                \"请检查 {} 是否为可用平台, 或前往 https://github.com/Borber/seam/issues 申请支持\",\n                live\n            ))\n        }\n    };\n\n    // 无参数情况下，直接输出直播源信息\n    if !(danmu || config_danmu || record || auto_record) {\n        match node {\n            Ok(node) => {\n                if all {\n                    println!(\"{}\", node.json())\n                } else {\n                    println!(\n                        \"{}\",\n                        node.urls\n                            .into_iter()\n                            .map(|item| item.url)\n                            .collect::<Vec<_>>()\n                            .join(\"\\n\\n\")\n                    )\n                }\n            }\n            Err(SeamError::None) => println!(\"未开播\"),\n            Err(e) => println!(\"{}\", e),\n        }\n        return Ok(());\n    }\n\n    // 收集不同参数功能的异步线程 handler\n    let mut thread_handlers = vec![];\n\n    // 处理参数-d，直接输出弹幕。\n    // 由于该函数为cli层，所以出错可以直接panic。\n    if danmu {\n        let h = tokio::spawn(async move {\n            // args.command\n            //     .danmu(vec![&Terminal::try_new(None).unwrap()])\n            //     .await\n            //     .unwrap();\n            println!(\"弹幕功能正在重构中，敬请期待\") // TODO\n        });\n        thread_handlers.push(h);\n    }\n    tokio::select! {\n        _ = async {\n            for h in thread_handlers {\n                h.await.unwrap();\n            }\n        } => {}\n    }\n\n    Ok(())\n}\n\n#[tokio::main]\nasync fn main() -> Result<()> {\n    cli().await?;\n    Ok(())\n}\n"
  },
  {
    "path": "crates/cli/src/util.rs",
    "content": "#[cfg(windows)]\nconst SEPARATOR: &str = \"\\\\\";\n\n#[cfg(not(windows))]\nconst SEPARATOR: &str = \"/\";\n\npub fn bin_dir() -> String {\n    let p = std::env::current_exe()\n        .unwrap()\n        .parent()\n        .unwrap()\n        .to_str()\n        .unwrap()\n        .to_owned();\n    format!(\"{p}{SEPARATOR}\")\n}\n"
  },
  {
    "path": "crates/core/Cargo.toml",
    "content": "[package]\nname = \"seam_core\"\nversion = \"0.1.20\"\nedition = \"2021\"\n\n[dependencies]\nmacros = { package = \"seam_marcos\", path = \"../marcos\" }\n\nthiserror = \"1\"\nonce_cell = \"1\"\nasync-trait = \"0.1\"\n\ntokio = { version = \"1\", features = [\"full\"] }\n\n\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\nregex = \"1\"\nurlencoding = \"2\"\nchrono = \"0.4\"\nmd-5 = \"0.10\"\nhex = \"0.4\"\nrand = \"0.8\"\nbase64 = \"0.21\"\n\n\nreqwest = { version = \"0.11\", features = [\"json\", \"gzip\", \"deflate\"] }\n\nboa_engine = { version = \"0.17\", features = [\"annex-b\"] }\n\n\n[target.'cfg(unix)'.dependencies]\nopenssl = { version = '0.10', features = [\"vendored\"] }\n"
  },
  {
    "path": "crates/core/src/common.rs",
    "content": "use once_cell::sync::Lazy;\nuse reqwest::Client;\n\n// TODO 这玩意也应该额外传入\npub 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\";\n\npub static CLIENT: Lazy<Client> = Lazy::new(Client::new);\n"
  },
  {
    "path": "crates/core/src/error.rs",
    "content": "use thiserror::Error;\n\npub type Result<T> = std::result::Result<T, SeamError>;\n\n// 需要添加\n#[derive(Error, Debug)]\npub enum SeamError {\n    #[error(\"Request error: {0}\")]\n    Request(#[from] reqwest::Error),\n    #[error(\"Type error: {0}\")]\n    Type(String),\n    #[error(\"Serde json error: {0}\")]\n    Json(#[from] serde_json::Error),\n    #[error(\"Regex error: {0}\")]\n    Regex(#[from] regex::Error),\n    #[error(\"Urlencoding error: {0}\")]\n    Decode(#[from] std::string::FromUtf8Error),\n    #[error(\"InvalidHeaderValue error: {0}\")]\n    InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),\n    #[error(\"ParseInt error: {0}\")]\n    ParseInt(#[from] std::num::ParseIntError),\n    #[error(\"Base64 error: {0}\")]\n    Base64Error(#[from] base64::DecodeError),\n    #[error(\"SystemTime error: {0}\")]\n    SystemTimeError(#[from] std::time::SystemTimeError),\n    #[error(\"{0}\")]\n    Plugin(String),\n    #[error(\"Need fix {0}\")]\n    NeedFix(&'static str),\n    #[error(\"Not on\")]\n    None,\n    #[error(\"Error msg: {0}\")]\n    Unknown(String),\n}\n"
  },
  {
    "path": "crates/core/src/lib.rs",
    "content": "pub mod common;\npub mod error;\npub mod live;\npub mod util;\n"
  },
  {
    "path": "crates/core/src/live/afreeca.rs",
    "content": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse regex::Regex;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header, parse_url},\n};\n\nuse super::{Live, Node};\n\nconst URL: &str = \"https://play.afreecatv.com/\";\nconst PLAY_URL: &str = \"https://live.afreecatv.com/afreeca/player_live_api.php?bjid=\";\nconst CDN: &str = \"https://live-global-cdn-v02.afreecatv.com/live-stmc-32/auth_playlist.m3u8?aid=\";\n\n/// afreecatv直播\n///\n/// https://www.afreecatv.com/\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let text = CLIENT\n            .get(format!(\"{URL}{rid}\"))\n            .headers(hash2header(headers))\n            .send()\n            .await?\n            .text()\n            .await?;\n        let re = Regex::new(r#\"var nBroadNo = ([0-9]{9})\"#)?;\n        let bno = match re.captures(&text) {\n            Some(rap) => rap.get(1).ok_or(SeamError::NeedFix(\"bno regex\"))?.as_str(),\n            None => {\n                return Err(SeamError::None);\n            }\n        };\n        let re = Regex::new(r#\"var szBroadTitle   = \"([\\s\\S]*?)\";\"#)?;\n        let title = match re.captures(&text) {\n            Some(rap) => match rap.get(1) {\n                Some(rap) => rap.as_str(),\n                None => \"获取失败\",\n            },\n            None => \"获取失败\",\n        };\n        let mut form = HashMap::new();\n        form.insert(\"bid\", rid);\n        form.insert(\"bno\", bno);\n        form.insert(\"mode\", \"landing\");\n        form.insert(\"player_type\", \"html5\");\n        form.insert(\"stream_type\", \"common\");\n        form.insert(\"from_api\", \"0\");\n        form.insert(\"type\", \"aid\");\n        form.insert(\"quality\", \"original\");\n        let json: serde_json::Value = CLIENT\n            .post(format!(\"{PLAY_URL}{rid}\"))\n            .form(&form)\n            .send()\n            .await?\n            .json()\n            .await?;\n        let urls = vec![parse_url(format!(\n            \"{CDN}{}\",\n            json[\"CHANNEL\"][\"AID\"]\n                .as_str()\n                .ok_or(SeamError::NeedFix(\"channel aid\"))?\n        ))];\n        Ok(Node {\n            rid: rid.to_owned(),\n            title: title.to_owned(),\n            cover: \"\".to_owned(),\n            anchor: \"\".to_owned(),\n            head: \"\".to_owned(),\n            urls,\n        })\n    }\n}\n\n#[cfg(test)]\nmacros::gen_test!(suji0624);\n"
  },
  {
    "path": "crates/core/src/live/bili.rs",
    "content": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse reqwest::header::HeaderValue;\nuse serde_json::Value;\n\nuse super::{Live, Node};\nuse crate::error::{Result, SeamError};\nuse crate::util::hash2header;\nuse crate::{\n    common::{CLIENT, USER_AGENT},\n    util::parse_url,\n};\n\nconst INIT_URL: &str = \"https://api.live.bilibili.com/room/v1/Room/room_init\";\nconst INFO_URL: &str =\n    \"https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom?room_id=\";\nconst PLAY_URL: &str = \"https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo\";\n\n/// bilibili直播\n///\n/// https://live.bilibili.com/\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let resp = CLIENT\n            .get(INIT_URL)\n            .query(&[(\"id\", rid)])\n            .headers(hash2header(headers.clone()))\n            .send()\n            .await?\n            .json::<Value>()\n            .await?;\n\n        // 获取真实房间号\n        let rid = match resp[\"data\"][\"live_status\"].as_i64() {\n            Some(1) => resp[\"data\"][\"room_id\"]\n                .as_u64()\n                .ok_or(SeamError::NeedFix(\"room_id\"))?\n                .to_string(),\n            _ => return Err(SeamError::None),\n        };\n\n        let mut stream_info = get_bili_stream_info(&rid, 10000, headers.clone()).await?;\n\n        let max = stream_info\n            .as_array()\n            .ok_or(SeamError::NeedFix(\"stream_info\"))?\n            .iter()\n            .map(|data| {\n                data[\"format\"][0][\"codec\"][0][\"accept_qn\"]\n                    .as_array()\n                    .unwrap()\n                    .iter()\n                    .map(|item| item.as_u64().unwrap())\n                    .max()\n                    .unwrap()\n            })\n            .max()\n            .ok_or(SeamError::NeedFix(\"max\"))?;\n\n        if max != 10000 {\n            stream_info = get_bili_stream_info(&rid, max, headers.clone()).await?;\n        }\n\n        let mut urls = vec![];\n        for obj in stream_info.as_array().ok_or(SeamError::NeedFix(\"obj\"))? {\n            for format in obj[\"format\"]\n                .as_array()\n                .ok_or(SeamError::NeedFix(\"format\"))?\n            {\n                for codec in format[\"codec\"]\n                    .as_array()\n                    .ok_or(SeamError::NeedFix(\"codec\"))?\n                {\n                    let base_url = codec[\"base_url\"]\n                        .as_str()\n                        .ok_or(SeamError::NeedFix(\"base_url\"))?;\n                    for url_info in codec[\"url_info\"]\n                        .as_array()\n                        .ok_or(SeamError::NeedFix(\"url_info\"))?\n                    {\n                        let host = url_info[\"host\"]\n                            .as_str()\n                            .ok_or(SeamError::NeedFix(\"host\"))?;\n                        let extra = url_info[\"extra\"]\n                            .as_str()\n                            .ok_or(SeamError::NeedFix(\"extra\"))?;\n                        urls.push(parse_url(format!(\"{host}{base_url}{extra}\")));\n                    }\n                }\n            }\n        }\n\n        let json = CLIENT\n            .get(format!(\"{}{}\", INFO_URL, rid))\n            .send()\n            .await?\n            .json::<Value>()\n            .await?;\n\n        let title = json[\"data\"][\"room_info\"][\"title\"]\n            .as_str()\n            .unwrap_or(\"获取失败\")\n            .to_owned();\n\n        let cover = json[\"data\"][\"room_info\"][\"cover\"]\n            .as_str()\n            .unwrap_or(\"\")\n            .to_owned();\n\n        let anchor = json[\"data\"][\"anchor_info\"][\"base_info\"][\"uname\"]\n            .as_str()\n            .unwrap_or(\"\")\n            .to_owned();\n\n        let head = json[\"data\"][\"anchor_info\"][\"base_info\"][\"face\"]\n            .as_str()\n            .unwrap_or(\"\")\n            .to_owned();\n\n        Ok(Node {\n            rid,\n            title,\n            cover,\n            anchor,\n            head,\n            urls,\n        })\n    }\n}\n\n/// 通过真实房间号获取直播源信息\n/// 不带 cookie 只给 480P, 带 cookie 才给原画画质\npub async fn get_bili_stream_info(\n    rid: &str,\n    qn: u64,\n    headers: Option<HashMap<String, String>>,\n) -> Result<serde_json::Value> {\n    let mut headers = hash2header(headers);\n    headers.append(\"User-Agent\", HeaderValue::from_static(USER_AGENT));\n    Ok(CLIENT\n        .get(PLAY_URL)\n        .headers(headers)\n        .query(&[\n            (\"room_id\", rid),\n            (\"protocol\", \"0,1\"),\n            (\"format\", \"0,1,2\"),\n            (\"codec\", \"0,1\"),\n            (\"qn\", qn.to_string().as_str()),\n            (\"platform\", \"h5\"),\n            (\"ptype\", \"8\"),\n        ])\n        .send()\n        .await?\n        .json::<serde_json::Value>()\n        .await?[\"data\"][\"playurl_info\"][\"playurl\"][\"stream\"]\n        .to_owned())\n}\n\n#[cfg(test)]\nmacros::gen_test!(6);\n"
  },
  {
    "path": "crates/core/src/live/cc.rs",
    "content": "use std::collections::HashMap;\n\nuse super::{Live, Node};\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header, parse_url},\n};\nuse async_trait::async_trait;\nuse regex::Regex;\n\nconst URL: &str = \"https://cc.163.com/\";\n\n/// 网易CC直播\n///\n/// https://cc.163.com/\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let text = CLIENT\n            .get(format!(\"{URL}{rid}\"))\n            .headers(hash2header(headers))\n            .send()\n            .await?\n            .text()\n            .await?;\n        let re = Regex::new(\n            r#\"<script id=\"__NEXT_DATA__\" type=\"application/json\" crossorigin=\"anonymous\">([\\s\\S]*?)</script>\"#,\n        )?;\n        let json = match re.captures(&text) {\n            Some(rap) => rap.get(1).ok_or(SeamError::NeedFix(\"json re\"))?.as_str(),\n            None => {\n                return Err(SeamError::None);\n            }\n        };\n        let json: serde_json::Value = serde_json::from_str(json)?;\n        let resolution = match &json[\"props\"][\"pageProps\"][\"roomInfoInitData\"][\"live\"][\"quickplay\"]\n            [\"resolution\"]\n        {\n            serde_json::Value::Null => return Err(SeamError::NeedFix(\"resolution\")),\n            v => v,\n        };\n        let title = json[\"props\"][\"pageProps\"][\"roomInfoInitData\"][\"live\"][\"title\"]\n            .as_str()\n            .unwrap_or(\"获取失败\")\n            .to_owned();\n        let mut urls = vec![];\n        for vbr in [\"blueray\", \"ultra\", \"high\", \"standard\"] {\n            if resolution[vbr] != serde_json::Value::Null {\n                if resolution[vbr][\"cdn\"][\"ali\"] != serde_json::Value::Null {\n                    urls.push(parse_url(\n                        resolution[vbr][\"cdn\"][\"ali\"]\n                            .as_str()\n                            .ok_or(SeamError::NeedFix(\"cdn ali\"))?\n                            .to_string(),\n                    ));\n                }\n                if resolution[vbr][\"cdn\"][\"ks\"] != serde_json::Value::Null {\n                    urls.push(parse_url(\n                        resolution[vbr][\"cdn\"][\"ks\"]\n                            .as_str()\n                            .ok_or(SeamError::NeedFix(\"cdn ks\"))?\n                            .to_string(),\n                    ));\n                }\n                break;\n            }\n        }\n        Ok(Node {\n            rid: rid.to_owned(),\n            title,\n            cover: \"\".to_owned(),\n            anchor: \"\".to_owned(),\n            head: \"\".to_owned(),\n            urls,\n        })\n    }\n}\n\n#[cfg(test)]\nmacros::gen_test!(361433);\n"
  },
  {
    "path": "crates/core/src/live/douyin.rs",
    "content": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse reqwest::header::HeaderValue;\nuse serde_json::Value;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header, parse_url},\n};\n\nuse super::{Live, Node};\n\nconst URL: &str = \"https://live.douyin.com/\";\nconst ENTER_URL: &str =\n    \"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=\";\n\n/// 抖音直播\n///\n/// https://live.douyin.com/\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    /// `headers`: cookie 必须， 但不需要是登录状态\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let mut headers = hash2header(headers);\n        headers.append(\"referer\", HeaderValue::from_static(URL));\n        // 通过网页内容获取直播地址\n        let json = CLIENT\n            .get(format!(\"{ENTER_URL}{rid}\"))\n            .headers(headers)\n            .send()\n            .await?\n            .json::<Value>()\n            .await?;\n\n        let data = &json[\"data\"][\"data\"][0];\n\n        let status = &data[\"status\"];\n\n        if status.as_i64().unwrap_or(0) != 2 {\n            return Err(SeamError::None);\n        }\n\n        let title = data[\"title\"].as_str().unwrap_or(\"获取失败\").to_string();\n        let cover = data[\"cover\"][\"url_list\"][0]\n            .as_str()\n            .unwrap_or(\"\")\n            .to_string();\n        let anchor = data[\"owner\"][\"nickname\"].as_str().unwrap_or(\"\").to_string();\n        let head = data[\"owner\"][\"avatar_thumb\"][\"url_list\"][0]\n            .as_str()\n            .unwrap_or(\"\")\n            .to_string();\n\n        let stream_data = data[\"stream_url\"][\"live_core_sdk_data\"][\"pull_data\"][\"stream_data\"]\n            .as_str()\n            .ok_or(SeamError::NeedFix(\"stream_data\"))?;\n\n        let new_json = serde_json::from_str::<Value>(stream_data)?;\n        // 返回最高清晰度的直播地址 flv 和 hls\n        let urls = vec![\n            parse_url(\n                new_json[\"data\"][\"origin\"][\"main\"][\"flv\"]\n                    .as_str()\n                    .ok_or(SeamError::NeedFix(\"flv_pull_url\"))?\n                    .to_owned(),\n            ),\n            parse_url(\n                new_json[\"data\"][\"origin\"][\"main\"][\"hls\"]\n                    .as_str()\n                    .ok_or(SeamError::NeedFix(\"hls_pull_url_map\"))?\n                    .to_owned(),\n            ),\n        ];\n        Ok(Node {\n            rid: rid.to_string(),\n            title,\n            cover,\n            anchor,\n            head,\n            urls,\n        })\n    }\n}\n\n#[cfg(test)]\nmacros::gen_test!(7274955926023686967);\n"
  },
  {
    "path": "crates/core/src/live/douyu.rs",
    "content": "use std::collections::{HashMap, HashSet};\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{eval, hash2header, parse_url},\n};\n\nuse async_trait::async_trait;\nuse chrono::prelude::*;\nuse md5::{Digest, Md5};\nuse regex::Regex;\nuse reqwest::header::HeaderMap;\nuse serde_json::Value;\n\nuse super::{Live, Node};\n\nconst URL: &str = \"https://www.douyu.com/\";\nconst PLAY_URL: &str = \"https://www.douyu.com/lapi/live/getH5Play/\";\nconst BETARD_URL: &str = \"https://www.douyu.com/betard/\";\nconst DID: &str = \"10000000000000000000000000001501\";\n\n/// 斗鱼直播\n///\n/// https://www.douyu.com/\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    /// `headers`: cookie 不必须, 登录状态下可以获取备用路线高清源\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let headers = hash2header(headers);\n        // 构造时间戳\n        let binding = Local::now().timestamp_millis().to_string();\n        let dt = &binding.as_str()[0..10];\n\n        // 获取指定直播间的首页源代码, 认证的sign和直播间是绑定的\n        let text = CLIENT\n            .get(format!(\"{URL}{rid}\"))\n            .headers(headers.clone())\n            .send()\n            .await?\n            .text()\n            .await?;\n\n        // 获取直播间的真实ID\n        let re = Regex::new(r\"\\$ROOM\\.room_id\\s?=\\s?(\\d+);\")?;\n        let rid = match re.captures(&text) {\n            Some(cap) => cap\n                .get(1)\n                .ok_or(SeamError::NeedFix(\"room_id capture\"))?\n                .as_str(),\n            None => return Err(SeamError::NeedFix(\"room_id\")),\n        };\n\n        // 正则匹配固定位置的js代码\n        let re = Regex::new(r#\"<script type=\"text/javascript\">([\\s\\S]*?)</script>\"#)?;\n        let mut func = String::new();\n        let mut v = \"\";\n        for cap in re.captures_iter(&text) {\n            let script = cap.get(1).ok_or(SeamError::NeedFix(\"script\"))?.as_str();\n            let re2 = Regex::new(\"\\\"([0-9]{12})\\\"\")?;\n            match re2.captures(script) {\n                Some(t_cap) => {\n                    v = t_cap\n                        .get(1)\n                        .ok_or(SeamError::NeedFix(\"script captures\"))?\n                        .as_str();\n                    func = script.to_owned();\n                }\n                None => continue,\n            }\n        }\n\n        // 将eval运行字符串更改为直接返回字符串\n        let re3 = Regex::new(r\"eval\\(strc\\)[\\s\\S]*?\\)\")?;\n        let func = re3.replace_all(&func, \"strc\").to_string();\n        let func = format!(\"{func}ub98484234(0,0,0)\");\n\n        // 获取eval实际运行的字符串\n        let res = eval(&func);\n        let res = res.trim_matches('\"');\n\n        // 构建函数, 替换数值\n        let res = res.replace(\"(function\", \"let ccc = function\");\n        let res = res.replace(\n            \"rt;})\",\n            format!(\"rt;}}; ccc({rid}, \\\"{DID}\\\", {dt})\").as_str(),\n        );\n\n        // 替换md5值避免js依赖\n        let cb = format!(\"{rid}{DID}{dt}{v}\");\n        let rb = {\n            let mut h = Md5::new();\n            h.update(cb);\n            hex::encode(h.finalize())\n        };\n\n        let res = res.replace(\n            \"CryptoJS.MD5(cb).toString();\",\n            format!(\"\\\"{}\\\";\", &rb).as_str(),\n        );\n\n        // 运行js获取签名值\n        let sign = eval(&res);\n        let sign = sign.trim_matches('\"');\n        let sign = sign.rsplit_once('=').ok_or(SeamError::NeedFix(\"sign\"))?.1;\n\n        let mut params = HashMap::new();\n        params.insert(\"v\", v);\n        params.insert(\"did\", DID);\n        params.insert(\"tt\", dt);\n        params.insert(\"sign\", sign);\n\n        let json = CLIENT\n            .post(format!(\"{PLAY_URL}{rid}\"))\n            .form(&params)\n            .headers(headers.clone())\n            .send()\n            .await?\n            .json::<Value>()\n            .await?;\n\n        match json[\"error\"]\n            .as_i64()\n            .ok_or(SeamError::NeedFix(\"error code\"))?\n        {\n            0 => {\n                let info = get_info(rid, headers.clone()).await?;\n\n                let cdns = json[\"data\"][\"cdnsWithName\"]\n                    .as_array()\n                    .ok_or(SeamError::NeedFix(\"cdns\"))?;\n                let cdns = cdns\n                    .iter()\n                    .map(|x| x[\"cdn\"].as_str().unwrap_or(\"\").to_owned())\n                    .collect::<HashSet<_>>();\n                let rtmp_cdn = json[\"data\"][\"rtmp_cdn\"]\n                    .as_str()\n                    .ok_or(SeamError::NeedFix(\"rtmp_cdn\"))?\n                    .to_owned();\n\n                let mut jsons = vec![json];\n\n                if headers.get(\"cookie\").is_some() {\n                    for cdn in cdns {\n                        if cdn == rtmp_cdn {\n                            continue;\n                        }\n                        let mut tmp = params.clone();\n                        let headers_tmp = headers.clone();\n                        tmp.insert(\"cdn\", &cdn);\n\n                        let json = CLIENT\n                            .post(format!(\"{PLAY_URL}{rid}\"))\n                            .form(&tmp)\n                            .headers(headers_tmp)\n                            .send()\n                            .await?\n                            .json::<Value>()\n                            .await?;\n\n                        jsons.push(json);\n                    }\n                }\n\n                let nodes = jsons\n                    .iter()\n                    .map(|json| {\n                        let key = json[\"data\"][\"rtmp_live\"].as_str().unwrap_or(\"需要修复\");\n                        let url = json[\"data\"][\"rtmp_url\"].as_str().unwrap_or(\"需要修复\");\n                        parse_url(format!(\"{url}/{key}\"))\n                    })\n                    .collect::<Vec<_>>();\n\n                Ok(Node {\n                    rid: rid.to_owned(),\n                    title: info.title,\n                    cover: info.cover,\n                    anchor: info.anchor,\n                    head: info.head,\n                    urls: nodes,\n                })\n            }\n            _ => Err(SeamError::None),\n        }\n    }\n}\n\nstruct DouyuInfo {\n    title: String,\n    cover: String,\n    anchor: String,\n    head: String,\n}\n\nasync fn get_info(rid: &str, headers: HeaderMap) -> Result<DouyuInfo> {\n    let json = CLIENT\n        .get(format!(\"{BETARD_URL}{rid}\"))\n        .headers(headers)\n        .send()\n        .await?\n        .json::<Value>()\n        .await?;\n    let title = json[\"room\"][\"room_name\"]\n        .as_str()\n        .unwrap_or(\"获取失败\")\n        .to_string();\n    let cover = json[\"room\"][\"room_pic\"].as_str().unwrap_or(\"\").to_string();\n    let anchor = json[\"room\"][\"owner_name\"]\n        .as_str()\n        .unwrap_or(\"\")\n        .to_string();\n    let head = json[\"room\"][\"owner_avatar\"]\n        .as_str()\n        .unwrap_or(\"\")\n        .to_string();\n    Ok(DouyuInfo {\n        title,\n        cover,\n        anchor,\n        head,\n    })\n}\n\n#[cfg(test)]\nmacros::gen_test!(100);\n"
  },
  {
    "path": "crates/core/src/live/flex.rs",
    "content": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header, parse_url},\n};\n\nuse super::{Live, Node};\n\nconst URL: &str = \"https://api.flextv.co.kr/api/channels/rid/stream?option=all\";\n\n/// flextv\n///\n/// https://www.flextv.co.kr/\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let json: serde_json::Value = CLIENT\n            .get(URL.replace(\"rid\", rid))\n            .headers(hash2header(headers))\n            .send()\n            .await?\n            .json()\n            .await?;\n        match &json[\"sources\"][0][\"url\"] {\n            serde_json::Value::Null => Err(SeamError::None),\n            url => {\n                let urls = vec![parse_url(\n                    url.as_str().ok_or(SeamError::NeedFix(\"url\"))?.to_string(),\n                )];\n\n                let title = match &json[\"stream\"][\"title\"] {\n                    serde_json::Value::Null => \"获取失败\",\n                    title => title.as_str().unwrap_or(\"获取失败\"),\n                };\n\n                Ok(Node {\n                    rid: rid.to_owned(),\n                    title: title.to_owned(),\n                    cover: \"\".to_owned(),\n                    anchor: \"\".to_owned(),\n                    head: \"\".to_owned(),\n                    urls,\n                })\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmacros::gen_test!(437149);\n"
  },
  {
    "path": "crates/core/src/live/huajiao.rs",
    "content": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse regex::Regex;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header, parse_url},\n};\n\nuse super::{Live, Node};\n\nconst URL: &str = \"https://www.huajiao.com/l/\";\n\n/// 花椒直播\n///\n/// https://www.huajiao.com/\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let text = CLIENT\n            .get(format!(\"{URL}{rid}\"))\n            .headers(hash2header(headers))\n            .send()\n            .await?\n            .text()\n            .await?;\n\n        let re1 = Regex::new(r#\"sn\":\"([\\s\\S]*?)\"\"#)?;\n        let re2 = Regex::new(r#\"\"replay_status\":([0-9]*)\"#)?;\n        let sn = match re1.captures(&text) {\n            Some(cap) => cap.get(1).ok_or(SeamError::NeedFix(\"sn\"))?.as_str(),\n            None => return Err(SeamError::None),\n        };\n\n        let re_title = Regex::new(r#\"content=\"【(.+)】\"#)?;\n        let title = match re_title.captures(&text) {\n            Some(cap) => match cap.get(1) {\n                Some(title) => title.as_str().to_owned(),\n                None => \"获取失败\".to_owned(),\n            },\n            None => \"获取失败\".to_owned(),\n        };\n\n        let pls: Vec<&str> = sn.split('_').collect();\n        let pl = pls[2].to_lowercase();\n\n        let captures = re2.captures(&text).ok_or(SeamError::None)?;\n        let code = captures.get(1).ok_or(SeamError::NeedFix(\"code\"))?.as_str();\n\n        if code == \"0\" {\n            Ok(Node {\n                rid: rid.to_owned(),\n                title,\n                cover: \"\".to_owned(),\n                anchor: \"\".to_owned(),\n                head: \"\".to_owned(),\n                urls: vec![parse_url(format!(\n                    \"https://{pl}-flv.live.huajiao.com/live_huajiao_v2/{sn}.m3u8\"\n                ))],\n            })\n        } else {\n            Err(SeamError::None)\n        }\n    }\n}\n\n#[cfg(test)]\nmacros::gen_test!(337633032);\n"
  },
  {
    "path": "crates/core/src/live/huya.rs",
    "content": "use std::collections::HashMap;\nuse std::time::{SystemTime, UNIX_EPOCH};\n\nuse async_trait::async_trait;\nuse base64::{engine::general_purpose, Engine as _};\nuse md5::{Digest, Md5};\nuse rand::Rng;\nuse regex::Regex;\nuse serde_json::json;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header, parse_url},\n};\n\nuse super::{Live, Node};\n\nconst URL: &str = \"https://m.huya.com/\";\n\n/// 虎牙直播\n///\n/// https://huya.com/\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();\n        let rand = rand::thread_rng().gen_range(0..1000);\n\n        let uid = get_anonymous_uid().await?;\n\n        let text = CLIENT\n            .get(format!(\"{URL}{rid}\"))\n            .header(\"Content-Type\", \"application/x-www-form-urlencoded\")\n            .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\")\n            .headers(hash2header(headers))\n            .send()\n            .await?\n            .text()\n            .await?;\n\n        let re = Regex::new(r\"<script> window.HNF_GLOBAL_INIT = ([\\s\\S]*) </script>\")?;\n\n        let stream = match re.captures(&text) {\n            Some(caps) => caps.get(1).ok_or(SeamError::NeedFix(\"stream\"))?.as_str(),\n            None => return Err(SeamError::NeedFix(\"stream none\")),\n        };\n\n        let json: serde_json::Value = serde_json::from_str(stream)?;\n\n        let status = json[\"roomInfo\"][\"eLiveStatus\"]\n            .as_i64()\n            .ok_or(SeamError::NeedFix(\"eLiveStatus\"))?;\n\n        if status != 2 {\n            return Err(SeamError::None);\n        }\n\n        let title = match json[\"roomInfo\"][\"tLiveInfo\"][\"sIntroduction\"] {\n            serde_json::Value::String(ref title) => title.as_str(),\n            _ => \"获取失败\",\n        };\n\n        let mut urls = vec![];\n\n        let streams = json[\"roomInfo\"][\"tLiveInfo\"][\"tLiveStreamInfo\"][\"vStreamInfo\"][\"value\"]\n            .as_array()\n            .ok_or(SeamError::NeedFix(\"vStreamInfo\"))?;\n\n        for stream in streams {\n            let flv = stream[\"sFlvUrl\"]\n                .as_str()\n                .ok_or(SeamError::NeedFix(\"sFlvUrl\"))?;\n            let hls = stream[\"sHlsUrl\"]\n                .as_str()\n                .ok_or(SeamError::NeedFix(\"sHlsUrl\"))?;\n\n            let anti_code = process_anticode(\n                stream[\"sFlvAntiCode\"]\n                    .as_str()\n                    .ok_or(SeamError::NeedFix(\"sFlvAntiCode\"))?,\n                stream[\"sStreamName\"]\n                    .as_str()\n                    .ok_or(SeamError::NeedFix(\"sStreamName\"))?,\n                uid,\n                now,\n                rand,\n            )?;\n            urls.push(parse_url(format!(\n                \"{}/{}.{}?{}\",\n                flv,\n                stream[\"sStreamName\"]\n                    .as_str()\n                    .ok_or(SeamError::NeedFix(\"sStreamName\"))?,\n                stream[\"sFlvUrlSuffix\"]\n                    .as_str()\n                    .ok_or(SeamError::NeedFix(\"sFlvUrlSuffix\"))?,\n                anti_code\n            )));\n\n            let anti_code = process_anticode(\n                stream[\"sHlsAntiCode\"]\n                    .as_str()\n                    .ok_or(SeamError::NeedFix(\"sHlsAntiCode\"))?,\n                stream[\"sStreamName\"]\n                    .as_str()\n                    .ok_or(SeamError::NeedFix(\"sStreamName\"))?,\n                uid,\n                now,\n                rand,\n            )?;\n            urls.push(parse_url(format!(\n                \"{}/{}.{}?{}\",\n                hls,\n                stream[\"sStreamName\"]\n                    .as_str()\n                    .ok_or(SeamError::NeedFix(\"sStreamName\"))?,\n                stream[\"sHlsUrlSuffix\"]\n                    .as_str()\n                    .ok_or(SeamError::NeedFix(\"sHlsUrlSuffix\"))?,\n                anti_code\n            )));\n        }\n\n        Ok(Node {\n            rid: rid.to_owned(),\n            title: title.to_owned(),\n            cover: \"\".to_owned(),\n            anchor: \"\".to_owned(),\n            head: \"\".to_owned(),\n            urls,\n        })\n    }\n}\n\nfn get_uuid(now: u128, rand: u32) -> u128 {\n    (now % 10000000000 * 1000 + rand as u128) % 4294967295\n}\n\nasync fn get_anonymous_uid() -> Result<u128> {\n    let resp: HashMap<String, serde_json::Value> = CLIENT\n        .post(\"https://udblgn.huya.com/web/anonymousLogin\")\n        .json(&json!({\n            \"appId\": 5002,\n            \"byPass\": 3,\n            \"context\": \"\",\n            \"version\": \"2.4\",\n            \"data\": {}\n        }))\n        .send()\n        .await?\n        .json()\n        .await?;\n\n    let uid = resp[\"data\"][\"uid\"]\n        .as_str()\n        .ok_or(SeamError::NeedFix(\"uid\"))?\n        .to_string()\n        .parse()?;\n    Ok(uid)\n}\n\nfn process_anticode(\n    anticode: &str,\n    stream_name: &str,\n    uid: u128,\n    now: u128,\n    rand: u32,\n) -> Result<String> {\n    let anticode = urlencoding::decode(anticode)?.to_string();\n    let mut anti_map = anticode.split('&').fold(HashMap::new(), |mut map, s| {\n        let (k, v) = s.split_once('=').unwrap_or_default();\n        map.insert(k.to_owned(), v.to_owned());\n        map\n    });\n\n    anti_map.insert(\"ver\".to_string(), \"1\".to_string());\n    anti_map.insert(\"sv\".to_string(), \"2110211124\".to_string());\n    anti_map.insert(\"seqid\".to_string(), format!(\"{}\", uid + now * 1_000));\n    anti_map.insert(\"uid\".to_string(), uid.to_string());\n    anti_map.insert(\"uuid\".to_string(), get_uuid(now, rand).to_string());\n\n    let seqid = anti_map[\"seqid\"].as_str();\n    let ctype = anti_map[\"ctype\"].as_str();\n    let t = anti_map[\"t\"].as_str();\n\n    let result = {\n        let mut h = Md5::new();\n        h.update(format!(\"{}|{}|{}\", seqid, ctype, t));\n        hex::encode(h.finalize())\n    };\n\n    let fm = anti_map[\"fm\"].as_str();\n\n    let fm = general_purpose::STANDARD.decode(fm)?;\n    let fm = String::from_utf8(fm)?;\n\n    let fm = fm.replace(\"$0\", &anti_map[\"uid\"]);\n    let fm = fm.replace(\"$1\", stream_name);\n    let fm = fm.replace(\"$2\", &result);\n    let fm = fm.replace(\"$3\", &anti_map[\"wsTime\"]);\n\n    let secret = {\n        let mut h = Md5::new();\n        h.update(fm);\n        hex::encode(h.finalize())\n    };\n\n    anti_map\n        .insert(\"wsSecret\".to_string(), secret)\n        .ok_or(SeamError::NeedFix(\"wsSecret\"))?;\n\n    anti_map.remove(\"fm\");\n\n    let mut s = String::new();\n    for (k, v) in anti_map {\n        s = format!(\"{}&{}={}\", s, k, v);\n    }\n    Ok(s)\n}\n\n#[cfg(test)]\nmacros::gen_test!(660000);\n"
  },
  {
    "path": "crates/core/src/live/inke.rs",
    "content": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header, parse_url},\n};\n\nuse super::{Live, Node};\n\nconst URL: &str = \"https://webapi.busi.inke.cn/web/live_share_pc?uid=\";\n\n/// 映客直播\n///\n/// https://www.inke.cn/\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let json: serde_json::Value = CLIENT\n            .get(format!(\"{URL}{rid}\"))\n            .headers(hash2header(headers))\n            .send()\n            .await?\n            .json()\n            .await?;\n\n        match &json[\"data\"][\"status\"].as_i64() {\n            Some(1) => {\n                let title = json[\"data\"][\"live_name\"]\n                    .as_str()\n                    .unwrap_or(\"inke\")\n                    .to_string();\n                let mut urls = vec![];\n                for s in [\"stream_addr\", \"hls_stream_addr\", \"rtmp_stream_addr\"] {\n                    if !json[\"data\"][\"live_addr\"][0][s].is_null() {\n                        urls.push(parse_url(\n                            json[\"data\"][\"live_addr\"][0][s]\n                                .as_str()\n                                .ok_or(SeamError::NeedFix(\"live_addr\"))?\n                                .to_string(),\n                        ));\n                    }\n                }\n\n                Ok(Node {\n                    rid: rid.to_owned(),\n                    title,\n                    cover: \"\".to_owned(),\n                    anchor: \"\".to_owned(),\n                    head: \"\".to_owned(),\n                    urls,\n                })\n            }\n            _ => Err(SeamError::None),\n        }\n    }\n}\n\n#[cfg(test)]\nmacros::gen_test!(713935849);\n"
  },
  {
    "path": "crates/core/src/live/kk.rs",
    "content": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse regex::Regex;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header, parse_url},\n};\n\nuse super::{Live, Node};\n\nconst URL: &str = \"https://www.kktv5.com/show/\";\n\n/// kk直播\n///\n/// https://www.kktv5.com/\npub struct Client;\n\n// TODO 简化后半部分逻辑, 仅判断开播与标题, 最后拼接node\n#[async_trait]\nimpl Live for Client {\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let text = CLIENT\n            .get(format!(\"{URL}{rid}\"))\n            .headers(hash2header(headers))\n            .send()\n            .await?\n            .text()\n            .await?;\n\n        let re = Regex::new(r\"window.acotor_simple_info = ([\\s\\S]*?);\")?;\n        let re2 = Regex::new(r\"var __actor_info__ = ([\\s\\S]*?) var\")?;\n\n        let node1 = match re.captures(&text) {\n            Some(cap) => {\n                let json = cap.get(1).ok_or(SeamError::NeedFix(\"captures\"))?.as_str();\n                let json = serde_json::from_str::<serde_json::Value>(json)?;\n\n                let title = json[\"roomTheme\"].as_str().unwrap_or(\"获取失败\").to_owned();\n\n                let live = json[\"liveType\"]\n                    .as_i64()\n                    .ok_or(SeamError::NeedFix(\"liveType\"))?;\n\n                match live {\n                    1 => {\n                        let urls = vec![parse_url(format!(\n                            \"https://pull.kktv8.com/livekktv/{}.flv\",\n                            rid\n                        ))];\n                        Some(Node {\n                            rid: rid.to_owned(),\n                            title,\n                            cover: \"\".to_owned(),\n                            anchor: \"\".to_owned(),\n                            head: \"\".to_owned(),\n                            urls,\n                        })\n                    }\n                    _ => None,\n                }\n            }\n            None => None,\n        };\n        let node2 = match re2.captures(&text) {\n            Some(cap) => {\n                let json = cap.get(1).ok_or(SeamError::NeedFix(\"captures\"))?.as_str();\n                let json = serde_json::from_str::<serde_json::Value>(json)?;\n\n                let title = json[\"roomTheme\"].as_str().unwrap_or(\"获取失败\").to_owned();\n\n                let live = json[\"liveType\"]\n                    .as_i64()\n                    .ok_or(SeamError::NeedFix(\"liveType\"))?;\n\n                match live {\n                    1 => {\n                        let urls = vec![parse_url(format!(\n                            \"https://pull.kktv8.com/livekktv/{}.flv\",\n                            rid\n                        ))];\n                        Some(Node {\n                            rid: rid.to_owned(),\n                            title,\n                            cover: \"\".to_owned(),\n                            anchor: \"\".to_owned(),\n                            head: \"\".to_owned(),\n                            urls,\n                        })\n                    }\n                    _ => None,\n                }\n            }\n            None => None,\n        };\n        if let Some(node) = node1 {\n            return Ok(node);\n        }\n        if let Some(node) = node2 {\n            return Ok(node);\n        }\n        Err(SeamError::None)\n    }\n}\n\n#[cfg(test)]\nmacros::gen_test!(521);\n"
  },
  {
    "path": "crates/core/src/live/ks.rs",
    "content": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse regex::Regex;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header, parse_url},\n};\n\nuse super::{Live, Node};\n\nconst URL: &str = \"https://live.kuaishou.com/u/\";\n\n/// 快手直播\n///\n/// https://live.kuaishou.com/\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    // TODO 说明所需 cookie\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let text = CLIENT\n            .get(format!(\"{URL}{rid}\"))\n            .headers(hash2header(headers))\n            .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\")\n            .send()\n            .await?\n            .text()\n            .await?;\n        let re = Regex::new(r\"<script>window.__INITIAL_STATE__=([\\s\\S]*?);\\(function\")?;\n        let stream = match re.captures(&text) {\n            Some(caps) => caps.get(1).ok_or(SeamError::NeedFix(\"stream\"))?.as_str(),\n            None => {\n                return Err(SeamError::NeedFix(\"stream none\"));\n            }\n        };\n        let json: serde_json::Value = serde_json::from_str(stream)?;\n\n        let title = json[\"liveroom\"][\"playList\"][0][\"liveStream\"][\"caption\"]\n            .as_str()\n            .unwrap_or(\"获取失败\")\n            .to_owned();\n\n        let cover = json[\"liveroom\"][\"playList\"][0][\"liveStream\"][\"poster\"]\n            .as_str()\n            .unwrap_or(\"\")\n            .to_owned();\n\n        let head = json[\"liveroom\"][\"playList\"][0][\"author\"][\"avatar\"]\n            .as_str()\n            .unwrap_or(\"\")\n            .to_owned();\n\n        let anchor = json[\"liveroom\"][\"playList\"][0][\"author\"][\"name\"]\n            .as_str()\n            .unwrap_or(\"获取失败\")\n            .to_owned();\n\n        match &json[\"liveroom\"][\"playList\"][0][\"liveStream\"][\"playUrls\"][0][\"adaptationSet\"]\n            [\"representation\"]\n        {\n            serde_json::Value::Null => Err(SeamError::None),\n            reps => {\n                let list = reps.as_array().ok_or(SeamError::NeedFix(\"list\"))?;\n                let url = list[list.len() - 1][\"url\"]\n                    .as_str()\n                    .ok_or(SeamError::NeedFix(\"url\"))?;\n                let urls = vec![parse_url(url.to_string())];\n                Ok(Node {\n                    rid: rid.to_owned(),\n                    title,\n                    cover,\n                    anchor,\n                    head,\n                    urls,\n                })\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmacros::gen_test!(Bd20210915);\n"
  },
  {
    "path": "crates/core/src/live/mht.rs",
    "content": "use std::collections::HashMap;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header, parse_url},\n};\n\nuse async_trait::async_trait;\nuse serde_json::Value;\n\nuse super::{Live, Node};\n\nconst URL: &str = \"https://www.2cq.com/proxy/room/room/info\";\n\n/// 棉花糖直播\n///\n/// https://www.2cq.com/\npub struct Client;\n\n// TODO 似乎某些房间有额外的 flv 地址\n#[async_trait]\nimpl Live for Client {\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let resp: serde_json::Value = CLIENT\n            .get(URL)\n            .query(&[(\"roomId\", rid), (\"appId\", \"1004\")])\n            .headers(hash2header(headers))\n            .send()\n            .await?\n            .json()\n            .await?;\n        match &resp[\"errorMsg\"] {\n            Value::Null => {\n                // 不报错的情况必然有结果返回 直接提取\n                let result = &resp[\"result\"];\n                let title = result[\"roomName\"].as_str().unwrap_or(\"获取失败\");\n                match result[\"liveState\"].to_string().parse::<usize>()? {\n                    // 开播状态\n                    1 => {\n                        let urls = vec![parse_url(\n                            result[\"pullUrl\"]\n                                .as_str()\n                                .ok_or(SeamError::NeedFix(\"pull url\"))?\n                                .to_owned(),\n                        )];\n                        Ok(Node {\n                            rid: rid.to_owned(),\n                            title: title.to_owned(),\n                            cover: \"\".to_owned(),\n                            anchor: \"\".to_owned(),\n                            head: \"\".to_owned(),\n                            urls,\n                        })\n                    }\n                    _ => Err(SeamError::None),\n                }\n            }\n            // 房间不存在或其他错误\n            msg => Err(SeamError::Unknown(msg.to_string())),\n        }\n    }\n}\n\n#[cfg(test)]\nmacros::gen_test!(911038);\n"
  },
  {
    "path": "crates/core/src/live/mod.rs",
    "content": "//! 直播相关模块。\n//!\n//! 本模块提供了标准化的直播获取方式和直播状态检测的async trait 以及\n//! 标准化的直播源信息和直播状态enum\n\nuse async_trait::async_trait;\nuse macros::gen_all;\nuse serde::{Serialize, Serializer};\nuse std::collections::HashMap;\nuse std::sync::Arc;\n\nuse crate::error::{Result, SeamError};\n\npub mod afreeca;\npub mod bili;\npub mod cc;\npub mod douyin;\npub mod douyu;\npub mod flex;\npub mod huajiao;\npub mod huya;\npub mod inke;\npub mod kk;\npub mod ks;\npub mod mht;\npub mod now;\npub mod panda;\npub mod qf;\npub mod twitch;\npub mod wink;\npub mod yqs;\n\n// TODO 调整平台名称缩写， 尽量使用官方完整名称\n\n/// 直播信息模块\n#[async_trait]\npub trait Live: Send + Sync {\n    /// 获取直播源\n    ///\n    /// rid: 直播间号\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node>;\n}\n\n// 返回所有受支持的直播平台 对应的 hashmap\ngen_all!();\n\n#[cfg(test)]\nmod test {\n    use super::all;\n\n    #[tokio::test]\n    async fn test_get() {\n        println!(\n            \"{:#?}\",\n            all().get(\"bili\").unwrap().get(\"6\", None).await.unwrap()\n        );\n    }\n}\n\n/// TODO 拆分独立模块\n/// 1. 检测是否开播, 仅返回是否开播\n/// 2. 直播间信息模块,\n///     1. 直播间标题\n///     2. 直播间封面\n///     3. 主播头像\n/// 3. 直播源地址模块\n/// 4. 弹幕模块\n\n// TODO 整理代码中的注释, 使其更加规范\n\n// TODO title 可以弄成&str吗?\n\n/// 直播源\n///\n/// - rid: 直播间号\n/// - title: 直播间标题\n/// - nodes: 直播源列表\n#[derive(Serialize, Debug, Clone, PartialEq)]\npub struct Node {\n    pub rid: String,\n    pub title: String,\n    pub cover: String,\n    pub anchor: String,\n    pub head: String,\n    pub urls: Vec<Url>,\n}\n\nimpl Node {\n    pub fn json(&self) -> String {\n        serde_json::to_string_pretty(&self).unwrap_or(\"序列化失败\".to_owned())\n    }\n}\n\n#[derive(Serialize, Debug, Clone, PartialEq)]\npub struct Url {\n    /// 直播源格式\n    pub format: Format,\n    /// 直播源地址, 默认均为最高清晰度, 故而无需额外标注清晰度\n    pub url: String,\n}\n\nimpl Url {\n    pub fn is_m3u8(&self) -> Result<String> {\n        match self.format {\n            Format::M3U => Ok(self.url.clone()),\n            _ => Err(SeamError::Type(\"not m3u8\".to_string())),\n        }\n    }\n}\n\n#[derive(Debug, Clone, PartialEq)]\npub enum Format {\n    Flv,\n    M3U,\n    Rtmp,\n    Other(String),\n}\n/// 自定义序列化方法\nimpl Serialize for Format {\n    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>\n    where\n        S: Serializer,\n    {\n        let str = match self {\n            Format::Flv => \"flv\",\n            Format::M3U => \"m3u\",\n            Format::Rtmp => \"rtmp\",\n            Format::Other(s) => s.as_str(),\n        };\n        serializer.serialize_str(str)\n    }\n}\n"
  },
  {
    "path": "crates/core/src/live/now.rs",
    "content": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header, parse_url},\n};\n\nuse super::{Live, Node};\n\nconst ROOM_URL: &str =\n    \"https://now.qq.com/cgi-bin/now/web/room/get_live_room_url?platform=8&room_id=\";\nconst URL: &str = \"https://now.qq.com/pcweb/story.html?roomid=\";\n/// NOW直播\n///\n/// https://now.qq.com/\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let json: serde_json::Value = CLIENT\n            .get(format!(\"{ROOM_URL}{rid}\"))\n            .headers(hash2header(headers.clone()))\n            .send()\n            .await?\n            .json()\n            .await?;\n\n        match &json[\"result\"][\"is_on_live\"]\n            .as_bool()\n            .ok_or(SeamError::NeedFix(\"result\"))?\n        {\n            true => {\n                let mut urls = vec![];\n                for f in [\"raw_flv_url\", \"raw_hls_url\", \"raw_rtmp_url\"] {\n                    if let Some(url) = json[\"result\"][f].as_str() {\n                        urls.push(parse_url(url.to_string()));\n                    }\n                }\n                let title = get_title(rid, headers)\n                    .await\n                    .unwrap_or(\"获取失败\".to_owned());\n                Ok(Node {\n                    rid: rid.to_owned(),\n                    title,\n                    cover: \"\".to_owned(),\n                    anchor: \"\".to_owned(),\n                    head: \"\".to_owned(),\n                    urls,\n                })\n            }\n            false => Err(SeamError::None),\n        }\n    }\n}\n\nasync fn get_title(rid: &str, headers: Option<HashMap<String, String>>) -> Result<String> {\n    let json = CLIENT\n        .get(format!(\"{URL}{rid}\"))\n        .headers(hash2header(headers))\n        .send()\n        .await?\n        .text()\n        .await?;\n\n    let re = regex::Regex::new(r#\"\"anchorName\":\"([\\s\\S]*?)\"\"#).unwrap();\n\n    match re.captures(&json) {\n        Some(caps) => {\n            let title = caps\n                .get(1)\n                .ok_or(SeamError::NeedFix(\"captures\"))?\n                .as_str()\n                .to_owned();\n            Ok(title)\n        }\n        None => Err(SeamError::NeedFix(\"title\")),\n    }\n}\n\n#[cfg(test)]\nmacros::gen_test!(1351697153);\n"
  },
  {
    "path": "crates/core/src/live/panda.rs",
    "content": "use std::collections::HashMap;\n\nconst URL: &str = \"https://api.pandalive.co.kr/v1/live/play/\";\n\nuse async_trait::async_trait;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header, parse_url},\n};\n\nuse super::{Live, Node};\n\n/// pandalive\n///\n/// https://www.pandalive.co.kr/\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let mut form = HashMap::new();\n        form.insert(\"action\", \"watch\");\n        form.insert(\"userId\", rid);\n        let json: serde_json::Value = CLIENT\n            .post(URL)\n            .form(&form)\n            .headers(hash2header(headers))\n            .send()\n            .await?\n            .json()\n            .await?;\n        match &json[\"PlayList\"] {\n            serde_json::Value::Null => Err(SeamError::None),\n            list => {\n                let mut urls = vec![];\n                for item in [\"hls\", \"hls2\", \"hls3\", \"rtmp\"] {\n                    if list.get(item).is_some() {\n                        urls.push(parse_url(\n                            list[item][0][\"url\"]\n                                .as_str()\n                                .ok_or(SeamError::NeedFix(\"list\"))?\n                                .to_string(),\n                        ));\n                    }\n                }\n\n                let title = match &json[\"media\"][\"title\"] {\n                    serde_json::Value::Null => \"获取失败\",\n                    title => title.as_str().unwrap_or(\"获取失败\"),\n                };\n\n                Ok(Node {\n                    rid: rid.to_owned(),\n                    title: title.to_owned(),\n                    cover: \"\".to_owned(),\n                    anchor: \"\".to_owned(),\n                    head: \"\".to_owned(),\n                    urls,\n                })\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmacros::gen_test!(wert681800);\n"
  },
  {
    "path": "crates/core/src/live/qf.rs",
    "content": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse regex::Regex;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header, parse_url},\n};\n\nuse super::{Live, Node};\n\nconst URL: &str = \"https://qf.56.com/\";\n\n/// 千帆直播\n///\n/// https://qf.56.com/\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let text = CLIENT\n            .get(format!(\"{URL}{rid}\"))\n            .headers(hash2header(headers))\n            .send()\n            .await?\n            .text()\n            .await?;\n        let re_title = Regex::new(r\"nickName: '(.+)'\")?;\n        let title = match re_title.captures(&text) {\n            Some(cap) => cap\n                .get(1)\n                .ok_or(SeamError::NeedFix(\"title\"))?\n                .as_str()\n                .to_owned(),\n            None => \"qf\".to_owned(),\n        };\n        let re = Regex::new(r\"flvUrl:'([\\s\\S]*?)'\")?;\n        match re.captures(&text) {\n            Some(cap) => {\n                let urls = vec![parse_url(\n                    cap.get(1)\n                        .ok_or(SeamError::NeedFix(\"captures\"))?\n                        .as_str()\n                        .to_string(),\n                )];\n                Ok(Node {\n                    rid: rid.to_owned(),\n                    title,\n                    cover: \"\".to_owned(),\n                    anchor: \"\".to_owned(),\n                    head: \"\".to_owned(),\n                    urls,\n                })\n            }\n            None => Err(SeamError::None),\n        }\n    }\n}\n\n#[cfg(test)]\nmacros::gen_test!(520006);\n"
  },
  {
    "path": "crates/core/src/live/twitch.rs",
    "content": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse reqwest::header::HeaderMap;\nuse serde_json::Value;\n\nuse super::{Live, Node, Url};\nuse crate::common::{CLIENT, USER_AGENT};\nuse crate::error::{Result, SeamError};\nuse crate::util::hash2header;\n\n/// twitch直播\n///\n/// https://www.twitch.tv\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let rid = rid.to_ascii_lowercase();\n\n        let mut headers = hash2header(headers);\n        headers.insert(\"Referer\", \"https://www.twitch.tv\".parse()?);\n        headers.insert(\"Origin\", \"https://www.twitch.tv\".parse()?);\n        headers.insert(\"Client-ID\", \"kimne78kx3ncx6brgo4mv6wki5h1ko\".parse()?);\n\n        if !headers.contains_key(\"User-Agent\") {\n            headers.insert(\"User-Agent\", USER_AGENT.parse()?);\n        }\n\n        let metadata = self.get_channel_metadata(&rid, headers.clone()).await?;\n\n        if metadata[1][\"data\"][\"user\"][\"stream\"].is_null() {\n            Err(SeamError::None)\n        } else {\n            let (signature, token) = self.get_access_token(&rid, headers.clone()).await?;\n            let urls = self\n                .get_live_streams(&rid, &signature, &token, headers.clone())\n                .await?\n                .into_iter()\n                .map(|url| Url {\n                    format: super::Format::M3U,\n                    url,\n                })\n                .collect();\n\n            let get_string = |v: &Value| v.as_str().map(|s| s.to_string()).unwrap_or_default();\n\n            Ok(Node {\n                rid: rid.to_string(),\n                title: get_string(&metadata[1][\"data\"][\"user\"][\"lastBroadcast\"][\"title\"]),\n                cover: format!(\n                    \"https://static-cdn.jtvnw.net/previews-ttv/live_user_{rid}-320x180.jpg\"\n                ),\n                anchor: get_string(&metadata[0][\"data\"][\"userOrError\"][\"displayName\"]),\n                head: get_string(&metadata[1][\"data\"][\"user\"][\"profileImageURL\"]),\n                urls,\n            })\n        }\n    }\n}\n\nimpl Client {\n    async fn get_access_token(\n        &self,\n        rid: &str,\n        mut headers: HeaderMap,\n    ) -> Result<(String, String)> {\n        let data = r#\"{\"operationName\":\"PlaybackAccessToken_Template\",\"query\":\"query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) {  streamPlaybackAccessToken(channelName: $login, params: {platform: \\\"web\\\", playerBackend: \\\"mediaplayer\\\", playerType: $playerType}) @include(if: $isLive) {    value    signature   authorization { isForbidden forbiddenReasonCode }   __typename  }  videoPlaybackAccessToken(id: $vodID, params: {platform: \\\"web\\\", playerBackend: \\\"mediaplayer\\\", playerType: $playerType}) @include(if: $isVod) {    value    signature   __typename  }}\",\"variables\":{\"isLive\":true,\"login\":\"___rid___\",\"isVod\":false,\"vodID\":\"\",\"playerType\":\"site\"}}\"#;\n        let data = data.replace(\"___rid___\", rid);\n\n        headers.insert(\"Content-Type\", \"application/json\".parse()?);\n        let json: Value = CLIENT\n            .post(\"https://gql.twitch.tv/gql\")\n            .headers(headers.clone())\n            .body(data)\n            .send()\n            .await?\n            .json()\n            .await?;\n        let signature = json[\"data\"][\"streamPlaybackAccessToken\"][\"signature\"]\n            .as_str()\n            .map(|s| s.to_string())\n            .ok_or_else(|| SeamError::NeedFix(\"twitch signature\"))?;\n        let token = json[\"data\"][\"streamPlaybackAccessToken\"][\"value\"]\n            .as_str()\n            .map(|s| s.to_string())\n            .ok_or_else(|| SeamError::NeedFix(\"twitch token\"))?;\n        Ok((signature, token))\n    }\n\n    async fn get_live_streams(\n        &self,\n        rid: &str,\n        signature: &str,\n        token: &str,\n        headers: HeaderMap,\n    ) -> Result<Vec<String>> {\n        let query = [\n            (\"cdm\", \"wv\"),\n            (\"allow_source\", \"true\"),\n            (\"fast_bread\", \"true\"),\n            (\"player_backend\", \"mediaplayer\"),\n            (\"player_version\", \"1.23.0\"),\n            (\"playlist_include_framerate\", \"true\"),\n            (\"reassignments_supported\", \"true\"),\n            (\"sig\", signature),\n            (\"supported_codecs\", \"avc1\"),\n            (\"token\", token),\n        ];\n\n        let req = CLIENT\n            .get(format!(\n                \"https://usher.ttvnw.net/api/channel/hls/{rid}.m3u8\"\n            ))\n            .headers(headers)\n            .query(&query);\n\n        let urls = req\n            .send()\n            .await?\n            .text()\n            .await?\n            .lines()\n            .filter(|l| l.starts_with(\"https://\"))\n            .map(|l| l.to_string())\n            .collect();\n\n        Ok(urls)\n    }\n\n    async fn get_channel_metadata(&self, rid: &str, headers: HeaderMap) -> Result<Value> {\n        let query = r#\"\n        [\n            {\n                \"operationName\": \"ChannelShell\",\n                \"variables\": {\n                    \"login\": \"___rid___\"\n                },\n                \"extensions\": {\n                    \"persistedQuery\": {\n                        \"version\": 1,\n                        \"sha256Hash\": \"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe\"\n                    }\n                }\n            },\n            {\n                \"operationName\": \"StreamMetadata\",\n                \"variables\": {\n                    \"channelLogin\": \"___rid___\"\n                },\n                \"extensions\": {\n                    \"persistedQuery\": {\n                        \"version\": 1,\n                        \"sha256Hash\": \"252a46e3f5b1ddc431b396e688331d8d020daec27079893ac7d4e6db759a7402\"\n                    }\n                }\n            }\n        ]\n        \"#\n        .replace(\"___rid___\", rid);\n        let json: Value = serde_json::from_str(&query)?;\n\n        let rsp = CLIENT\n            .post(\"https://gql.twitch.tv/gql\")\n            .headers(headers)\n            .json(&json)\n            .send()\n            .await?\n            .json()\n            .await?;\n\n        Ok(rsp)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[tokio::test]\n    async fn test_twitch() {\n        let c = Client;\n        match c.get(\"nl_Kripp\", None).await {\n            Ok(r) => println!(\"{r:?}\"),\n            Err(SeamError::None) => {}\n            Err(e) => {\n                println!(\"{e}\");\n                assert!(false);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/core/src/live/wink.rs",
    "content": "use std::collections::HashMap;\n\nconst URL: &str = \"https://api.winktv.co.kr/v1/live/play\";\n\nuse async_trait::async_trait;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header, parse_url},\n};\n\nuse super::{Live, Node};\n\n/// winktv\n///\n/// https://www.winktv.co.kr/\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let mut form = HashMap::new();\n        form.insert(\"action\", \"watch\");\n        form.insert(\"userId\", rid);\n        let json: serde_json::Value = CLIENT\n            .post(URL)\n            .form(&form)\n            .headers(hash2header(headers))\n            .send()\n            .await?\n            .json()\n            .await?;\n        match &json[\"PlayList\"] {\n            serde_json::Value::Null => Err(SeamError::None),\n            list => {\n                let mut urls = vec![];\n                for item in [\"hls\", \"hls2\", \"hls3\", \"rtmp\"] {\n                    if list.get(item).is_some() {\n                        urls.push(parse_url(\n                            list[item][0][\"url\"]\n                                .as_str()\n                                .ok_or(SeamError::NeedFix(\"url\"))?\n                                .to_string(),\n                        ));\n                    }\n                }\n\n                let title = match &json[\"media\"][\"title\"] {\n                    serde_json::Value::Null => \"获取失败\",\n                    title => title.as_str().unwrap_or(\"获取失败\"),\n                };\n\n                Ok(Node {\n                    rid: rid.to_owned(),\n                    title: title.to_owned(),\n                    cover: \"\".to_owned(),\n                    anchor: \"\".to_owned(),\n                    head: \"\".to_owned(),\n                    urls,\n                })\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmacros::gen_test!(roeunlove);\n"
  },
  {
    "path": "crates/core/src/live/yqs.rs",
    "content": "use async_trait::async_trait;\nuse regex::Regex;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header, parse_url},\n};\n\nuse std::collections::HashMap;\n\nuse super::{Live, Node};\n\nconst URL: &str = \"https://www.173.com/\";\nconst ROOM_URL: &str = \"https://www.173.com/room/getVieoUrl\";\n\n/// 艺气山直播\n///\n/// https://www.173.com/\npub struct Client;\n\n#[async_trait]\nimpl Live for Client {\n    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {\n        let mut params = HashMap::new();\n        params.insert(\"roomId\", rid);\n        let resp: serde_json::Value = CLIENT\n            .post(ROOM_URL)\n            .form(&params)\n            .headers(hash2header(headers.clone()))\n            .send()\n            .await?\n            .json()\n            .await?;\n        let data = &resp[\"data\"];\n        match data[\"status\"].as_i64() {\n            Some(2) => {\n                let urls = vec![parse_url(\n                    data[\"url\"]\n                        .as_str()\n                        .ok_or(SeamError::NeedFix(\"url\"))?\n                        .to_owned(),\n                )];\n                let title = match get_title(rid, headers).await {\n                    Ok(title) => title,\n                    Err(_) => \"获取失败\".to_owned(),\n                };\n                Ok(Node {\n                    rid: rid.to_owned(),\n                    title,\n                    cover: \"\".to_owned(),\n                    anchor: \"\".to_owned(),\n                    head: \"\".to_owned(),\n                    urls,\n                })\n            }\n            _ => Err(SeamError::None),\n        }\n    }\n}\n\n// TODO 主播名和ID 需要 websocket 获取 就很离谱, 目前先获取标题\n// TODO 异步同时请求\nasync fn get_title(rid: &str, headers: Option<HashMap<String, String>>) -> Result<String> {\n    let resp = CLIENT\n        .post(format!(\"{}{}\", URL, rid))\n        .headers(hash2header(headers))\n        .send()\n        .await?\n        .text()\n        .await?;\n\n    let re = Regex::new(r\"var room = JSON\\.parse\\('([\\s\\S]*?)'\\);\")?;\n\n    let caps = re.captures(&resp).ok_or(SeamError::None)?;\n    let data = caps.get(1).ok_or(SeamError::None)?.as_str();\n\n    let data: serde_json::Value = serde_json::from_str(data)?;\n    let title = data[\"name\"].as_str().ok_or(SeamError::None)?;\n\n    Ok(title.to_owned())\n}\n\n#[cfg(test)]\nmacros::gen_test!(96);\n"
  },
  {
    "path": "crates/core/src/util.rs",
    "content": "use boa_engine::Context;\nuse boa_engine::Source;\nuse reqwest::header::HeaderMap;\nuse reqwest::header::HeaderName;\nuse reqwest::header::HeaderValue;\n\nuse crate::live::Format;\nuse crate::live::Url;\nuse std::collections::HashMap;\nuse std::str::FromStr;\n\n/// js运行时\npub fn eval(js: &str) -> String {\n    let mut context = Context::default();\n    match context.eval(Source::from_bytes(js)) {\n        Ok(result) => result.display().to_string(),\n        Err(e) => e.to_string(),\n    }\n}\n\npub fn match_format(url: &str) -> Format {\n    if url.contains(\".m3u8\") {\n        Format::M3U\n    } else if url.contains(\".flv\") {\n        Format::Flv\n    } else if url.contains(\"rtmp:\") {\n        Format::Rtmp\n    } else {\n        Format::Other(\"unknown\".to_owned())\n    }\n}\n\npub fn parse_url(url: String) -> Url {\n    Url {\n        format: match_format(&url),\n        url: url.to_owned(),\n    }\n}\n\n/// 获取当前时间\n/// 格式：20230121-000000-000 (年月日-时分秒-毫秒)\n#[inline]\npub fn get_datetime() -> String {\n    chrono::Local::now().format(\"%Y%m%d-%H%M%S-%3f\").to_string()\n}\n\npub fn hash2header(map: Option<HashMap<String, String>>) -> HeaderMap {\n    if let Some(map) = map {\n        let mut headers = HeaderMap::new();\n        for (k, v) in map.iter() {\n            if let Ok(k) = HeaderName::from_str(k) {\n                match v.parse() {\n                    Ok(v) => {\n                        headers.insert(k, v);\n                    }\n                    Err(_) => {\n                        headers.insert(k, HeaderValue::from_static(\"\"));\n                    }\n                }\n            }\n        }\n        headers\n    } else {\n        HeaderMap::default()\n    }\n}\n"
  },
  {
    "path": "crates/danmu/Cargo.toml",
    "content": "[package]\nname = \"seam_danmu\"\nversion = \"0.1.1\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\nseam_status = { path = \"../status\" }\nthiserror = \"1\"\nserde_json = \"1\"\ntokio = { version = \"1\", features = [\"full\"] }\ntokio-tungstenite = { version = \"0.20\", features = [\"native-tls\"] }\nfutures-sink = \"0.3\"\nfutures-util = { version = \"0.3\", default-features = false, features = [\n    \"sink\",\n    \"std\",\n] }\nminiz_oxide = \"0.7\"\ncolored = \"2\"\nrand = \"0.8\"\nasync-trait = \"0.1\"\npaste = \"1.0\"\n"
  },
  {
    "path": "crates/danmu/src/danmu/afreeca.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Afreeca);\n"
  },
  {
    "path": "crates/danmu/src/danmu/bili.rs",
    "content": "use async_trait::async_trait;\nuse miniz_oxide::inflate::decompress_to_vec_zlib;\nuse rand::Rng;\nuse seam_status::status::bili::Status;\nuse seam_status::StatusTrait;\nuse serde_json::json;\n\nuse crate::error::Result;\n\nuse crate::{websocket_danmu_work_flow, DanmuBody, DanmuRecorder, DanmuTrait};\n\nconst WSS_URL: &str = \"wss://broadcastlv.chat.bilibili.com/sub\";\nconst HEART_BEAT: &str = \"\\x00\\x00\\x00\\x1f\\x00\\x10\\x00\\x01\\x00\\x00\\x00\\x02\\x00\\x00\\x00\\x01\\x5b\\x6f\\x62\\x6a\\x65\\x63\\x74\\x20\\x4f\\x62\\x6a\\x65\\x63\\x74\\x5d \";\nconst HEART_BEAT_INTERVAL: u64 = 60;\n\nfn init_msg_generator(rid: &str) -> Vec<Vec<u8>> {\n    let mut reg_data = vec![];\n\n    let room_id = rid.parse::<i64>().unwrap();\n    let random_uid: u64 = rand::thread_rng().gen_range(100_000_000_000_000..300_000_000_000_000);\n    let data = json!({\n        \"roomid\": room_id,\n        \"uid\": random_uid,\n        \"protover\": 1\n    })\n    .to_string();\n    let data = vec![\n        (data.len() as i32 + 16).to_be_bytes().to_vec(),\n        vec![0x00, 0x10, 0x00, 0x01],\n        7i32.to_be_bytes().to_vec(),\n        1i32.to_be_bytes().to_vec(),\n        data.as_bytes().to_vec(),\n    ];\n    reg_data.push(data.concat());\n    reg_data\n}\n\nfn decode_and_record_danmu(data: &[u8]) -> Result<Vec<DanmuBody>> {\n    if data.len() < 16 {\n        return Ok(vec![]);\n    }\n\n    let mut msgs = vec![];\n\n    let data_to_danmu_body = |sliced_data: &[u8]| -> Option<DanmuBody> {\n        let j: serde_json::Value = serde_json::from_slice(sliced_data).unwrap();\n        let msg_type = j.get(\"cmd\").unwrap().as_str().unwrap();\n        if msg_type == \"DANMU_MSG\" {\n            let user = j[\"info\"][2][1].as_str().unwrap().trim().to_string();\n            let content = j[\"info\"][1].as_str().unwrap().trim().to_string();\n            Some(DanmuBody::new(user, content))\n        } else {\n            None\n        }\n    };\n\n    let decompress_data_to_danmu_body = |compressed_data: &[u8]| -> Vec<DanmuBody> {\n        let decompressed = decompress_to_vec_zlib(compressed_data).unwrap();\n        let mut sptr = 0;\n        let mut danmu_bodies = vec![];\n\n        loop {\n            let packet_len = u32::from_be_bytes(decompressed[sptr..sptr + 4].try_into().unwrap());\n            let op = u32::from_be_bytes(decompressed[sptr + 8..sptr + 12].try_into().unwrap());\n\n            if decompressed[sptr..].len() < packet_len as usize {\n                break;\n            }\n\n            if op == 5 {\n                if let Some(danmu_body) =\n                    data_to_danmu_body(&decompressed[sptr + 16..sptr + packet_len as usize])\n                {\n                    danmu_bodies.push(danmu_body);\n                }\n            }\n\n            if decompressed[sptr..].len() == packet_len as usize {\n                break;\n            } else {\n                sptr += packet_len as usize;\n            }\n        }\n\n        danmu_bodies\n    };\n\n    let mut sptr = 0;\n    loop {\n        let packet_len = u32::from_be_bytes(data[sptr..sptr + 4].try_into().unwrap());\n        let ver = u16::from_be_bytes(data[sptr + 6..sptr + 8].try_into().unwrap());\n        let op = u32::from_be_bytes(data[sptr + 8..sptr + 12].try_into().unwrap());\n\n        if data[sptr..].len() < packet_len as usize {\n            break;\n        }\n\n        if (ver == 1 || ver == 0) && (op == 5) {\n            if let Some(danmu_body) =\n                data_to_danmu_body(&data[sptr + 16..sptr + packet_len as usize])\n            {\n                msgs.push(danmu_body);\n            }\n        } else if ver == 2 {\n            msgs.extend(decompress_data_to_danmu_body(\n                &data[sptr + 16..sptr + packet_len as usize],\n            ));\n        }\n\n        if data[sptr..].len() == packet_len as usize {\n            break;\n        } else {\n            sptr += packet_len as usize;\n        }\n    }\n\n    Ok(msgs)\n}\n\npub struct Danmu;\n\n#[async_trait]\nimpl DanmuTrait for Danmu {\n    async fn start(rid: &str, recorder: Vec<&dyn DanmuRecorder>) -> Result<()> {\n        let heart_beat_msg_generator = || HEART_BEAT.as_bytes().to_vec();\n        let heart_beat_interval = HEART_BEAT_INTERVAL;\n\n        let is_closed_room = || async { Status::status(rid).await.unwrap() };\n\n        websocket_danmu_work_flow(\n            rid,\n            WSS_URL,\n            recorder,\n            init_msg_generator,\n            is_closed_room,\n            heart_beat_msg_generator,\n            heart_beat_interval,\n            decode_and_record_danmu,\n        )\n        .await?;\n\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::Terminal;\n\n    use super::*;\n\n    #[tokio::test]\n    async fn test_danmu_terminal() {\n        Danmu::start(\"6\", vec![&Terminal::try_new(None).unwrap()])\n            .await\n            .unwrap();\n    }\n}\n"
  },
  {
    "path": "crates/danmu/src/danmu/cc.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Cc);\n"
  },
  {
    "path": "crates/danmu/src/danmu/douyin.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Douyu);\n"
  },
  {
    "path": "crates/danmu/src/danmu/douyu.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Douyin);\n"
  },
  {
    "path": "crates/danmu/src/danmu/flex.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Flex);\n"
  },
  {
    "path": "crates/danmu/src/danmu/huajiao.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Huajiao);\n"
  },
  {
    "path": "crates/danmu/src/danmu/huya.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Huya);\n"
  },
  {
    "path": "crates/danmu/src/danmu/inke.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Inke);\n"
  },
  {
    "path": "crates/danmu/src/danmu/kk.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Kk);\n"
  },
  {
    "path": "crates/danmu/src/danmu/ks.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Ks);\n"
  },
  {
    "path": "crates/danmu/src/danmu/mht.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Mht);\n"
  },
  {
    "path": "crates/danmu/src/danmu/mod.rs",
    "content": "pub mod afreeca;\npub mod bili;\npub mod cc;\npub mod douyin;\npub mod douyu;\npub mod flex;\npub mod huajiao;\npub mod huya;\npub mod inke;\npub mod kk;\npub mod ks;\npub mod mht;\npub mod now;\npub mod panda;\npub mod qf;\npub mod wink;\npub mod yqs;\n"
  },
  {
    "path": "crates/danmu/src/danmu/now.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Now);\n"
  },
  {
    "path": "crates/danmu/src/danmu/panda.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Panda);\n"
  },
  {
    "path": "crates/danmu/src/danmu/qf.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Qf);\n"
  },
  {
    "path": "crates/danmu/src/danmu/wink.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Wink);\n"
  },
  {
    "path": "crates/danmu/src/danmu/yqs.rs",
    "content": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Yqs);\n"
  },
  {
    "path": "crates/danmu/src/error.rs",
    "content": "use thiserror::Error;\n\npub type Result<T> = std::result::Result<T, SeamDanmuError>;\n\n#[derive(Error, Debug)]\npub enum SeamDanmuError {\n    #[error(\"IO error: {0}\")]\n    IO(#[from] std::io::Error),\n    #[error(\"Path error: {0}\")]\n    Path(String),\n    #[error(\"WebSocket error: {0}\")]\n    WebSocket(#[from] tokio_tungstenite::tungstenite::Error),\n    #[error(\"unknown data store error\")]\n    Unknown,\n}\n"
  },
  {
    "path": "crates/danmu/src/lib.rs",
    "content": "//! 弹幕相关模块。\n//!\n//! 本模块提供了标准化的弹幕记录的async trait 以及\n//! 标准化的弹幕记录方式enum。\n//!\n//! 本模块提供了基于websocket的标准弹幕工作流。\n//! 如无定制需求，可以直接使用本模块提供的工作流。\n\npub mod danmu;\npub mod error;\n\nuse std::fs::{File, OpenOptions};\nuse std::future::Future;\nuse std::io::prelude::*;\nuse std::path::PathBuf;\nuse std::pin::Pin;\n\nuse async_trait::async_trait;\nuse colored::Colorize;\nuse error::{Result, SeamDanmuError};\nuse futures_sink::Sink;\nuse futures_util::stream::{SplitSink, SplitStream};\nuse futures_util::{SinkExt, StreamExt};\nuse tokio::net::TcpStream;\nuse tokio_tungstenite::{tungstenite::protocol::Message, MaybeTlsStream, WebSocketStream};\n\n/// 标准化弹幕记录异步接口。\n#[async_trait]\npub trait DanmuTrait {\n    /// 运行弹幕记录服务。\n    ///\n    /// 本函数通常将运行websocket长连接，并按指定方式记录弹幕。\n    /// 由于websocket的机制，本函数需要`&mut self`作为参数。\n    ///\n    /// # Errors\n    ///\n    /// 发生不可继续运行的错误的情况下，返回错误。\n    async fn start(rid: &str, recorder: Vec<&dyn DanmuRecorder>) -> Result<()>;\n}\n\n/// 标准化弹幕记录trait。\n///\n/// 本trait提供了标准化的弹幕记录方式。\n///\n/// - try_new: 尝试使用给定的地址初始化弹幕记录器，None地址可以被终端记录器接受，其他必须有文件地址。\n/// - path: 获取弹幕记录的地址，输出到终端为None。\n/// - init: 初始化弹幕记录器，如创建文件，创建表头，创建文件格式信息（如BOM头等）。\n/// - formatter: 格式化弹幕，将弹幕转换为字符串。\n/// - record: 记录弹幕（自动调用自带的formatter函数，所以入参为`&DanmuBody`）。\npub trait DanmuRecorder: Send + Sync {\n    fn try_new(path: Option<PathBuf>) -> Result<Self>\n    where\n        Self: Sized;\n\n    fn path(&self) -> Option<&PathBuf>;\n\n    fn init(&self) -> Result<()> {\n        let path = self.path().ok_or_else(|| {\n            SeamDanmuError::Path(\"Path does not exist or failed to open\".to_owned())\n        })?;\n        File::create(path)?;\n        Ok(())\n    }\n\n    fn formatter(&self, danmu: &DanmuBody) -> String {\n        format!(\n            \"{}{}    {}\",\n            danmu.user.yellow(),\n            \":\".yellow(),\n            danmu.content.green().bold()\n        )\n    }\n\n    fn record(&self, danmu: &DanmuBody) -> Result<()> {\n        let path = self.path().ok_or_else(|| {\n            SeamDanmuError::Path(\"Path does not exist or failed to open\".to_owned())\n        })?;\n        let mut file = OpenOptions::new().append(true).open(path)?;\n        file.write_all(self.formatter(danmu).as_bytes())?;\n        file.write_all(b\"\\n\")?;\n        Ok(())\n    }\n}\n\n/// CSV弹幕记录器。\npub struct Csv {\n    path: PathBuf,\n}\n\nimpl DanmuRecorder for Csv {\n    fn try_new(path: Option<PathBuf>) -> Result<Self> {\n        let file_stem = path.ok_or_else(|| {\n            SeamDanmuError::Path(\"初始化CSV弹幕记录器时未指定文件地址\".to_owned())\n        })?;\n        let path = file_stem.with_extension(\"csv\");\n        Ok(Self { path })\n    }\n\n    fn path(&self) -> Option<&PathBuf> {\n        Some(&self.path)\n    }\n\n    /// 初始化csv文件\n    /// - 添加BOM头\n    /// - 添加表头\n    fn init(&self) -> Result<()> {\n        let mut file = File::create(&self.path)?;\n        let mut init_info: Vec<u8> = vec![0xEF, 0xBB, 0xBF];\n        init_info.extend(b\"user, content\\n\");\n        file.write_all(&init_info)?;\n        Ok(())\n    }\n\n    fn formatter(&self, danmu: &DanmuBody) -> String {\n        format!(\"{}, {}\", danmu.user, danmu.content)\n    }\n}\n\npub struct Terminal;\n\nimpl DanmuRecorder for Terminal {\n    fn try_new(_path: Option<PathBuf>) -> Result<Self> {\n        Ok(Self)\n    }\n\n    fn path(&self) -> Option<&PathBuf> {\n        None\n    }\n\n    fn init(&self) -> Result<()> {\n        println!(\"即将在终端输出弹幕：\");\n        Ok(())\n    }\n\n    fn record(&self, danmu: &DanmuBody) -> Result<()> {\n        println!(\"{}\", &self.formatter(danmu));\n        Ok(())\n    }\n}\n\n/// 标准弹幕格式\n// TODO: 时间戳\npub struct DanmuBody {\n    pub user: String,\n    pub content: String,\n}\n\nimpl DanmuBody {\n    pub fn new(user: String, content: String) -> Self {\n        Self { user, content }\n    }\n}\n\n/// 基于websocket的标准弹幕工作流。\n///\n/// 本函数将会运行websocket长连接，并按指定方式记录弹幕。\n///\n//\n// # 本函数接管的工作流\n//\n// 1. 连接websocket服务器。\n// 2. 发送初始化消息。\n// 3. 维持心跳/接收websocket返回的消息。\n//\n// # 本函数未接管的工作\n//\n// 1. 检查recorder选项是否支持。\n// 2. 生成websocket使用的初始化消息。\n// 3. 生成心跳消息。\n// 4. 解码并按照recorder的要求记录弹幕。\n//\n// # 本函数的参数设计\n//\n// - recorder_checker: 检查recorder选项是否支持，不支持请返回错误。\n//\n// - init_msg_generator: 生成初始化消息，返回一个Vec<Vec<u8>>，每个Vec<u8>为一条消息。\n//   生成的消息将逐条发送给服务器以供初始化websocket。\n//\n// - heart_beat_msg_generator: 生成心跳消息，返回一个Vec<u8>，为一条消息。\n//   生成的消息将按照`heart_beat_interval`的间隔发送给服务器以保持websocket长连接。\n//\n// - heart_beat_interval: 心跳间隔，单位为秒。\n//\n// - decode_and_record_danmu: 解码并按照recorder的要求记录弹幕。\n//\n// - 特别说明：heart_beat与decode_and_record_danmu将在同一线程异步并行。\npub async fn websocket_danmu_work_flow<B>(\n    room_id: &str,\n    url: &str,\n    recorder: Vec<&dyn DanmuRecorder>,\n    init_msg_generator: fn(&str) -> Vec<Vec<u8>>,\n    is_closed_room: impl Fn() -> B,\n    heart_beat_msg_generator: fn() -> Vec<u8>,\n    heart_beat_interval: u64,\n    decode_and_record_danmu: fn(&[u8]) -> Result<Vec<DanmuBody>>,\n) -> Result<()>\nwhere\n    B: Future<Output = bool>,\n{\n    // 初始化websocket连接\n    let reg_datas = init_msg_generator(room_id);\n    let (mut ws, _) = tokio_tungstenite::connect_async(url).await?;\n    for data in reg_datas {\n        Pin::new(&mut ws).start_send(Message::Binary(data))?;\n    }\n\n    // 分开websocket的读写\n    let (mut write, mut read) = ws.split();\n\n    // 异步执行心跳机制和弹幕获取\n    // 需要检测直播间是否关闭，如果关闭则停止心跳机制和弹幕获取\n    tokio::select! {\n        _ = closed_room_checker(is_closed_room) => { }\n        _ = heart_beat(&mut write, heart_beat_msg_generator, heart_beat_interval) => { println!(\"websocket已关闭\"); }\n        e = fetch_danmu(&mut read, decode_and_record_danmu, recorder) => { e?; }\n    }\n\n    Ok(())\n}\n\n// 检测直播间是否关闭\nasync fn closed_room_checker<B>(is_closed_room: impl Fn() -> B)\nwhere\n    B: Future<Output = bool>,\n{\n    loop {\n        match is_closed_room().await {\n            // 间隔10秒检测一次直播间是否关闭\n            true => tokio::time::sleep(tokio::time::Duration::from_secs(10)).await,\n            false => {\n                println!(\"直播间已关闭\");\n                break;\n            }\n        }\n    }\n}\n\n// 心跳机制\nasync fn heart_beat(\n    ws_write: &mut SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>,\n    heart_beat_msg_generator: fn() -> Vec<u8>,\n    heart_beat_interval: u64,\n) {\n    loop {\n        let msg = heart_beat_msg_generator();\n        if Pin::new(&mut *ws_write)\n            .send(Message::Binary(msg))\n            .await\n            .is_ok()\n        {\n            tokio::time::sleep(tokio::time::Duration::from_secs(heart_beat_interval)).await;\n        } else {\n            let short_rebeat_interval = if heart_beat_interval / 10 > 3 {\n                heart_beat_interval / 10\n            } else {\n                3\n            };\n            tokio::time::sleep(tokio::time::Duration::from_secs(short_rebeat_interval)).await;\n        }\n    }\n}\n\n// 解码并记录弹幕\nasync fn fetch_danmu(\n    ws_read: &mut SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>,\n    decode_and_record_danmu: fn(&[u8]) -> Result<Vec<DanmuBody>>,\n    recorder: Vec<&dyn DanmuRecorder>,\n) -> Result<()> {\n    // 初始化recorder\n    for r in recorder.iter() {\n        r.init()?;\n    }\n\n    let ws_to_stdout = {\n        ws_read.for_each(|message| async {\n            let data = message.unwrap().into_data();\n            let msgs = decode_and_record_danmu(&data).unwrap();\n            for msg in msgs.iter() {\n                for r in recorder.iter() {\n                    if let Err(e) = r.record(msg) {\n                        println!(\"记录弹幕失败: {e}\");\n                        println!(\"弹幕内容: {:?}\", msg.content);\n                    }\n                }\n            }\n        })\n    };\n\n    ws_to_stdout.await;\n    Ok(())\n}\n\n// 为没有实现弹幕功能的直播平台添加默认空白实现\n#[macro_export]\nmacro_rules! default_danmu_client {\n    ($name: ident) => {\n        use paste::paste;\n\n        paste! {\n            use async_trait::async_trait;\n            use $crate::{DanmuTrait, DanmuRecorder};\n\n            pub struct Danmu;\n\n            #[async_trait]\n            impl DanmuTrait for Danmu {\n                async fn start(_rid: &str, _recorder: Vec<&dyn DanmuRecorder>) -> Result<()> {\n                    println!(\"该直播平台暂未实现弹幕功能。\");\n                    Ok(())\n                }\n            }\n        }\n    };\n}\n"
  },
  {
    "path": "crates/gui/.eslintrc.json",
    "content": "{\n    \"env\": {\n        \"browser\": true,\n        \"es2021\": true\n    },\n    \"parser\": \"@typescript-eslint/parser\",\n    \"plugins\": [\"@typescript-eslint\", \"solid\", \"simple-import-sort\"],\n    \"extends\": [\n        \"eslint:recommended\",\n        \"plugin:@typescript-eslint/recommended\",\n        \"plugin:solid/typescript\"\n    ],\n    \"parserOptions\": {\n        \"ecmaVersion\": \"latest\",\n        \"sourceType\": \"module\"\n    },\n    \"ignorePatterns\": [\"src/assets\", \"src/css\", \"src/*.css\"],\n    \"rules\": {\n        \"semi\": [\"error\", \"never\"],\n        \"simple-import-sort/imports\": \"error\",\n        \"simple-import-sort/exports\": \"error\"\n    }\n}\n"
  },
  {
    "path": "crates/gui/.gitignore",
    "content": "node_modules\ndist\ndata"
  },
  {
    "path": "crates/gui/.vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"tauri-apps.tauri-vscode\", \"rust-lang.rust-analyzer\"]\n}\n"
  },
  {
    "path": "crates/gui/CHANGELOG.md",
    "content": "# Changelog\n\n## [0.1.8]\n\n**请重新获取最新的抖音 cookie**\n\n### 修复\n\n-   修复 douyin 直播源获取\n\n### 更新\n\n-   douyu 支持 直播间封面, 主播名,主播头像获取\n\n## [0.1.7]\n\n### 更新\n\n-   修复抖音, 获取最高清晰度\n\n## [0.1.6]\n\n### 更新\n\n-   目前所有支持平台,房间名获取均可用\n\n## [0.1.5]\n\n### 修复\n\n-   斗鱼获取最清晰线路\n\n### 更新\n\n-   斗鱼在设置 cookie 的情况下尝试获取 备用线路\n\n## [0.1.4]\n\n### 更新\n\n-   新增播放按钮\n    -   请自行安装播放器, 请确认它可以通过命令行+链接打开视频文件\n    -   需要配置 `play.bin`\n    -   获取成功后点击直接播放\n\n## [0.1.3]\n\n### 修复\n\n-   快手代号错误导致的闪退\n\n### 更新\n\n-   抖音,快手直播修复, 但需要新增配置文件, 详情请查看 README 配置模块\n\n## [0.1.2]\n\n### 修复\n\n-   虎牙直播\n\n## [0.1.1]\n\n### 更新\n\n-   调整样式\n-   新增发布 action\n"
  },
  {
    "path": "crates/gui/README.md",
    "content": "<p align=\"center\">\n    <img src=\"../../assets/icon.png\" style=\"width: 150px;\" alt=\"Seam\" />\n</p>\n\n<h2 align=\"center\">\n  Seam\n</h2>\n\n目前提供了简单的 GUI 界面, 进一步降低使用门槛\n\n如果你是 win11, 或 win10 以下但安装过 webview2 可以直接使用, 否则你应该安装它, 下载链接: [WebView2](https://developer.microsoft.com/zh-cn/microsoft-edge/webview2/#download-section)\n\n当前 GUI 界面 仅为早期版本, 后期会进行较大修改, 主播头像，直播封面，主播名称，全平台订阅，开播通知，自动录播 都会有的.\n\n使用中出现任何问题都可以提 issue, 或加入 TG 群进行反馈: [Telegram](https://t.me/seam_rust)\n\n下载链接: [Releases](https://github.com/Borber/seam/releases) 自行下载最新 Seam GUI 版本\n"
  },
  {
    "path": "crates/gui/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"utf-8\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n        <meta name=\"theme-color\" content=\"#000000\" />\n        <link rel=\"icon\" type=\"image/svg+xml\" href=\"/src/assets/logo.svg\" />\n        <meta name=\"referrer\" content=\"no-referrer\" />\n        <title>Seam</title>\n    </head>\n\n    <body>\n        <div id=\"root\"></div>\n        <script src=\"/src/index.tsx\" type=\"module\"></script>\n    </body>\n</html>\n"
  },
  {
    "path": "crates/gui/package.json",
    "content": "{\n    \"name\": \"seam\",\n    \"version\": \"0.1.4\",\n    \"description\": \"\",\n    \"scripts\": {\n        \"start\": \"vite\",\n        \"dev\": \"vite\",\n        \"build\": \"vite build\",\n        \"serve\": \"vite preview\",\n        \"tauri\": \"tauri\",\n        \"fix\": \"eslint --fix src/**\"\n    },\n    \"license\": \"MIT\",\n    \"dependencies\": {\n        \"@solidjs/router\": \"^0.8.3\",\n        \"@tauri-apps/api\": \"^1.5.1\",\n        \"solid-js\": \"^1.8.3\",\n        \"solid-spinner\": \"^0.2.0\",\n        \"solid-toast\": \"^0.5.0\",\n        \"solid-transition-group\": \"^0.2.3\"\n    },\n    \"devDependencies\": {\n        \"@tauri-apps/cli\": \"^1.5.6\",\n        \"@types/node\": \"^20.8.8\",\n        \"@typescript-eslint/eslint-plugin\": \"^6.9.0\",\n        \"@typescript-eslint/parser\": \"^6.9.0\",\n        \"eslint\": \"^8.52.0\",\n        \"eslint-plugin-simple-import-sort\": \"^10.0.0\",\n        \"eslint-plugin-solid\": \"^0.13.0\",\n        \"typescript\": \"^5.2.2\",\n        \"vite\": \"^4.5.0\",\n        \"vite-plugin-solid\": \"^2.7.2\"\n    }\n}\n"
  },
  {
    "path": "crates/gui/src/App.css",
    "content": ".not-draggable {\n  user-select: none;\n}\n\n.container {\n  width: 100%;\n  height: calc(100% - 40px);\n  display: flex;\n  flex-direction: row;\n}\n\n\ninput,\nbutton {\n  text-align: center;\n  border-radius: 8px;\n  border: 1px solid transparent;\n  font-weight: 500;\n  font-family: inherit;\n  color: #ffffff;\n  background-color: #0f0f0fc9;\n  transition: all 0.3s ease;\n  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);\n}\n\nbutton {\n  cursor: pointer;\n}\n\ninput:hover,\nbutton:hover {\n  filter: brightness(1.4);\n}\n\n\n\n.content {\n  flex: 1;\n  height: 100%;\n  background-color: #373737;\n  overflow: auto;\n}\n\n/* 美化滚动条 */\n::-webkit-scrollbar {\n  width: 0;\n}\n\n::-webkit-scrollbar-track {\n  background-color: #3f3f3f;\n}\n\n::-webkit-scrollbar-thumb {\n  background-color: #555;\n}"
  },
  {
    "path": "crates/gui/src/App.tsx",
    "content": "import \"./App.css\"\nimport \"./css/TopBar.css\"\n\nimport { useRoutes } from \"@solidjs/router\"\nimport { lazy, onMount } from \"solid-js\"\nimport { Toaster } from \"solid-toast\"\n\nimport Control from \"./components/Control\"\nimport SideBar from \"./components/SideBar\"\nimport TopBar from \"./components/TopBar\"\n\nconst App = () => {\n    onMount(async () => {\n        // 生产环境, 全局取消右键菜单;\n        if (!import.meta.env.DEV) {\n            document.oncontextmenu = (event) => {\n                event.preventDefault()\n            }\n        }\n\n        // 避免窗口闪烁, 等待500ms再显示窗口\n        // 这个该死的bug什么时候才能修\n        setTimeout(() => {\n            setupWindow()\n        }, 500)\n    })\n\n    const setupWindow = async () => {\n        const appWindow = (await import(\"@tauri-apps/api/window\")).appWindow\n        appWindow.show()\n    }\n\n    const routes = [\n        { path: \"/\", component: lazy(() => import(\"./pages/Home\")) },\n        { path: \"/good\", component: lazy(() => import(\"./pages/Good\")) },\n        { path: \"/chart\", component: lazy(() => import(\"./pages/Chart\")) },\n        { path: \"/setting\", component: lazy(() => import(\"./pages/Setting\")) },\n    ]\n\n    const Routes = useRoutes(routes)\n\n    return (\n        <>\n            <Control maximize={false} />\n            <TopBar />\n            <div class=\"container  not-draggable\">\n                <SideBar />\n                <div class=\"content\">\n                    <Routes />\n                </div>\n            </div>\n            <Toaster\n                position=\"bottom-center\"\n                gutter={8}\n                toastOptions={{\n                    className: \"\",\n                    duration: 5000,\n                    style: {\n                        background: \"#0f0f0fc9\",\n                        color: \"#fff\",\n                    },\n                }}\n            />\n        </>\n    )\n}\n\nexport default App\n"
  },
  {
    "path": "crates/gui/src/components/Control.tsx",
    "content": "import \"../css/Control.css\"\n\nimport { appWindow } from \"@tauri-apps/api/window\"\nimport { Show } from \"solid-js\"\n\n// TODO 修复最小化, 隐藏时的渲染bug\n\nconst Minimize = () => {\n    return (\n        <svg aria-hidden=\"false\" width=\"10\" height=\"10\" viewBox=\"0 0 12 12\">\n            <rect fill=\"currentColor\" width=\"10\" height=\"1\" x=\"1\" y=\"6\" />\n        </svg>\n    )\n}\n\nconst Maximize = () => {\n    return (\n        <svg aria-hidden=\"false\" width=\"10\" height=\"10\" viewBox=\"0 0 12 12\">\n            <rect\n                width=\"9\"\n                height=\"9\"\n                x=\"1.5\"\n                y=\"1.5\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n            />\n        </svg>\n    )\n}\n\nconst Close = () => {\n    return (\n        <svg aria-hidden=\"false\" width=\"10\" height=\"10\" viewBox=\"0 0 12 12\">\n            <polygon\n                fill=\"currentColor\"\n                fill-rule=\"evenodd\"\n                points=\"11 1.576 6.583 6 11 10.424 10.424 11 6 6.583 1.576 11 1 10.424 5.417 6 1 1.576 1.576 1 6 5.417 10.424 1\"\n            />\n        </svg>\n    )\n}\n\nexport interface BarProps {\n    minimize?: boolean\n    maximize?: boolean\n    close?: boolean\n}\n\nconst defaultProps: BarProps = {\n    minimize: true,\n    maximize: true,\n    close: true,\n}\n\nconst Control = (props: BarProps) => {\n    const setting = { ...defaultProps, ...props }\n    return (\n        <>\n            <div class=\"control\">\n                <Show when={setting.minimize}>\n                    <div\n                        class=\"control-item\"\n                        title=\"最小化\"\n                        onClick={() => appWindow.minimize()}>\n                        {Minimize()}\n                    </div>\n                </Show>\n                <Show when={setting.maximize}>\n                    <div class=\"control-item\" title=\"最大化\">\n                        {Maximize()}\n                    </div>\n                </Show>\n                <Show when={setting.close}>\n                    <div\n                        class=\"control-item control-item-close\"\n                        onClick={() => appWindow.close()}\n                        title=\"关闭\">\n                        {Close()}\n                    </div>\n                </Show>\n            </div>\n        </>\n    )\n}\n\nexport default Control\n"
  },
  {
    "path": "crates/gui/src/components/GoodItem.tsx",
    "content": "import \"../css/GoodItem.css\"\n\nimport { AddIcon, CopyIcon, PlayIcon } from \"../icon/icon\"\n\ninterface Url {\n    format: string;\n    url: string;\n}\n\ninterface GoodItemProps {\n    live: string;\n    rid: string;\n    title: string;\n    anchor: string;\n    urls: Url[];\n    img?: string;\n}\n\nconst GoodItem = (props: GoodItemProps) => {\n    return (\n        <div class=\"good-item\">\n            <img class=\"good-img\" src={props.img ?? \"/src/assets/no_img.png\"} />\n            <div class=\"good-panel\">\n                <div class=\"good-title\">{props.title}</div>\n                <div class=\"good-info\">快来看</div>\n                <div class=\"good-control\">\n                    <button class=\"good-control-btn\">\n                        <AddIcon size={15} />\n                    </button>\n                    <button class=\"good-control-btn\">\n                        <CopyIcon size={15} />\n                    </button>\n                    <button class=\"good-control-btn\">\n                        <PlayIcon size={15} />\n                    </button>\n                </div>\n            </div>\n        </div>\n    )\n}\n\nexport default GoodItem\n"
  },
  {
    "path": "crates/gui/src/components/Live.tsx",
    "content": "import \"../css/Live.css\"\n\nimport { CopyIcon, PlayIcon } from \"../icon/icon\"\n\ninterface Url {\n    format: string;\n    url: string;\n}\n\ninterface LiveProps {\n    live: string;\n    rid: string;\n    title: string;\n    anchor: string;\n    urls: Url[];\n    img?: string;\n}\n\n// TODO 减少内阴影的使用, 按钮的样式应该简洁一些\n\nconst Live = (props: LiveProps) => {\n    return (\n        <div class=\"live\">\n            <img class=\"live-img\" src={props.img ?? \"/src/assets/no_img.png\"} />\n            <div class=\"live-panel\">\n                <div class=\"live-title\">{props.title}</div>\n                <div class=\"live-control\">\n                    <button class=\"live-control-btn\">\n                        <CopyIcon size={15} />\n                    </button>\n                    <button class=\"live-control-btn\">\n                        <PlayIcon size={15} />\n                    </button>\n                </div>\n            </div>\n        </div>\n    )\n}\n\nexport default Live\n"
  },
  {
    "path": "crates/gui/src/components/Panel.tsx",
    "content": "import \"../css/Panel.css\"\n\nimport { Accessor, For, Setter } from \"solid-js\"\n\nimport allLives from \"../model/Live\"\n\ninterface LiveProps {\n    flag: Setter<boolean>;\n    live: Accessor<string>;\n    setLive: Setter<string>;\n}\n\nconst Panel = (props: LiveProps) => {\n    return (\n        <div\n            class=\"not-draggable panel\"\n            onMouseEnter={() => props.flag(true)}\n            onMouseLeave={() => props.flag(false)}>\n            <div class=\"panel-container\">\n                <For each={allLives()}>\n                    {(item) => (\n                        <div\n                            class=\"panel-item\"\n                            classList={{\n                                \"panel-item-activate\":\n                                    props.live() === item.cmd,\n                            }}\n                            onClick={() => props.setLive(item.cmd)}>\n                            {item.name}\n                        </div>\n                    )}\n                </For>\n            </div>\n        </div>\n    )\n}\n\nexport default Panel\n"
  },
  {
    "path": "crates/gui/src/components/SideBar.tsx",
    "content": "import \"../css/SideBar.css\"\n\nimport { useLocation } from \"@solidjs/router\"\nimport { createMemo } from \"solid-js\"\n\nimport { ChartIcon, GoodIcon, HomeIcon, SettingIcon } from \"../icon/icon\"\nimport SideItem from \"./SideItem\"\n\nconst SideBar = () => {\n    const pathname = createMemo(() => {\n        return useLocation().pathname\n    })\n    return (\n        <div data-tauri-drag-region class=\"side-bar\">\n            <SideItem path=\"/\" pathname={pathname}>\n                <HomeIcon size={30} />\n            </SideItem>\n            <SideItem path=\"/good\" pathname={pathname}>\n                <GoodIcon size={30} />\n            </SideItem>\n            <SideItem path=\"/chart\" pathname={pathname}>\n                <ChartIcon size={30} />\n            </SideItem>\n            <SideItem path=\"/setting\" pathname={pathname} bottom={true}>\n                <SettingIcon size={30} />\n            </SideItem>\n        </div>\n    )\n}\n\nexport default SideBar\n"
  },
  {
    "path": "crates/gui/src/components/SideItem.tsx",
    "content": "import \"../css/SideItem.css\"\n\nimport { A } from \"@solidjs/router\"\nimport { JSX } from \"solid-js/jsx-runtime\"\n\nconst SideItem = (props: {\n    children: JSX.Element;\n    path: string;\n    bottom?: boolean;\n    pathname: () => string;\n}) => {\n    return (\n        <A href={props.path} class=\"side-link\">\n            <div\n                class=\"side-item\"\n                classList={{\n                    \"side-item-bottom\": props.bottom,\n                    \"side-item-selected\": props.pathname() == props.path,\n                }}>\n                {props.children}\n            </div>\n        </A>\n    )\n}\n\nexport default SideItem\n"
  },
  {
    "path": "crates/gui/src/components/TopBar.tsx",
    "content": "import { invoke } from \"@tauri-apps/api\"\nimport { createSignal } from \"solid-js\"\nimport { Spinner, SpinnerType } from \"solid-spinner\"\nimport toast from \"solid-toast\"\nimport { Transition } from \"solid-transition-group\"\n\nimport { AddIcon, SyncIcon } from \"../icon/icon\"\nimport { Resp } from \"../model/Resp\"\nimport Panel from \"./Panel\"\n\nconst TopBar = () => {\n    const [refresh, setRefresh] = createSignal(false)\n    const [rid, setRid] = createSignal(\"\")\n    const [onInput, setInput] = createSignal(false)\n    const [onPanel, setPanel] = createSignal(false)\n    const [live, setLive] = createSignal(\"bili\")\n\n    // TODO 添加成功后应该发布事件, 让 Chart 页面刷新,\n    // 当然如果Chart没有接收到消息,说明当前并没有打开Chart页面, 那么就不需要刷新了\n\n    const add = async () => {\n        await invoke<Resp<boolean>>(\"subscribe_add\", {\n            live: live(),\n            rid: rid(),\n        }).then((p) => {\n            if (p.code === 0) {\n                console.log(p.data)\n                toast.success(\"添加成功\")\n            } else {\n                toast.error(p.msg)\n            }\n        })\n    }\n\n    return (\n        <div data-tauri-drag-region class=\"top-bar\">\n            <button class=\"top-bar-btn\">\n                <div class=\"refresh\" onClick={() => setRefresh(!refresh())}>\n                    {refresh() ? (\n                        <Spinner\n                            type={SpinnerType.oval}\n                            width={16}\n                            height={16}\n                        />\n                    ) : (\n                        <SyncIcon size={20} />\n                    )}\n                </div>\n            </button>\n            <input\n                placeholder=\"房间号\"\n                class=\"top-bar-input\"\n                onFocusIn={() => {\n                    setInput(true)\n                }}\n                onFocusOut={() => {\n                    setInput(false)\n                }}\n                onInput={async (e) => {\n                    setRid(e.target.value)\n                }}\n            />\n            <button\n                class=\"top-bar-btn\"\n                onClick={async () => {\n                    await add()\n                }}>\n                <AddIcon size={16} />\n            </button>\n            <Transition name=\"slide-fade\">\n                {(onInput() || onPanel()) && (\n                    <Panel flag={setPanel} live={live} setLive={setLive} />\n                )}\n            </Transition>\n        </div>\n    )\n}\n\nexport default TopBar\n"
  },
  {
    "path": "crates/gui/src/css/Chart.css",
    "content": ".chart {\n    width: 60%;\n    height: 100%;\n    margin: 0 auto;\n    color: #fff;\n    padding: 20px;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n}\n\n.chart-kind-container {\n    flex-shrink: 0;\n    display: flex;\n    flex-wrap: wrap;\n    border-radius: 8px;\n    align-content: space-between;\n    overflow: hidden;\n    border-radius: 8px;\n    background-color: #0f0f0faf;\n    backdrop-filter: blur(10px);\n    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);\n\n}\n\n.chart-kind-item {\n    flex: 1;\n    font-size: 16px;\n    font-weight: bold;\n    color: #fff;\n    padding: 5px 8px;\n    cursor: pointer;\n    text-align: center;\n    box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6);\n    white-space: nowrap;\n}\n\n\n.chart-kind-item:not(.chart-kind-item-activate):hover {\n    background-color: #5f5f5f;\n}\n\n.chart-kind-item-activate {\n    background-color: #ff8c00;\n    box-shadow: inset 0.3px 0.3px 3px 0.9px rgba(260, 260, 260, 0.9);\n}\n\n.chart-table {\n    margin: 20px 0;\n    border-radius: 8px;\n    overflow: hidden;\n    background-color: #0f0f0faf;\n    backdrop-filter: blur(10px);\n    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);\n}\n\n.chart-table th {\n    padding: 8px;\n    text-align: center;\n    font-size: 16px;\n    font-weight: bold;\n    box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6);\n    white-space: nowrap;\n}\n\n.chart-table td {\n    padding: 5px;\n    text-align: center;\n    box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6);\n    white-space: nowrap;\n}\n\n.chart-table tr:hover {\n    background-color: #5f5f5f;\n}\n\n.chart-table-title-1 {\n    text-align: center;\n    width: 80px;\n}\n\n.chart-table-title-2 {\n    text-align: center;\n    width: 140px;\n}\n\n.chart-table-title-3 {\n    text-align: center;\n    width: 140px;\n}\n\n.chart-table-title-4 {\n    text-align: center;\n    width: 100px;\n}\n\n.chart-table button {\n    padding: 5px 8px;\n}\n\n.chart-separator {\n    flex-shrink: 0;\n    width: 100%;\n    height: 10px;\n}"
  },
  {
    "path": "crates/gui/src/css/Control.css",
    "content": ".control {\n    position: absolute;\n    right: 0;\n    display: flex;\n    text-align: center;\n    justify-content: center;\n}\n\n.control-item {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 40px;\n    height: 40px;\n    color: #5b595c;\n    box-sizing: border-box;\n    padding: 10px;\n}\n\n.control-item:hover {\n    color: #fff;\n    background-color: #373737;\n}\n\n.control-item.control-item-close:hover {\n    color: #fff;\n    background-color: #cd1a2b;\n}"
  },
  {
    "path": "crates/gui/src/css/Good.css",
    "content": ".good {\n    color: #fff;\n    display: flex;\n    flex-wrap: wrap;\n}"
  },
  {
    "path": "crates/gui/src/css/GoodItem.css",
    "content": ".good-item {\n    width: 460px;\n    height: 300px;\n    position: relative;\n    background-color: aliceblue;\n}\n\n.good-img {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n    object-position: center;\n}\n\n.good-panel {\n    position: absolute;\n    bottom: 0;\n    width: 100%;\n    height: 100px;\n    background: #1d1d1d6d;\n    backdrop-filter: blur(10px);\n    /* box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6); */\n}\n\n.good-title {\n    padding: 10px 10px 0 10px;\n    font-size: 16px;\n    font-weight: bold;\n}\n\n.good-info {\n    font-size: 14px;\n    padding: 5px 0 0 10px;\n\n}\n\n.good-control {\n    position: absolute;\n    bottom: 0;\n    right: 0;\n    margin-bottom: 5px;\n}\n\n.good-control-btn {\n    width: 30px;\n    height: 30px;\n    margin-right: 5px;\n}"
  },
  {
    "path": "crates/gui/src/css/Home.css",
    "content": ".home {\n    color: #fff;\n    display: flex;\n    flex-wrap: wrap;\n}"
  },
  {
    "path": "crates/gui/src/css/Live.css",
    "content": ".live {\n    width: 230px;\n    height: 150px;\n    position: relative;\n}\n\n.live-img {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n    object-position: center;\n}\n\n.live-panel {\n    position: absolute;\n    bottom: 0;\n    width: 100%;\n    height: 60px;\n    background: #1d1d1d6d;\n    backdrop-filter: blur(10px);\n    /* box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6); */\n}\n\n.live-title {\n    padding: 5px;\n    font-size: 14px;\n    font-weight: bold;\n}\n\n.live-control {\n    text-align: right;\n}\n\n.live-control-btn {\n    width: 30px;\n    height: 30px;\n    margin-right: 5px;\n}"
  },
  {
    "path": "crates/gui/src/css/Panel.css",
    "content": ".panel {\n    width: 400px;\n    position: absolute;\n    z-index: 10;\n    top: 40px;\n    left: 300px;\n    background-color: #0f0f0fa0;\n    backdrop-filter: blur(10px);\n    border-radius: 8px;\n    box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6);\n\n}\n\n.panel-container {\n    display: flex;\n    flex-wrap: wrap;\n    border-radius: 8px;\n    align-content: space-between;\n    overflow: hidden;\n}\n\n.panel-item {\n    flex: 1;\n    font-size: 14px;\n    font-weight: bold;\n    color: #fff;\n    padding: 6px 10px;\n    cursor: pointer;\n    text-align: center;\n    box-shadow: inset 0.3px 0.3px 3px 0.1px rgba(160, 160, 160, 0.6);\n    white-space: nowrap;\n}\n\n.panel-item:not(.panel-item-activate):hover {\n    background-color: #5d5d5d5d;\n}\n\n.panel-item-activate {\n    background-color: #ff8c00;\n    box-shadow: inset 0.3px 0.3px 3px 0.9px rgba(260, 260, 260, 0.9);\n}"
  },
  {
    "path": "crates/gui/src/css/Setting.css",
    "content": ".setting {\n    width: 60%;\n    height: 100%;\n    margin: 0 auto;\n    color: #fff;\n    padding: 20px;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n}\n\n.setting-title {\n    width: 100%;\n    font-size: 20px;\n    font-weight: bold;\n    padding: 5px;\n    border-bottom: 1px solid #fff;\n    margin-bottom: 5px;\n}\n\n.setting-item {\n    width: 100%;\n    display: flex;\n    justify-content: space-between;\n    padding: 10px 5px;\n    font-size: 16px;\n    font-weight: bold;\n}\n\n.setting-item:hover {\n    background-color: #525252;\n}\n\n.setting-item-title {\n    line-height: 30px;\n}\n\n.setting-input {\n    width: 150px;\n    height: 30px;\n}\n\n.setting-btn {\n    width: 50px;\n    height: 30px;\n    padding: 5px;\n    margin-left: 10px;\n}\n\n.setting-arg {\n    width: 210px;\n}\n\n.setting-textarea {\n    width: 100%;\n    min-height: 100px;\n    border-radius: 8px;\n    border: 1px solid transparent;\n    font-weight: 500;\n    font-family: inherit;\n    color: #ffffff;\n    background-color: #0f0f0fc9;\n    padding: 10px;\n    transition: all 0.3s ease;\n    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);\n}\n\n.setting-save {\n    width: 100px;\n    height: 30px;\n    padding: 5px;\n    margin-top: 30px;\n}"
  },
  {
    "path": "crates/gui/src/css/SideBar.css",
    "content": ".side-bar {\n    width: 80px;\n    height: 100%;\n    background-color: #1e1e1e;\n}"
  },
  {
    "path": "crates/gui/src/css/SideItem.css",
    "content": ".side-link {\n    cursor: default;\n}\n\n.side-item {\n    width: 80px;\n    height: 75px;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n}\n\n.side-item:not(.side-item-selected):hover {\n    background-color: #373737;\n}\n\n.side-item-selected {\n    background-color: #575757;\n}\n\n.side-item-bottom {\n    position: absolute;\n    bottom: 0;\n}"
  },
  {
    "path": "crates/gui/src/css/TopBar.css",
    "content": ".top-bar {\n    display: flex;\n    justify-content: center;\n    align-items: center;\n}\n\n.refresh {\n    height: 20px;\n    width: 20px;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n}\n\n.top-bar-input {\n    width: 400px;\n    height: 30px;\n    margin: 5px;\n    font-weight: bold;\n    font-size: 14px;\n}\n\n.top-bar-btn {\n    width: 30px;\n    height: 30px;\n    padding: 0;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n}\n\n.slide-fade-enter-active,\n.slide-fade-exit-active {\n    transition: opacity 0.3s, transform 0.3s;\n}\n\n.slide-fade-enter,\n.slide-fade-exit-to {\n    opacity: 0;\n}"
  },
  {
    "path": "crates/gui/src/icon/icon.tsx",
    "content": "interface IconProps {\n    size: number\n}\n\nconst HomeIcon = (props: IconProps) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 100 100\"\n            width={props.size}\n        >\n            <path\n                d=\"M19.5,95c-3.584,0-6.5-2.916-6.5-6.5V50.822C12.507,50.939,11.503,47,11,47c-1.209,0-1.973,0.635-3,0 c-1.929-1.192-3-0.231-3-2.5v-8c0-2.332,1.259-4.497,3.287-5.65l40.5-23.038c0.978-0.556,2.089-0.85,3.214-0.85 s2.236,0.294,3.214,0.851l40.5,23.038C97.741,32.003,99,34.168,99,36.5v8c0,2.269-1.152,4.336-3.083,5.529 C94.891,50.664,93.709,51,92.501,51c-0.504,0-1.009-0.06-1.501-0.177V88.5c0,3.584-2.916,6.5-6.5,6.5H19.5z\"\n                opacity=\".35\"\n            />\n            <path\n                fill=\"#f2f2f2\"\n                d=\"M17.5,93c-3.584,0-6.5-2.916-6.5-6.5V48.822C10.507,48.939,10.003,49,9.5,49 c-1.209,0-2.391-0.336-3.418-0.971C4.152,46.836,3,44.769,3,42.5v-8c0-2.332,1.259-4.497,3.287-5.65l40.5-23.038 c0.978-0.556,2.089-0.85,3.214-0.85s2.236,0.294,3.214,0.851l40.5,23.038C95.741,30.003,97,32.168,97,34.5v8 c0,2.269-1.152,4.336-3.083,5.529C92.891,48.664,91.709,49,90.501,49c-0.504,0-1.009-0.06-1.501-0.177V86.5 c0,3.584-2.916,6.5-6.5,6.5H17.5z\"\n            />\n            <polygon\n                fill=\"#d9eeff\"\n                points=\"90.5,34.5 50,11.462 9.5,34.5 9.5,42.5 17.5,38.5 17.5,86.5 82.5,86.5 82.5,38.5 90.5,42.5\"\n            />\n            <polygon\n                fill=\"#ff7575\"\n                points=\"9.5,34.5 10,40.981 17.006,37.087 50,18.981 82.985,37.078 90,40.981 90.5,34.5 50,11.462\"\n            />\n            <polygon\n                fill=\"none\"\n                stroke=\"#40396e\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-miterlimit=\"10\"\n                stroke-width=\"3\"\n                points=\"90.5,34.5 50,11.462 9.5,34.5 9.5,42.5 17.5,38.5 17.5,86.5 82.5,86.5 82.5,38.5 90.5,42.5\"\n            />\n            <polygon\n                fill=\"#40396e\"\n                points=\"16,41 17.044,37.069 50,19 82.985,37.078 84,41 50,22.506\"\n                opacity=\".35\"\n            />\n            <rect\n                width=\"62\"\n                height=\"10\"\n                x=\"19\"\n                y=\"75\"\n                fill=\"#70bfff\"\n                opacity=\".35\"\n            />\n            <rect width=\"26\" height=\"35\" x=\"37\" y=\"50\" fill=\"#ff7575\" />\n            <circle cx=\"56.5\" cy=\"68.5\" r=\"2.5\" fill=\"#40396e\" />\n        </svg>\n    )\n}\n\nconst GoodIcon = (props: IconProps) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 100 100\"\n            width={props.size}\n        >\n            <path\n                d=\"M69.508,94c-12.685,0-25.878-1.705-31.755-2.57C35.73,93.074,33.186,94,30.508,94h-10\tc-6.341,0-11.5-5.159-11.5-11.5v-33c0-6.341,5.159-11.5,11.5-11.5h10c1.864,0,3.671,0.458,5.28,1.292\tc1.983-1.945,4.398-4.007,7.188-5.825l5.031-10.449l-0.001-4.021c-0.002-3.209,1.553-6.249,4.158-8.129\tc1.71-1.235,3.717-1.889,5.802-1.889c1.064,0,2.12,0.172,3.139,0.511c5.188,1.726,10.571,7.583,10.898,16.78\tc0.135,3.791-0.432,6.486-0.945,8.857c1.194-0.086,2.451-0.142,3.735-0.142c12.849,0,17.276,5.794,18.729,10.655\tc0.687,2.302,0.742,4.612,0.19,6.774c1.495,2.017,2.309,4.433,2.309,7.006c0,3.142-1.105,6.042-3.165,8.417\tc0.1,0.82,0.15,1.668,0.15,2.541c0,3.672-1.33,6.984-3.667,9.35c-0.133,2.706-0.995,5.259-2.526,7.394\tC83.537,91.686,77.715,94,69.508,94z\"\n                opacity=\".35\"\n            />\n            <path\n                fill=\"#f2f2f2\"\n                d=\"M67.508,92c-12.685,0-25.878-1.705-31.755-2.57C33.73,91.074,31.186,92,28.508,92h-10\tc-6.341,0-11.5-5.159-11.5-11.5v-33c0-6.341,5.159-11.5,11.5-11.5h10c1.864,0,3.671,0.458,5.28,1.292\tc1.983-1.945,4.398-4.007,7.188-5.825l5.031-10.449l-0.001-4.021c-0.002-3.209,1.553-6.249,4.158-8.129\tc1.71-1.235,3.717-1.889,5.802-1.889c1.064,0,2.12,0.172,3.139,0.511c5.188,1.726,10.571,7.583,10.898,16.78\tc0.135,3.791-0.432,6.486-0.945,8.857c1.194-0.086,2.451-0.142,3.735-0.142c12.849,0,17.276,5.794,18.729,10.655\tc0.687,2.302,0.742,4.612,0.19,6.774c1.495,2.017,2.309,4.433,2.309,7.006c0,3.142-1.105,6.042-3.165,8.417\tc0.1,0.82,0.15,1.668,0.15,2.541c0,3.672-1.33,6.984-3.667,9.35c-0.133,2.706-0.995,5.259-2.526,7.394\tC81.537,89.686,75.715,92,67.508,92z\"\n            />\n            <path\n                fill=\"#ffc7a3\"\n                d=\"M87.014,57.455c0-4.18-3.775-5.455-3.775-5.455s2.27-1.994,2.27-6.5c0-11-23.806-4-23.806-4 c-2.539-5.449,1.806-6,1.806-17c0-5.893-3.425-9.836-6.455-10.843c-2.242-0.746-4.548,0.974-4.547,3.337 c0.002,2.211,0.002,4.506,0.002,4.506c-1.019,7.028-9.584,17.807-9.584,17.807C35.373,43.676,33.508,50.5,33.508,50.5v-3 c0-2.761-2.239-5-5-5h-10c-2.761,0-5,2.239-5,5v33c0,2.761,2.239,5,5,5h10c2.761,0,5-2.239,5-5v2c0,0,17.281,3,34,3s13-11,13-11 s4-1,4-6.124c0-3.236-1-4.876-1-4.876S87.014,61.635,87.014,57.455z\"\n            />\n            <path\n                fill=\"#70bfff\"\n                d=\"M29.824,85.5H18.692c-2.863,0-5.184-2.321-5.184-5.184V47.684c0-2.863,2.321-5.184,5.184-5.184 h11.132c2.863,0,5.184,2.321,5.184,5.184v32.632C35.008,83.179,32.687,85.5,29.824,85.5z\"\n            />\n            <path\n                fill=\"none\"\n                stroke=\"#40396e\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-miterlimit=\"10\"\n                stroke-width=\"3\"\n                d=\"M87.522,57.419c0-4.18-4.284-5.418-4.284-5.418s3.346-2.183,2.056-6.5c-2.98-9.975-23.805-4.345-23.778-5.083 c0.287-7.826,2.205-9.911,1.991-15.917c-0.209-5.889-3.425-9.836-6.455-10.843c-2.242-0.746-4.548,0.974-4.547,3.337 c0.002,2.211,0.002,5.506,0.002,5.506l-6.5,13.5c-7.551,4.369-12.5,11.5-12.5,11.5c0-2.761-2.239-5-5-5h-10c-2.761,0-5,2.239-5,5 v33c0,2.761,2.239,5,5,5h10c2.761,0,5-2.239,5-5v2c0,0,17.281,3,34,3s13-11,13-11s4-1,4-6.124c0-3.236-1-4.876-1-4.876 S87.522,61.598,87.522,57.419z\"\n            />\n            <circle cx=\"23.501\" cy=\"75.493\" r=\"3.499\" fill=\"#40396e\" />\n        </svg>\n    )\n}\n\nconst SettingIcon = (props: IconProps) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 100 100\"\n            width={props.size}\n        >\n            <path\n                d=\"M47.5,100c-3.245,0-6.015-2.426-6.443-5.643l-0.684-5.129c-2.237-0.696-4.401-1.592-6.476-2.683 l-4.112,3.145c-1.12,0.86-2.525,1.337-3.946,1.337c-1.736,0-3.368-0.675-4.596-1.903l-6.366-6.365 c-2.295-2.293-2.539-5.968-0.566-8.546l3.143-4.111c-1.091-2.075-1.987-4.238-2.683-6.476l-5.131-0.684 C6.426,62.515,4,59.745,4,56.5v-9c0-3.245,2.426-6.015,5.643-6.443l5.129-0.684c0.696-2.237,1.592-4.401,2.683-6.476l-3.145-4.112 c-1.972-2.577-1.728-6.251,0.569-8.546l6.362-6.362c1.226-1.227,2.859-1.904,4.596-1.904c1.419,0,2.821,0.475,3.948,1.336 l4.113,3.145c2.075-1.09,4.239-1.987,6.476-2.683l0.684-5.131C41.485,6.426,44.255,4,47.5,4h9c3.245,0,6.015,2.426,6.443,5.643 l0.684,5.129c2.237,0.696,4.401,1.592,6.476,2.683l4.112-3.145c1.13-0.863,2.531-1.336,3.948-1.336 c1.737,0,3.37,0.677,4.598,1.906l6.362,6.362c2.295,2.293,2.539,5.968,0.566,8.546l-3.143,4.111 c1.09,2.075,1.987,4.239,2.683,6.476l5.131,0.684C97.574,41.485,100,44.255,100,47.5v9c0,3.245-2.426,6.015-5.643,6.443 l-5.129,0.684c-0.696,2.237-1.592,4.4-2.683,6.476l3.145,4.112c1.972,2.577,1.728,6.251-0.569,8.546l-6.362,6.362 c-1.223,1.227-2.857,1.904-4.597,1.904c-1.42,0-2.824-0.476-3.953-1.341l-4.107-3.141c-2.075,1.09-4.239,1.987-6.476,2.683 l-0.684,5.131C62.515,97.574,59.745,100,56.5,100H47.5z\"\n                opacity=\".35\"\n            />\n            <path\n                fill=\"#f2f2f2\"\n                d=\"M45.5,98c-3.245,0-6.015-2.426-6.443-5.643l-0.684-5.129c-2.237-0.696-4.401-1.592-6.476-2.683 l-4.112,3.145c-1.12,0.86-2.525,1.337-3.946,1.337c-1.736,0-3.368-0.675-4.596-1.903l-6.366-6.365 c-2.295-2.293-2.539-5.968-0.566-8.546l3.143-4.111c-1.091-2.075-1.987-4.238-2.683-6.476l-5.131-0.684 C4.426,60.515,2,57.745,2,54.5v-9c0-3.245,2.426-6.015,5.643-6.443l5.129-0.684c0.696-2.237,1.592-4.401,2.683-6.476l-3.145-4.112 c-1.972-2.577-1.728-6.251,0.569-8.546l6.362-6.362c1.226-1.227,2.859-1.904,4.596-1.904c1.419,0,2.821,0.475,3.948,1.336 l4.113,3.145c2.075-1.09,4.239-1.987,6.476-2.683l0.684-5.131C39.485,4.426,42.255,2,45.5,2h9c3.245,0,6.015,2.426,6.443,5.643 l0.684,5.129c2.237,0.696,4.401,1.592,6.476,2.683l4.112-3.145c1.13-0.863,2.531-1.336,3.948-1.336 c1.737,0,3.37,0.677,4.598,1.906l6.362,6.362c2.295,2.293,2.539,5.968,0.566,8.546l-3.143,4.111 c1.09,2.075,1.987,4.239,2.683,6.476l5.131,0.684C95.574,39.485,98,42.255,98,45.5v9c0,3.245-2.426,6.015-5.643,6.443 l-5.129,0.684c-0.696,2.237-1.592,4.4-2.683,6.476l3.145,4.112c1.972,2.577,1.728,6.251-0.569,8.546l-6.362,6.362 c-1.223,1.227-2.857,1.904-4.597,1.904c-1.42,0-2.824-0.476-3.953-1.341l-4.107-3.141c-2.075,1.09-4.239,1.987-6.476,2.683 l-0.684,5.131C60.515,95.574,57.745,98,54.5,98H45.5z\"\n            />\n            <path\n                fill=\"#9aa2e6\"\n                d=\"M81.979,55.769c-0.839,4.679-2.675,9.009-5.287,12.763l5.835,7.63l-6.364,6.364l-7.63-5.835 c-3.754,2.612-8.085,4.448-12.763,5.287L54.5,91.5h-9l-1.269-9.521c-4.679-0.839-9.009-2.675-12.763-5.287l-7.63,5.835 l-6.364-6.364l5.835-7.63c-2.612-3.754-4.448-8.085-5.287-12.763L8.5,54.5v-9l9.521-1.269c0.839-4.679,2.675-9.009,5.287-12.763 l-5.835-7.63l6.364-6.364l7.63,5.835c3.754-2.612,8.085-4.448,12.763-5.287L45.5,8.5h9l1.269,9.521 c4.679,0.839,9.009,2.675,12.763,5.287l7.63-5.835l6.364,6.364l-5.835,7.63c2.612,3.754,4.448,8.085,5.287,12.763L91.5,45.5v9 L81.979,55.769z M50,35.5c-8.008,0-14.5,6.492-14.5,14.5S41.992,64.5,50,64.5S64.5,58.008,64.5,50S58.008,35.5,50,35.5z\"\n            />\n            <path\n                fill=\"none\"\n                stroke=\"#40396e\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-miterlimit=\"10\"\n                stroke-width=\"3\"\n                d=\"M81.979,55.769c-0.839,4.679-2.675,9.009-5.287,12.763l5.835,7.63l-6.364,6.364l-7.63-5.835c-3.754,2.612-8.085,4.448-12.763,5.287 L54.5,91.5h-9l-1.269-9.521c-4.679-0.839-9.009-2.675-12.763-5.287l-7.63,5.835l-6.364-6.364l5.835-7.63 c-2.612-3.754-4.448-8.085-5.287-12.763L8.5,54.5v-9l9.521-1.269c0.839-4.679,2.675-9.009,5.287-12.763l-5.835-7.63l6.364-6.364 l7.63,5.835c3.754-2.612,8.085-4.448,12.763-5.287L45.5,8.5h9l1.269,9.521c4.679,0.839,9.009,2.675,12.763,5.287l7.63-5.835 l6.364,6.364l-5.835,7.63c2.612,3.754,4.448,8.085,5.287,12.763L91.5,45.5v9L81.979,55.769z M81.979,55.769 c-0.839,4.679-2.675,9.009-5.287,12.763l5.835,7.63l-6.364,6.364l-7.63-5.835c-3.754,2.612-8.085,4.448-12.763,5.287L54.5,91.5h-9 l-1.269-9.521c-4.679-0.839-9.009-2.675-12.763-5.287l-7.63,5.835l-6.364-6.364l5.835-7.63c-2.612-3.754-4.448-8.085-5.287-12.763 L8.5,54.5v-9l9.521-1.269c0.839-4.679,2.675-9.009,5.287-12.763l-5.835-7.63l6.364-6.364l7.63,5.835 c3.754-2.612,8.085-4.448,12.763-5.287L45.5,8.5h9l1.269,9.521c4.679,0.839,9.009,2.675,12.763,5.287l7.63-5.835l6.364,6.364 l-5.835,7.63c2.612,3.754,4.448,8.085,5.287,12.763L91.5,45.5v9L81.979,55.769z M50,35.5c-8.008,0-14.5,6.492-14.5,14.5 S41.992,64.5,50,64.5S64.5,58.008,64.5,50S58.008,35.5,50,35.5z\"\n            />\n            <path\n                fill=\"none\"\n                stroke=\"#40396e\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-miterlimit=\"10\"\n                stroke-width=\"3\"\n                d=\"M50,35.5c-8.008,0-14.5,6.492-14.5,14.5S41.992,64.5,50,64.5S64.5,58.008,64.5,50S58.008,35.5,50,35.5z\"\n            />\n            <path\n                fill=\"none\"\n                stroke=\"#40396e\"\n                stroke-miterlimit=\"10\"\n                d=\"M50,35.5c-8.008,0-14.5,6.492-14.5,14.5S41.992,64.5,50,64.5 S64.5,58.008,64.5,50S58.008,35.5,50,35.5z\"\n            />\n        </svg>\n    )\n}\n\nconst SyncIcon = (props: IconProps) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 100 100\"\n            width={props.size}\n        >\n            <path\n                d=\"M52.054,96c-12.139,0-23.857-5.088-32.15-13.96C11.495,73.043,7.323,61.293,8.157,48.953 c1.5-22.195,20.114-40.172,42.376-40.927C51.039,8.009,51.544,8,52.046,8c5.552,0,10.922,1.014,16.016,3.02l2.083-3.125 C71.35,6.083,73.372,5,75.55,5c0.334,0,0.669,0.026,0.996,0.077c2.448,0.366,4.514,2.156,5.234,4.556l6,20 c0.578,1.922,0.246,3.969-0.913,5.613c-1.162,1.646-2.979,2.646-4.989,2.747l-20.124,1.006L61.554,39 c-2.333,0-4.499-1.26-5.651-3.288c-1.221-2.153-1.126-4.764,0.243-6.817l0.321-0.482C55.017,28.138,53.54,28,52.044,28 c-0.229,0-0.457,0.003-0.688,0.01c-12.279,0.353-22.492,10.215-23.254,22.453c-0.417,6.697,1.878,13.074,6.465,17.956 C39.161,73.308,45.371,76,52.054,76c12.627,0,23.147-9.862,23.951-22.452C76.239,49.876,79.304,47,82.983,47h6.011 c1.913,0,3.765,0.796,5.081,2.186c1.32,1.392,2.018,3.289,1.915,5.205C94.738,77.723,75.439,96,52.054,96z\"\n                opacity=\".35\"\n            />\n            <path\n                fill=\"#f2f2f2\"\n                d=\"M50.054,94c-12.139,0-23.857-5.088-32.15-13.96C9.495,71.043,5.323,59.293,6.157,46.953 c1.5-22.195,20.114-40.172,42.376-40.927C49.039,6.009,49.544,6,50.046,6c5.552,0,10.922,1.014,16.016,3.02l2.083-3.125 C69.35,4.083,71.372,3,73.55,3c0.334,0,0.669,0.026,0.996,0.077c2.448,0.366,4.514,2.156,5.234,4.556l6,20 c0.578,1.922,0.246,3.969-0.913,5.613c-1.162,1.646-2.979,2.646-4.989,2.747l-20.124,1.006L59.554,37 c-2.333,0-4.499-1.26-5.651-3.288c-1.221-2.153-1.126-4.764,0.243-6.817l0.321-0.482C53.017,26.138,51.54,26,50.044,26 c-0.229,0-0.457,0.003-0.688,0.01c-12.279,0.353-22.492,10.215-23.254,22.453c-0.417,6.697,1.878,13.074,6.465,17.956 C37.161,71.308,43.371,74,50.054,74c12.627,0,23.147-9.862,23.951-22.452C74.239,47.876,77.304,45,80.983,45h6.011 c1.913,0,3.765,0.796,5.081,2.186c1.32,1.392,2.018,3.289,1.915,5.205C92.738,75.723,73.439,94,50.054,94z\"\n            />\n            <path\n                fill=\"#96c362\"\n                d=\"M11.146,47.29c1.337-19.785,17.738-35.595,37.556-36.267c6.925-0.235,13.452,1.355,19.168,4.299 l4.436-6.654c0.321-0.483,0.894-0.74,1.47-0.651c0.574,0.086,1.048,0.496,1.215,1.052l6,20c0.133,0.442,0.055,0.919-0.211,1.296 c-0.266,0.376-0.69,0.61-1.15,0.633l-20,1C59.604,32,59.578,32,59.554,32c-0.538,0-1.037-0.289-1.305-0.759 c-0.279-0.493-0.258-1.101,0.057-1.573l3.962-5.944c-3.955-1.848-8.383-2.846-13.06-2.712 C34.365,21.438,22.036,33.33,21.112,48.151C20.062,64.976,33.451,79,50.054,79c15.364,0,27.975-12.009,28.941-27.133 C79.061,50.817,79.931,50,80.983,50h6.011c1.152,0,2.065,0.973,2.003,2.123C87.894,72.675,70.881,89,50.054,89 C27.618,89,9.607,70.055,11.146,47.29z\"\n            />\n            <path\n                fill=\"#40396e\"\n                d=\"M73.033,12.985l4.534,15.112l-15.112,0.756l2.31-3.465l0.645-0.968 c0.679-1.019,0.292-2.403-0.817-2.921l-1.054-0.493C59.269,19.012,54.729,18,50.044,18c-0.306,0-0.613,0.004-0.921,0.013 c-16.368,0.47-29.987,13.626-31.005,29.951c-0.557,8.925,2.505,17.424,8.619,23.932C32.77,78.317,41.269,82,50.054,82 c16.519,0,30.334-12.663,31.86-29h4.017c-1.648,20.028-19.587,35.208-40.296,32.736c-7.493-0.895-14.559-4.197-20.074-9.348 c-8.088-7.554-12.162-17.939-11.422-28.896c1.226-18.151,16.453-32.853,34.665-33.471C49.219,14.007,49.634,14,50.046,14 c5.777,0,11.312,1.342,16.45,3.989l0.794,0.409c0.905,0.466,2.015,0.178,2.58-0.669l0.496-0.744L73.033,12.985 M73.55,7.999 c-0.496,0-0.965,0.249-1.244,0.669l-4.436,6.654C62.525,12.569,56.472,11,50.046,11c-0.446,0-0.895,0.008-1.344,0.023 c-19.819,0.673-36.22,16.482-37.556,36.267C9.607,70.055,27.618,89,50.054,89c20.827,0,37.841-16.325,38.943-36.877 C89.059,50.973,88.146,50,86.994,50h-6.011c-1.052,0-1.921,0.817-1.988,1.867C78.029,66.991,65.417,79,50.054,79 c-16.603,0-29.991-14.024-28.942-30.849c0.924-14.821,13.253-26.713,28.097-27.139C49.488,21.004,49.766,21,50.044,21 c4.368,0,8.506,0.986,12.224,2.724l-3.962,5.944c-0.314,0.472-0.336,1.08-0.057,1.573C58.517,31.711,59.016,32,59.554,32 c0.024,0,0.05,0,0.075-0.002l20-1c0.46-0.023,0.885-0.256,1.15-0.633c0.266-0.377,0.344-0.854,0.211-1.296l-6-20 c-0.167-0.557-0.641-0.966-1.215-1.052C73.7,8.005,73.625,7.999,73.55,7.999L73.55,7.999z\"\n            />\n        </svg>\n    )\n}\n\nconst AddIcon = (props: IconProps) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 100 100\"\n            width={props.size}\n        >\n            <path\n                d=\"M89.5,37H67V14.5c0-3.59-2.91-6.5-6.5-6.5h-17c-1.724,0-3.378,0.685-4.597,1.905\tC37.684,11.124,37,12.778,37,14.502V37H14.5C10.91,37,8,39.91,8,43.5v17c0,3.59,2.91,6.5,6.5,6.5H37v22.5c0,3.59,2.91,6.5,6.5,6.5\th17c3.59,0,6.5-2.91,6.5-6.5V67h22.5c3.59,0,6.5-2.91,6.5-6.5v-17C96,39.91,93.09,37,89.5,37z\"\n                opacity=\".35\"\n            />\n            <path\n                fill=\"#f2f2f2\"\n                d=\"M87.5,35H65V12.5C65,8.91,62.09,6,58.5,6h-17c-1.724,0-3.378,0.685-4.597,1.905\tC35.684,9.124,35,10.778,35,12.502V35H12.5C8.91,35,6,37.91,6,41.5v17c0,3.59,2.91,6.5,6.5,6.5H35v22.5c0,3.59,2.91,6.5,6.5,6.5h17\tc3.59,0,6.5-2.91,6.5-6.5V65h22.5c3.59,0,6.5-2.91,6.5-6.5v-17C94,37.91,91.09,35,87.5,35z\"\n            />\n            <polygon\n                fill=\"#9aa2e6\"\n                points=\"58.5,41.5 58.5,12.5 41.5,12.5 41.5,41.5 12.5,41.5 12.5,58.5 41.5,58.5 41.5,87.5 58.5,87.5 58.5,58.5 87.5,58.5 87.5,41.5\"\n            />\n            <path\n                fill=\"#40396e\"\n                d=\"M58.5,89h-17c-0.829,0-1.5-0.671-1.5-1.5V60H12.5c-0.829,0-1.5-0.671-1.5-1.5v-17\tc0-0.829,0.671-1.5,1.5-1.5H40V12.5c0-0.829,0.671-1.5,1.5-1.5h17c0.829,0,1.5,0.671,1.5,1.5V40h27.5c0.829,0,1.5,0.671,1.5,1.5v17\tc0,0.829-0.671,1.5-1.5,1.5H60v27.5C60,88.329,59.329,89,58.5,89z M43,86h14V58.5c0-0.829,0.671-1.5,1.5-1.5H86V43H58.5\tc-0.829,0-1.5-0.671-1.5-1.5V14H43v27.5c0,0.829-0.671,1.5-1.5,1.5H14v14h27.5c0.829,0,1.5,0.671,1.5,1.5V86z\"\n            />\n        </svg>\n    )\n}\n\nconst CopyIcon = (props: IconProps) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 100 100\"\n            width={props.size}\n        >\n            <path\n                d=\"M17.448,95.054c-1.737,0-3.37-0.676-4.598-1.903c-1.229-1.228-1.904-2.86-1.904-4.597l0.016-65.047 c0-3.583,2.916-6.499,6.5-6.499h11.544v-1.489c0-3.582,2.914-6.498,6.496-6.5L70.496,9c1.776,0,3.493,0.864,4.715,2.148 l16.004,16.835c1.152,1.209,1.789,2.801,1.789,4.479v48.035c0,3.576-2.909,6.492-6.484,6.5l-9.527,0.024v1.477 c0,3.58-2.912,6.496-6.492,6.5l-53.047,0.056H17.448z\"\n                opacity=\".35\"\n            />\n            <path\n                fill=\"#f2f2f2\"\n                d=\"M15.448,93.054c-1.737,0-3.37-0.676-4.598-1.903c-1.229-1.228-1.904-2.86-1.904-4.597l0.016-65.047 c0-3.583,2.916-6.499,6.5-6.499h11.544v-1.489c0-3.582,2.914-6.498,6.496-6.5L68.496,7c1.776,0,3.493,0.737,4.715,2.021 l16.004,16.835c1.152,1.209,1.789,2.801,1.789,4.479v48.162c0,3.576-2.909,6.492-6.484,6.5l-9.527,0.024v1.477 c0,3.58-2.912,6.496-6.492,6.5l-53.047,0.056H15.448z\"\n            />\n            <path\n                fill=\"#d9eeff\"\n                d=\"M33.06,79.073v-66l34.051-0.131l17.838,18.73v47.27L33.06,79.073z\"\n            />\n            <path\n                fill=\"#70bfff\"\n                d=\"M33.06,79.612v-66l34.051-0.131l17.838,18.73v47.27L33.06,79.612z\"\n                opacity=\".35\"\n            />\n            <path\n                fill=\"#70bfff\"\n                d=\"M68,14.946v14.27C68,30.201,68.799,31,69.784,31h14.27l-0.892-2.366L69.784,14.809L68,14.946z\"\n            />\n            <path\n                fill=\"#70bfff\"\n                d=\"M75.5,42h-17c-0.828,0-1.5-0.672-1.5-1.5l0,0c0-0.828,0.672-1.5,1.5-1.5h17 c0.828,0,1.5,0.672,1.5,1.5l0,0C77,41.328,76.328,42,75.5,42z\"\n            />\n            <path\n                fill=\"#70bfff\"\n                d=\"M75.5,51h-33c-0.828,0-1.5-0.672-1.5-1.5l0,0c0-0.828,0.672-1.5,1.5-1.5h33 c0.828,0,1.5,0.672,1.5,1.5l0,0C77,50.328,76.328,51,75.5,51z\"\n            />\n            <path\n                fill=\"#70bfff\"\n                d=\"M75.5,69h-33c-0.828,0-1.5-0.672-1.5-1.5l0,0c0-0.828,0.672-1.5,1.5-1.5h33 c0.828,0,1.5,0.672,1.5,1.5l0,0C77,68.328,76.328,69,75.5,69z\"\n            />\n            <path\n                fill=\"#70bfff\"\n                d=\"M59.5,60h-17c-0.828,0-1.5-0.672-1.5-1.5l0,0c0-0.828,0.672-1.5,1.5-1.5h17 c0.828,0,1.5,0.672,1.5,1.5l0,0C61,59.328,60.328,60,59.5,60z\"\n            />\n            <path\n                fill=\"#70bfff\"\n                d=\"M75.5,60h-8c-0.828,0-1.5-0.672-1.5-1.5l0,0c0-0.828,0.672-1.5,1.5-1.5h8 c0.828,0,1.5,0.672,1.5,1.5l0,0C77,59.328,76.328,60,75.5,60z\"\n            />\n            <path\n                fill=\"#70bfff\"\n                d=\"M50.5,42h-8c-0.828,0-1.5-0.672-1.5-1.5l0,0c0-0.828,0.672-1.5,1.5-1.5h8 c0.828,0,1.5,0.672,1.5,1.5l0,0C52,41.328,51.328,42,50.5,42z\"\n            />\n            <g>\n                <path\n                    fill=\"#d9eeff\"\n                    d=\"M15.424,86.684L15.44,23H51l16,16l-0.062,47.944L15.424,86.684z\"\n                />\n            </g>\n            <path\n                fill=\"#70bfff\"\n                d=\"M51,22.946v14.27C51,38.201,51.799,39,52.784,39h14.27L51,22.946z\"\n            />\n            <polygon\n                fill=\"none\"\n                stroke=\"#40396e\"\n                stroke-linejoin=\"round\"\n                stroke-miterlimit=\"10\"\n                stroke-width=\"3\"\n                points=\"84.503,30.334 68.5,13.5 33.506,13.519 33.506,21.508 15.462,21.508 15.446,86.554 68.492,86.498 68.492,78.537 84.503,78.496\"\n            />\n            <g>\n                <path\n                    fill=\"#70bfff\"\n                    d=\"M58.5,50h-17c-0.828,0-1.5-0.672-1.5-1.5l0,0c0-0.828,0.672-1.5,1.5-1.5h17 c0.828,0,1.5,0.672,1.5,1.5l0,0C60,49.328,59.328,50,58.5,50z\"\n                />\n                <path\n                    fill=\"#70bfff\"\n                    d=\"M58.5,59h-33c-0.828,0-1.5-0.672-1.5-1.5l0,0c0-0.828,0.672-1.5,1.5-1.5h33 c0.828,0,1.5,0.672,1.5,1.5l0,0C60,58.328,59.328,59,58.5,59z\"\n                />\n                <path\n                    fill=\"#70bfff\"\n                    d=\"M58.5,77h-33c-0.828,0-1.5-0.672-1.5-1.5l0,0c0-0.828,0.672-1.5,1.5-1.5h33 c0.828,0,1.5,0.672,1.5,1.5l0,0C60,76.328,59.328,77,58.5,77z\"\n                />\n                <path\n                    fill=\"#70bfff\"\n                    d=\"M42.5,68h-17c-0.828,0-1.5-0.672-1.5-1.5l0,0c0-0.828,0.672-1.5,1.5-1.5h17 c0.828,0,1.5,0.672,1.5,1.5l0,0C44,67.328,43.328,68,42.5,68z\"\n                />\n                <path\n                    fill=\"#70bfff\"\n                    d=\"M58.5,68h-8c-0.828,0-1.5-0.672-1.5-1.5l0,0c0-0.828,0.672-1.5,1.5-1.5h8 c0.828,0,1.5,0.672,1.5,1.5l0,0C60,67.328,59.328,68,58.5,68z\"\n                />\n                <path\n                    fill=\"#70bfff\"\n                    d=\"M33.5,50h-8c-0.828,0-1.5-0.672-1.5-1.5l0,0c0-0.828,0.672-1.5,1.5-1.5h8 c0.828,0,1.5,0.672,1.5,1.5l0,0C35,49.328,34.328,50,33.5,50z\"\n                />\n            </g>\n        </svg>\n    )\n}\n\nconst PlayIcon = (props: IconProps) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            baseProfile=\"basic\"\n            viewBox=\"0 0 100 100\"\n            width={props.size}\n        >\n            <path\n                d=\"M28.5913086,94.0322266c-1.0322266,0-2.0625-0.25-2.9799805-0.7231445\tc-2.171875-1.1210938-3.5200195-3.3344727-3.5200195-5.7768555V16.4677734c0-2.4423828,1.3481445-4.6557617,3.5185547-5.7758789\tc0.9179688-0.4741211,1.9492188-0.7246094,2.9819336-0.7246094c1.3588867,0,2.6621094,0.4169922,3.769043,1.2050781\tl49.9848633,35.5297852C84.0576172,47.9194336,85.0795898,49.8999023,85.0795898,52s-1.0219727,4.0805664-2.7333984,5.2973633\tL32.3574219,92.8300781C31.2475586,93.6176758,29.9467773,94.0322266,28.5913086,94.0322266z\"\n                opacity=\".35\"\n            />\n            <path\n                fill=\"#F2F2F2\"\n                d=\"M26.5913086,92.0322266c-1.0322266,0-2.0625-0.25-2.9799805-0.7231445\tc-2.171875-1.1210938-3.5200195-3.3344727-3.5200195-5.7768555V14.4677734c0-2.4423828,1.3481445-4.6557617,3.5185547-5.7758789\tc0.9179688-0.4741211,1.9492188-0.7246094,2.9819336-0.7246094c1.3588867,0,2.6621094,0.4169922,3.769043,1.2050781\tl49.9848633,35.5297852C82.0576172,45.9194336,83.0795898,47.8999023,83.0795898,50s-1.0219727,4.0805664-2.7333984,5.2973633\tL30.3574219,90.8300781C29.2475586,91.6176758,27.9467773,92.0322266,26.5913086,92.0322266z\"\n            />\n            <polygon\n                fill=\"#70BFFF\"\n                points=\"76.5793686,50 26.5912914,14.4676981 26.5912914,85.5323029\"\n            />\n            <path\n                fill=\"#40396E\"\n                d=\"M26.5913086,87.0322266c-0.2353516,0-0.4711914-0.0551758-0.6879883-0.1669922 c-0.4985352-0.2573242-0.8120117-0.7719727-0.8120117-1.3330078V14.4677734c0-0.5610352,0.3134766-1.0756836,0.8120117-1.3330078 s1.0991211-0.2158203,1.5571289,0.1103516l49.9882812,35.5322266c0.3955078,0.28125,0.6308594,0.7368164,0.6308594,1.2226562 s-0.2353516,0.9414062-0.6308594,1.2226562L27.4604492,86.7548828 C27.2016602,86.9384766,26.8969727,87.0322266,26.5913086,87.0322266z M28.0913086,17.3745117v65.2509766L73.9907227,50 L28.0913086,17.3745117z\"\n            />\n        </svg>\n    )\n}\n\nconst ChartIcon = (props: IconProps) => {\n    return (\n        <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            baseProfile=\"basic\"\n            viewBox=\"0 0 100 100\"\n            width={props.size}\n        >\n            <path\n                d=\"M17.5,93c-3.5839844,0-6.5-2.9160156-6.5-6.5V53.9165039c0-1.2856445,0.3764648-2.5307617,1.0893555-3.6020508 l19.1689453-28.753418c1.2119141-1.8139648,3.2333984-2.894043,5.4101562-2.894043 c1.0004883,0,2.0024414,0.2358398,2.8984375,0.6821289L62.4057617,30.769043l19.7016602-18.0605469 C83.3100586,11.6064453,84.8696289,11,86.5,11c0.9047852,0,1.7836914,0.1845703,2.612793,0.5483398 C91.4765625,12.5893555,93,14.9243164,93,17.5v69c0,3.5839844-2.9160156,6.5-6.5,6.5H17.5z\"\n                opacity=\".35\"\n            />\n            <path\n                fill=\"#F2F2F2\"\n                d=\"M15.5,91C11.9160156,91,9,88.0839844,9,84.5V51.9165039 c0-1.2856445,0.3764648-2.5307617,1.0893555-3.6020508l19.1689453-28.753418 c1.2119141-1.8139648,3.2333984-2.894043,5.4101562-2.894043c1.0004883,0,2.0024414,0.2358398,2.8984375,0.6821289 L60.4057617,28.769043l19.7016602-18.0605469C81.3100586,9.6064453,82.8696289,9,84.5,9 c0.9047852,0,1.7836914,0.1845703,2.612793,0.5483398C89.4765625,10.5893555,91,12.9243164,91,15.5v69 c0,3.5839844-2.9160156,6.5-6.5,6.5H15.5z\"\n            />\n            <path\n                fill=\"#70BFFF\"\n                d=\"M84.5,74.9166641h-69V51.9166679L34.6666679,23.166666L61.5,36.5833321L84.5,15.5V74.9166641z\"\n            />\n            <path\n                fill=\"#707CC0\"\n                d=\"M84.5,84.5h-69V65.3333359L34.6666679,50L61.5,53.8333321l23-17.25V84.5z\"\n            />\n            <path\n                fill=\"#40396E\"\n                d=\"M84.5,86h-69c-0.8286133,0-1.5-0.6713867-1.5-1.5V51.9165039 c0-0.2958984,0.0878906-0.5854492,0.2519531-0.8320312l19.1665039-28.75 c0.4199219-0.628418,1.2436523-0.8466797,1.9189453-0.5097656l25.9101562,12.9555664L83.4863281,14.394043 c0.4389648-0.4013672,1.0722656-0.5063477,1.6176758-0.2670898C85.6484375,14.3666992,86,14.9052734,86,15.5v69 C86,85.3286133,85.3286133,86,84.5,86z M17,83h66V18.909668L62.5136719,37.6894531 c-0.4580078,0.4204102-1.1289062,0.5136719-1.6845703,0.2358398l-25.6489258-12.824707L17,52.3706055V83z\"\n            />\n        </svg>\n    )\n}\n\nexport {\n    AddIcon,\n    ChartIcon,\n    CopyIcon,\n    GoodIcon,\n    HomeIcon,\n    PlayIcon,\n    SettingIcon,\n    SyncIcon,\n}\n"
  },
  {
    "path": "crates/gui/src/index.tsx",
    "content": "/* @refresh reload */\nimport \"./styles.css\"\n\nimport { Router } from \"@solidjs/router\"\nimport { render } from \"solid-js/web\"\n\nimport App from \"./App\"\n\nrender(\n    () => (\n        <Router>\n            <App />\n        </Router>\n    ),\n    document.getElementById(\"root\") as HTMLElement\n)\n"
  },
  {
    "path": "crates/gui/src/model/Live.tsx",
    "content": "export interface LiveItem {\n    name: string;\n    cmd: string;\n}\n\nconst allLives = (): LiveItem[] => {\n    const lives: LiveItem[] = [\n        {\n            name: \"B站\",\n            cmd: \"bili\",\n        },\n        {\n            name: \"斗鱼\",\n            cmd: \"douyu\",\n        },\n        {\n            name: \"抖音\",\n            cmd: \"douyin\",\n        },\n        {\n            name: \"虎牙\",\n            cmd: \"huya\",\n        },\n        {\n            name: \"快手\",\n            cmd: \"ks\",\n        },\n        {\n            name: \"CC\",\n            cmd: \"cc\",\n        },\n        {\n            name: \"花椒\",\n            cmd: \"huajiao\",\n        },\n        {\n            name: \"艺气山\",\n            cmd: \"yqs\",\n        },\n        {\n            name: \"棉花糖\",\n            cmd: \"mht\",\n        },\n        {\n            name: \"KK\",\n            cmd: \"kk\",\n        },\n        {\n            name: \"千帆\",\n            cmd: \"qf\",\n        },\n        {\n            name: \"Now\",\n            cmd: \"now\",\n        },\n        {\n            name: \"映客\",\n            cmd: \"inke\",\n        },\n        {\n            name: \"afreeca\",\n            cmd: \"afreeca\",\n        },\n        {\n            name: \"pandalive\",\n            cmd: \"panda\",\n        },\n        {\n            name: \"flex\",\n            cmd: \"flex\",\n        },\n        {\n            name: \"wink\",\n            cmd: \"wink\",\n        },\n    ]\n    return lives\n}\n\nexport default allLives\n"
  },
  {
    "path": "crates/gui/src/model/Record.tsx",
    "content": "export interface SubscribeRecord {\n    live: string;\n    rid: string;\n}\n"
  },
  {
    "path": "crates/gui/src/model/Resp.tsx",
    "content": "export interface Resp<T> {\n    code: number;\n    msg: string;\n    data: T;\n}\n"
  },
  {
    "path": "crates/gui/src/pages/Chart.tsx",
    "content": "import \"../css/Chart.css\"\n\nimport { invoke } from \"@tauri-apps/api\"\nimport { createMemo, createSignal, For, onMount } from \"solid-js\"\nimport toast from \"solid-toast\"\n\nimport allLives from \"../model/Live\"\nimport { Resp } from \"../model/Resp\"\n\ninterface Record {\n    index: number\n    live: string\n    rid: string\n    anchor: string\n}\n\ninterface Subscribe {\n    live: string\n    rid: string\n}\n\nconst Chart = () => {\n    const [selected, setSelect] = createSignal(\"all\")\n    const [records, setRecords] = createSignal<Record[]>([])\n\n    // 开启页面获取 records 数据\n\n    onMount(async () => {\n        const subscribes = await invoke<Resp<Subscribe[]>>(\"subscribe_all\")\n        console.log(subscribes)\n        const map = subscribes.data.map((item, index) => {\n            return {\n                index: index,\n                live: item.live,\n                rid: item.rid,\n                anchor: \"未知\",\n            }\n            setRecords(map)\n        })\n        setRecords(map)\n    })\n\n    const filterRecords = createMemo(() => {\n        if (selected() === \"all\") {\n            return records()\n        } else {\n            return records().filter((item) => item.live === selected())\n        }\n    })\n\n    const deleteRecord = async (index: number) => {\n        const item = records().find((item) => item.index === index)\n        setRecords(records().filter((item) => item.index !== index))\n        await invoke<Resp<Subscribe[]>>(\"subscribe_remove\", {\n            live: item?.live,\n            rid: item?.rid,\n        }).then((resp) => {\n            if (resp.code === 0) {\n                toast.success(\"删除成功\")\n            } else {\n                toast.error(resp.msg)\n            }\n        })\n    }\n\n    const liveName = (live: string) => {\n        const lives = allLives()\n        for (let i = 0; i < lives.length; i++) {\n            if (lives[i].cmd === live) {\n                return lives[i].name\n            }\n        }\n        return \"未知\"\n    }\n\n    return (\n        <div class=\"chart\">\n            <div class=\"chart-kind-container\">\n                <div\n                    class=\"chart-kind-item\"\n                    classList={{\n                        \"chart-kind-item-activate\": selected() === \"all\",\n                    }}\n                    onClick={() => setSelect(\"all\")}>\n                    ALL\n                </div>\n                <For each={allLives()}>\n                    {(item) => (\n                        <div\n                            class=\"chart-kind-item\"\n                            classList={{\n                                \"chart-kind-item-activate\":\n                                    selected() === item.cmd,\n                            }}\n                            onClick={() => setSelect(item.cmd)}>\n                            {item.name}\n                        </div>\n                    )}\n                </For>\n            </div>\n            <table class=\"chart-table\">\n                <thead>\n                    <tr>\n                        <th class=\"chart-table-title-1\">平台</th>\n                        <th class=\"chart-table-title-2\">房间号</th>\n                        <th class=\"chart-table-title-3\">主播</th>\n                        <th class=\"chart-table-title-4\">操作</th>\n                    </tr>\n                </thead>\n                <tbody>\n                    <For each={filterRecords()}>\n                        {(item) => (\n                            <tr>\n                                <td>{liveName(item.live)}</td>\n                                <td>{item.rid}</td>\n                                <td>{item.anchor}</td>\n                                <td>\n                                    <button\n                                        onClick={() => {\n                                            deleteRecord(item.index)\n                                        }}>\n                                        删除\n                                    </button>\n                                </td>\n                            </tr>\n                        )}\n                    </For>\n                </tbody>\n            </table>\n            <div class=\"chart-separator\" />\n        </div>\n    )\n}\n\nexport default Chart\n"
  },
  {
    "path": "crates/gui/src/pages/Good.tsx",
    "content": "import \"../css/Good.css\"\n\nimport GoodItem from \"../components/GoodItem\"\n\nconst Good = () => {\n    const goodDemo = {\n        live: \"douyu\",\n        rid: \"123\",\n        title: \"恭喜你发现了我~\",\n        anchor: \"我是谁\",\n        urls: [],\n        img: undefined,\n    }\n    return (\n        <div class=\"good\">\n            <GoodItem {...goodDemo} />\n            <GoodItem {...goodDemo} />\n            <GoodItem {...goodDemo} />\n            <GoodItem {...goodDemo} />\n            <GoodItem {...goodDemo} />\n        </div>\n    )\n}\n\nexport default Good\n"
  },
  {
    "path": "crates/gui/src/pages/Home.tsx",
    "content": "import \"../css/Home.css\"\n\nimport Live from \"../components/Live\"\n\nconst Home = () => {\n    const liveDemo = {\n        live: \"douyu\",\n        rid: \"123\",\n        title: \"恭喜你发现了我~\",\n        anchor: \"我是谁\",\n        urls: [],\n        img: undefined,\n    }\n    return (\n        <div class=\"home\">\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n            <Live {...liveDemo} />\n        </div>\n    )\n}\n\nexport default Home\n"
  },
  {
    "path": "crates/gui/src/pages/Setting.tsx",
    "content": "import \"../css/Setting.css\"\n\nimport { open } from \"@tauri-apps/api/dialog\"\nimport toast from \"solid-toast\"\n\n// TODO headers 设置分离, 取消 textarea, 列表,\n// 顶部添加, 选择平台, 输入字段, 值, 点击添加\n// 列表最右边添加删除按钮, 点击删除\n// 列表的值可以修改, 点击修改\n\nconst Setting = () => {\n    const save = () => {\n        toast.success(\"保存成功\")\n    }\n    return (\n        <div class=\"setting\">\n            <div class=\"setting-title\">播放</div>\n            <div class=\"setting-item\">\n                <div class=\"setting-item-title\">播放器</div>\n                <div>\n                    <input class=\"setting-input\" placeholder=\"命令/地址\" />\n                    <button\n                        class=\"setting-btn\"\n                        onClick={async () => {\n                            const file = await open()\n                            console.log(file)\n                        }}>\n                        选择\n                    </button>\n                </div>\n            </div>\n            <div class=\"setting-item\">\n                <div class=\"setting-item-title\">参数</div>\n                <input\n                    class=\"setting-input setting-arg\"\n                    placeholder=\"逗号分隔\"\n                />\n            </div>\n\n            {/* TODO 将目前已知需要额外配置的 cookie 写入此处, 让用户知道哪些需要额外配置 */}\n            <div class=\"setting-title\">Headers</div>\n            <textarea class=\"setting-textarea\" placeholder=\"按照官网配置\" />\n            <button class=\"setting-save\" onClick={() => save()}>\n                保存\n            </button>\n        </div>\n    )\n}\n\nexport default Setting\n"
  },
  {
    "path": "crates/gui/src/styles.css",
    "content": "html,\nbody {\n  height: 100%;\n}\n\nhtml,\nbody,\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\ndiv,\ndl,\ndt,\ndd,\nul,\nol,\nli,\np,\nblockquote,\npre,\nhr,\nfigure,\ntable,\ncaption,\nth,\ntd,\nform,\nfieldset,\nlegend,\ninput,\nbutton,\ntextarea,\nmenu {\n  margin: 0;\n  padding: 0;\n}\n\nheader,\nfooter,\nsection,\narticle,\naside,\nnav,\nhgroup,\naddress,\nfigure,\nfigcaption,\nmenu,\ndetails {\n  display: block;\n}\n\ntable {\n  border-collapse: collapse;\n  border-spacing: 0;\n}\n\ncaption,\nth {\n  text-align: left;\n  font-weight: normal;\n}\n\nhtml,\nbody,\nfieldset,\nimg,\niframe,\nabbr {\n  border: 0;\n}\n\ni,\ncite,\nem,\nvar,\naddress,\ndfn {\n  font-style: normal;\n}\n\n[hidefocus],\nsummary {\n  outline: 0;\n}\n\nli {\n  list-style: none;\n}\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\nsmall {\n  font-size: 100%;\n}\n\nsup,\nsub {\n  font-size: 83%;\n}\n\npre,\ncode,\nkbd,\nsamp {\n  font-family: inherit;\n}\n\nq:before,\nq:after {\n  content: none;\n}\n\ntextarea {\n  overflow: auto;\n  resize: none;\n}\n\nlabel,\nsummary {\n  cursor: default;\n}\n\na,\nbutton {\n  cursor: pointer;\n}\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\nem,\nstrong,\nb {\n  font-weight: bold;\n}\n\ndel,\nins,\nu,\ns,\na,\na:hover {\n  text-decoration: none;\n}\n\nbody,\ntextarea,\ninput,\nbutton,\nselect,\nkeygen,\nlegend {\n  font: 12px/1.14 Microsoft YaHei, arial, \\5b8b\\4f53;\n  color: #333;\n  outline: 0;\n}\n\nbody {\n  background: #1e1e1e;\n}\n\na,\na:hover {\n  color: #333;\n}\n\n* {\n  box-sizing: border-box;\n}\n\ninput:-webkit-autofill {\n  -webkit-box-shadow: 0 0 0px 1000px white inset;\n}\n\n#root {\n  height: 100%;\n}\n\n/*\n  禁止双指滑动滚动反弹效果\n  参考资料: https://developer.mozilla.org/zh-CN/docs/Web/CSS/overscroll-behavior\n*/\nbody {\n  overscroll-behavior: none;\n}\n\n/* 选中颜色 */\n::selection {\n  background: #ff8c00;\n  color: #fff;\n}"
  },
  {
    "path": "crates/gui/src-tauri/.gitignore",
    "content": "# Generated by Cargo\n# will have compiled files and executables\n/target/\n\n"
  },
  {
    "path": "crates/gui/src-tauri/Cargo.toml",
    "content": "[package]\nname = \"seam_gui\"\nversion = \"0.1.8\"\ndescription = \"seam\"\nauthors = [\"Borber\"]\nlicense = \"\"\nrepository = \"\"\nedition = \"2021\"\n\n[build-dependencies]\ntauri-build = { version = \"1.5\", features = [] }\n\n[dependencies]\nseam_core = { path = \"../../core\" }\n\ntauri = { version = \"1.5\", features = [\n    \"dialog-all\",\n    \"window-show\",\n    \"window-minimize\",\n    \"window-close\",\n    \"window-maximize\",\n    \"shell-open\",\n    \"window-start-dragging\",\n] }\n\ntokio = { version = \"*\", features = [\"full\"] }\n\nanyhow = \"1\"\nonce_cell = \"1\"\n\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\nbasic-toml = \"0.1\"\n\nwindow-shadows = \"0.2\"\n\nsea-orm = { version = \"0.12\", features = [\n    \"sqlx-sqlite\",\n    \"runtime-tokio-native-tls\",\n    \"macros\",\n] }\n\n[features]\n# this feature is used for production builds or when `devPath` points to the filesystem\n# DO NOT REMOVE!!\ncustom-protocol = [\"tauri/custom-protocol\"]\n\n[target.'cfg(unix)'.dependencies]\nopenssl = { version = \"0.10\", features = [\"vendored\"] }\n"
  },
  {
    "path": "crates/gui/src-tauri/build.rs",
    "content": "fn main() {\n    tauri_build::build()\n}\n"
  },
  {
    "path": "crates/gui/src-tauri/src/command/live.rs",
    "content": "use seam_core::{error::SeamError, live::Node};\n\nuse crate::{clients, config::headers, resp::Resp, util};\n\n#[tauri::command]\npub async fn url(live: String, rid: String) -> Resp<Node> {\n    let cli = match clients!().get(&live) {\n        Some(cli) => cli,\n        None => return Resp::fail(0, \"目前不支持该平台\"),\n    };\n    match cli.get(&rid, Some(headers(&live))).await {\n        Ok(node) => Resp::success(node),\n        Err(e) => match e {\n            SeamError::None => Resp::fail(1, \"未开播\"),\n            SeamError::NeedFix(msg) => Resp::fail(2, msg),\n            _ => Resp::fail(3, &e.to_string()),\n        },\n    }\n}\n\n#[tauri::command]\npub async fn play(url: String) -> Resp<bool> {\n    util::play(&url).into()\n}\n"
  },
  {
    "path": "crates/gui/src-tauri/src/command/mod.rs",
    "content": "pub mod live;\npub mod refresh;\npub mod subscribe;\n"
  },
  {
    "path": "crates/gui/src-tauri/src/command/refresh.rs",
    "content": "use tauri::AppHandle;\n\nuse crate::{manager, resp::Resp};\n\n#[tauri::command]\npub async fn refresh_all(app: AppHandle) -> Resp<()> {\n    manager::refresh::all(&app).await.into()\n}\n\n#[tauri::command]\npub async fn refresh_one(app: AppHandle, live: String, rid: String) -> Resp<()> {\n    manager::refresh::one(&app, live, rid).await.into()\n}\n"
  },
  {
    "path": "crates/gui/src-tauri/src/command/subscribe.rs",
    "content": "use crate::{database, resp::Resp, service};\n\n#[tauri::command]\npub async fn subscribe_all() -> Resp<Vec<database::subscribe::Model>> {\n    service::subscribe::all().await.into()\n}\n\n#[tauri::command]\npub async fn subscribe_add(live: String, rid: String) -> Resp<bool> {\n    service::subscribe::add(live, rid).await.into()\n}\n\n#[tauri::command]\npub async fn subscribe_remove(live: String, rid: String) -> Resp<bool> {\n    service::subscribe::remove(live, rid).await.into()\n}\n"
  },
  {
    "path": "crates/gui/src-tauri/src/common.rs",
    "content": "use std::{collections::HashMap, path::Path, sync::Arc};\n\nuse sea_orm::Database;\nuse sea_orm::DatabaseConnection;\nuse seam_core::live::{self, Live};\nuse tokio::sync::OnceCell;\n\nuse crate::database;\nuse crate::util::bin_dir;\n\n#[macro_export]\nmacro_rules! pool {\n    () => {\n        &$crate::common::CONTEXT.get().unwrap().pool\n    };\n}\n\n#[macro_export]\nmacro_rules! clients {\n    () => {\n        &$crate::common::CONTEXT.get().unwrap().clients\n    };\n}\n\npub static CONTEXT: OnceCell<Context> = OnceCell::const_new();\n\npub struct Context {\n    pub pool: DatabaseConnection,\n    pub clients: HashMap<String, Arc<dyn Live>>,\n}\n\npub async fn load() -> Context {\n    let path = Path::new(&bin_dir()).join(\"data.db\");\n    let flag = path.exists();\n    if !flag {\n        std::fs::File::create(&path).unwrap();\n    }\n\n    // TODO 后续需要优化\n    let path = format!(\"sqlite://{}\", path.to_str().unwrap());\n\n    let pool = Database::connect(path)\n        .await\n        .expect(\"Connect database failed\");\n    if !flag {\n        // 初始化数据库\n        // TODO 打印日志\n        match database::init(&pool).await {\n            Ok(_) => {\n                println!(\"初始化数据库成功\");\n            }\n            Err(e) => {\n                panic!(\"{}\", e.to_string());\n            }\n        };\n    }\n    let clients = live::all();\n    Context { pool, clients }\n}\n\n#[cfg(test)]\nmod tests {\n    #[tokio::test]\n    async fn test() {\n        println!(\n            \"{:#?}\",\n            &super::CONTEXT\n                .get()\n                .unwrap()\n                .clients\n                .get(\"bili\")\n                .unwrap()\n                .get(\"6\", None)\n                .await\n                .unwrap()\n        );\n    }\n}\n"
  },
  {
    "path": "crates/gui/src-tauri/src/config.rs",
    "content": "use std::collections::HashMap;\n\nuse once_cell::sync::Lazy;\nuse serde::Deserialize;\n\nuse crate::util::bin_dir;\n\n#[derive(Deserialize, Debug)]\npub struct ConfigOption {\n    pub play: Option<PlayOption>,\n    pub headers: Option<HashMap<String, HashMap<String, String>>>,\n}\n\n#[derive(Debug)]\npub struct Config {\n    pub play: Play,\n    pub headers: HashMap<String, HashMap<String, String>>,\n}\n\n#[derive(Deserialize, Debug)]\npub struct PlayOption {\n    pub bin: Option<String>,\n    pub args: Option<Vec<String>>,\n}\n\n#[derive(Debug, Default)]\npub struct Play {\n    pub bin: String,\n    pub args: Vec<String>,\n}\n\n/// 配置文件\npub static CONFIG: Lazy<Config> = Lazy::new(|| {\n    let config =\n        std::fs::read_to_string(format!(\"{}config.toml\", bin_dir(),)).unwrap_or(\"\".to_owned());\n    let config_file = basic_toml::from_str::<ConfigOption>(&config).unwrap();\n\n    let bin = config_file\n        .play\n        .as_ref()\n        .and_then(|play| play.bin.clone())\n        .unwrap_or_default();\n\n    let args = config_file\n        .play\n        .as_ref()\n        .and_then(|play| play.args.clone())\n        .unwrap_or_default();\n\n    let play = Play { bin, args };\n\n    let headers = config_file.headers.unwrap_or_default();\n\n    Config { play, headers }\n});\n\npub fn headers(live: &str) -> HashMap<String, String> {\n    let global = CONFIG\n        .headers\n        .get(\"global\")\n        .unwrap_or(&HashMap::new())\n        .clone();\n    let mut live = CONFIG.headers.get(live).unwrap_or(&HashMap::new()).clone();\n    live.extend(global);\n    live\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_config() {\n        // 初始化 CONFIG\n        let _ = CONFIG.headers.get(\"bili\").unwrap_or(&HashMap::new());\n        println!(\"{:#?}\", CONFIG);\n    }\n}\n"
  },
  {
    "path": "crates/gui/src-tauri/src/database/mod.rs",
    "content": "pub mod subscribe;\n\nuse anyhow::Result;\nuse sea_orm::*;\n\n/// 初始化数据库\npub async fn init(db: &DatabaseConnection) -> Result<ExecResult, DbErr> {\n    let backend = DbBackend::Sqlite;\n    let schema = Schema::new(backend);\n    let stmt = backend.build(&schema.create_table_from_entity(subscribe::Entity));\n    db.execute(stmt).await\n}\n"
  },
  {
    "path": "crates/gui/src-tauri/src/database/subscribe.rs",
    "content": "use sea_orm::entity::prelude::*;\nuse serde::Serialize;\n\n/// 订阅记录\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]\n#[sea_orm(table_name = \"subscribe\")]\npub struct Model {\n    #[sea_orm(primary_key)]\n    pub live: String,\n    #[sea_orm(primary_key)]\n    pub rid: String,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\npub enum Relation {}\n\nimpl ActiveModelBehavior for ActiveModel {}\n"
  },
  {
    "path": "crates/gui/src-tauri/src/main.rs",
    "content": "#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nuse command::{\n    live::{play, url},\n    refresh::{refresh_all, refresh_one},\n    subscribe::{subscribe_add, subscribe_all, subscribe_remove},\n};\nuse common::CONTEXT;\n\nmod command;\nmod common;\nmod config;\nmod database;\nmod manager;\nmod model;\nmod resp;\nmod service;\nmod setup;\nmod util;\n\n#[tokio::main]\nasync fn main() {\n    CONTEXT.get_or_init(common::load).await;\n\n    tauri::Builder::default()\n        .setup(setup::handler)\n        .invoke_handler(tauri::generate_handler![\n            url,\n            play,\n            subscribe_all,\n            subscribe_add,\n            subscribe_remove,\n            refresh_all,\n            refresh_one,\n        ])\n        .run(tauri::generate_context!())\n        .expect(\"error while running tauri application\");\n}\n"
  },
  {
    "path": "crates/gui/src-tauri/src/manager/mod.rs",
    "content": "pub mod refresh;\n"
  },
  {
    "path": "crates/gui/src-tauri/src/manager/refresh.rs",
    "content": "use crate::{clients, config::headers, service};\n\nuse std::collections::HashMap;\n\nuse anyhow::Result;\nuse seam_core::live::Node;\nuse serde::Serialize;\nuse tauri::{AppHandle, Manager};\n\n/// 刷新返回结果\n#[derive(Clone, Debug, Serialize)]\npub struct ReFreshMessage {\n    pub live: String,\n    pub node: Node,\n}\n\n/// 刷新单个订阅\npub async fn one(app: &AppHandle, live: String, rid: String) -> Result<()> {\n    let clients = clients!();\n    let node = clients\n        .get(&live)\n        .unwrap()\n        .get(&rid, Some(headers(&live)))\n        .await?;\n\n    app.emit_all(\"refresh\", ReFreshMessage { live, node })?;\n    Ok(())\n}\n\n/// 刷新所有订阅的直播源\npub async fn all(app: &AppHandle) -> Result<()> {\n    let lives = service::subscribe::all().await?;\n    let mut lists = HashMap::new();\n    for live in lives {\n        let entry = lists.entry(live.live).or_insert_with(Vec::new);\n        entry.push(live.rid);\n    }\n\n    loop {\n        if lists.is_empty() {\n            break;\n        }\n\n        let once = lists\n            .iter_mut()\n            .map(|(live, rids)| rids.pop().map(|rid| (live.clone(), rid)))\n            .collect::<Vec<_>>();\n\n        for (live, rid) in once.into_iter().flatten() {\n            one(app, live, rid).await?;\n        }\n\n        // 去除所需获取主播为空的平台\n        lists.retain(|_, rids| !rids.is_empty());\n\n        // 等待间隔\n        tokio::time::sleep(std::time::Duration::from_millis(300)).await;\n    }\n    Ok(())\n}\n\n// TODO 手动更新\n// 1. 全部获取完毕发送更新完毕通知给前端\n//    - 开播\n//        - 存在卡片, 不做动作\n//        - 不存在卡片, 新增直播卡片\n//    - 未开播\n//        - 存在卡片, 删除\n//        - 不存在卡片, 不做动作\n\n// TODO 定时更新直播\n// 界面启动时,调用后端命令,然后获取App句柄,随后进行循环命令\n\n// TODO 设置增加 每次自动刷新的间隔时间\n"
  },
  {
    "path": "crates/gui/src-tauri/src/model.rs",
    "content": "\n"
  },
  {
    "path": "crates/gui/src-tauri/src/resp.rs",
    "content": "use anyhow::Result;\nuse serde::Serialize;\n\n#[derive(Debug, Serialize, Clone)]\npub struct Resp<T> {\n    pub code: i64,\n    pub msg: Option<String>,\n    pub data: Option<T>,\n}\n\nimpl<T> From<Result<T>> for Resp<T>\nwhere\n    T: Serialize,\n{\n    fn from(item: Result<T>) -> Self {\n        match item {\n            Ok(data) => Resp::success(data),\n            Err(e) => Resp::fail(1, &e.to_string()),\n        }\n    }\n}\n\nimpl<T> Resp<T>\nwhere\n    T: Serialize,\n{\n    pub fn success(data: T) -> Self {\n        Resp {\n            code: 0,\n            msg: Some(\"success\".to_string()),\n            data: Some(data),\n        }\n    }\n\n    pub fn fail(code: i64, e: &str) -> Self {\n        Resp {\n            code,\n            msg: Some(e.to_owned()),\n            data: None,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/gui/src-tauri/src/service/mod.rs",
    "content": "pub mod subscribe;\n"
  },
  {
    "path": "crates/gui/src-tauri/src/service/subscribe.rs",
    "content": "use anyhow::Result;\nuse sea_orm::*;\n\nuse crate::{database::subscribe, pool};\n\n/// 获取所有订阅\n///\n/// Get all subscribe\npub async fn all() -> Result<Vec<subscribe::Model>> {\n    Ok(subscribe::Entity::find().all(pool!()).await?)\n}\n\n/// 添加订阅\n///\n/// Add subscribe\npub async fn add(live: String, rid: String) -> Result<bool> {\n    subscribe::Entity::insert(subscribe::ActiveModel {\n        live: Set(live),\n        rid: Set(rid),\n    })\n    .exec(pool!())\n    .await?;\n    Ok(true)\n}\n\n/// 删除订阅\n///\n/// Delete subscribe\npub async fn remove(live: String, rid: String) -> Result<bool> {\n    subscribe::Entity::delete_many()\n        .filter(subscribe::Column::Live.eq(live))\n        .filter(subscribe::Column::Rid.eq(rid))\n        .exec(pool!())\n        .await?;\n    Ok(true)\n}\n"
  },
  {
    "path": "crates/gui/src-tauri/src/setup.rs",
    "content": "use std::error::Error;\n\nuse tauri::{App, Manager};\nuse window_shadows::set_shadow;\n\n/// Tauri 启动\npub fn handler(app: &mut App) -> Result<(), Box<dyn Error>> {\n    if cfg!(any(target_os = \"macos\", target_os = \"windows\")) {\n        let window = app.get_window(\"main\").unwrap();\n        set_shadow(&window, true).expect(\"Unknow error in the macos or windows platform\");\n    }\n    Ok(())\n}\n"
  },
  {
    "path": "crates/gui/src-tauri/src/util.rs",
    "content": "use anyhow::Result;\nuse std::process::Command;\n\nuse crate::config::CONFIG;\n\n#[cfg(windows)]\nconst SEPARATOR: &str = \"\\\\\";\n\n#[cfg(not(windows))]\nconst SEPARATOR: &str = \"/\";\n\npub fn bin_dir() -> String {\n    let p = std::env::current_exe()\n        .unwrap()\n        .parent()\n        .unwrap()\n        .to_str()\n        .unwrap()\n        .to_owned();\n    format!(\"{p}{SEPARATOR}\")\n}\n\npub fn play(url: &str) -> Result<bool> {\n    if CONFIG.play.bin.is_empty() {\n        return Result::Err(anyhow::anyhow!(\"please set play bin\"));\n    }\n    let _ = match Command::new(&CONFIG.play.bin)\n        .args(&CONFIG.play.args)\n        .arg(url)\n        .spawn()\n    {\n        Ok(child) => child,\n        Err(_) => return Result::Err(anyhow::anyhow!(\"play error\")),\n    };\n    Ok(true)\n}\n\n#[cfg(test)]\nmod tests {\n\n    #[tokio::test]\n    async fn test_play() {\n        super::play(\"https://cn-gddg-cu-01-02.bilivideo.com/live-bvc/217714/live_7734200_bs_1348183_bluray.flv?expires=1691721645&pt=h5&deadline=1691721645&len=0&oi=989332308&platform=h5&qn=10000&trid=1000241c8c242c7547639410b015865e52c8&uipk=100&uipv=100&nbs=1&uparams=cdn,deadline,len,oi,platform,qn,trid,uipk,uipv,nbs&cdn=cn-gotcha01&upsig=6750be48b70e8858b347738ba6e8404c&sk=15a1692aea632e29feeea2a18b1d9063&p2p_type=0&sl=10&free_type=0&mid=0&sid=cn-gddg-cu-01-02&chash=0&sche=ban&score=18&pp=rtmp&source=onetier&trace=8a0&site=21ff47c9de1e6b98250021b0a0e34eb5&order=1\").unwrap();\n    }\n}\n"
  },
  {
    "path": "crates/gui/src-tauri/tauri.conf.json",
    "content": "{\n    \"build\": {\n        \"beforeDevCommand\": \"yarn dev\",\n        \"beforeBuildCommand\": \"yarn build\",\n        \"devPath\": \"http://localhost:1420\",\n        \"distDir\": \"../dist\",\n        \"withGlobalTauri\": false\n    },\n    \"package\": {\n        \"productName\": \"seam\",\n        \"version\": \"0.1.8\"\n    },\n    \"tauri\": {\n        \"bundle\": {\n            \"active\": true,\n            \"targets\": \"all\",\n            \"identifier\": \"com.borber.seam\",\n            \"icon\": [\n                \"icons/32x32.png\",\n                \"icons/128x128.png\",\n                \"icons/128x128@2x.png\",\n                \"icons/icon.icns\",\n                \"icons/icon.ico\"\n            ]\n        },\n        \"allowlist\": {\n            \"all\": false,\n            \"shell\": {\n                \"all\": false,\n                \"open\": true\n            },\n            \"window\": {\n                \"startDragging\": true,\n                \"show\": true,\n                \"maximize\": true,\n                \"minimize\": true,\n                \"close\": true\n            },\n            \"dialog\": {\n                \"all\": true,\n                \"ask\": true,\n                \"confirm\": true,\n                \"message\": true,\n                \"open\": true,\n                \"save\": true\n            }\n        },\n        \"security\": {\n            \"csp\": null\n        },\n        \"windows\": [\n            {\n                \"fullscreen\": false,\n                \"resizable\": false,\n                \"title\": \"Seam\",\n                \"width\": 1000,\n                \"height\": 600,\n                \"decorations\": false,\n                \"center\": true,\n                \"visible\": false\n            }\n        ]\n    }\n}"
  },
  {
    "path": "crates/gui/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"strict\": true,\n    \"target\": \"ESNext\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Node\",\n    \"allowSyntheticDefaultImports\": true,\n    \"esModuleInterop\": true,\n    \"jsx\": \"preserve\",\n    \"jsxImportSource\": \"solid-js\",\n    \"types\": [\"vite/client\"],\n    \"noEmit\": true,\n    \"isolatedModules\": true\n  }\n}\n"
  },
  {
    "path": "crates/gui/vite.config.ts",
    "content": "import { defineConfig, type UserConfigExport } from \"vite\";\nimport solidPlugin from \"vite-plugin-solid\";\n\n// https://vitejs.dev/config/\nconst config: UserConfigExport = async () => ({\n  plugins: [solidPlugin()],\n\n  // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`\n  // prevent vite from obscuring rust errors\n  clearScreen: false,\n  // tauri expects a fixed port, fail if that port is not available\n  server: {\n    port: 1420,\n    strictPort: true,\n  },\n  // to make use of `TAURI_DEBUG` and other env variables\n  // https://tauri.studio/v1/api/config#buildconfig.beforedevcommand\n  envPrefix: [\"VITE_\", \"TAURI_\"],\n  build: {\n    // Tauri supports es2021\n    target: process.env.TAURI_PLATFORM == \"windows\" ? \"chrome105\" : \"safari13\",\n    // don't minify for debug builds\n    minify: !process.env.TAURI_DEBUG || \"esbuild\",\n    // produce sourcemaps for debug builds\n    sourcemap: !!process.env.TAURI_DEBUG,\n  },\n  css: {\n    modules: {\n      localsConvention: 'camelCase', // 修改生成的配置对象的key的展示形式(驼峰还是中划线形式)\n      scopeBehaviour: 'local', // 配置当前的模块化行为是模块化还是全局化 (有hash就是开启了模块化的一个标志, 因为他可以保证产生不同的hash值来控制我们的样式类名不被覆盖)\n      generateScopedName: '[name]_[local]_[hash:4]',\n      // globalModulePaths: [\"./componentB.module.css\"], // 代表不想参与到css模块化的路径\n    }\n  }\n});\n\nexport default defineConfig(config);\n"
  },
  {
    "path": "crates/marcos/Cargo.toml",
    "content": "[package]\nname = \"seam_marcos\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\nquote = \"1\"\nsyn = { version = \"2\", features = ['full'] }\nproc-macro2 = \"1\"\nregex = \"1\"\n\n[lib]\nproc-macro = true\n"
  },
  {
    "path": "crates/marcos/src/lib.rs",
    "content": "use std::path::Path;\n\nuse proc_macro::TokenStream;\nuse quote::quote;\n\n/// 返回 `fn all() -> HashMap<String, Box<dyn Live>>` 函数\n///\n/// 通过扫描 live 文件夹，自动生成，返回所有直播平台对应的 hashmap\n///\n/// 需要引入:\n///     - HashMap: std::collections::HashMap 或 hashbrown::HashMap 均可\n///     - Live: seam_core::live::Live\n///     - Arc: std::sync::Arc\n///\n/// 因为固定了扫描 live 文件夹，所以这个宏只能在 seam_core 中使用\n#[proc_macro]\npub fn gen_all(_: TokenStream) -> TokenStream {\n    // 获取 live 文件夹位置\n    let root = std::env::var(\"CARGO_MANIFEST_DIR\").unwrap();\n    let path = Path::new(&root).join(\"src\").join(\"live\");\n    let path = path.as_path();\n\n    let mut lives = vec![];\n\n    // 遍历 live 文件夹, 找出所有的rust源文件, 获取文件名\n    for entry in std::fs::read_dir(path).unwrap() {\n        let entry = entry.unwrap();\n        let file = entry.path();\n        // 判断是否为文件 && 是否为rs文件\n        if file.is_file() && file.extension().unwrap_or_default() == \"rs\" {\n            let file = file.file_stem().unwrap().to_str().unwrap();\n            if file != \"mod\" {\n                lives.push(file.to_owned())\n            }\n        }\n    }\n\n    // 通过文件名, 生成对应的代码\n    let lives = lives\n        .into_iter()\n        .map(|live| {\n            let ident = syn::Ident::new(&live, proc_macro2::Span::call_site());\n            quote! {\n                map.insert(String::from(#live), Arc::new(crate::live::#ident::Client));\n            }\n        })\n        .collect::<Vec<_>>();\n\n    quote! {\n        /// 返回 core 支持的所有平台\n        ///\n        /// 请参照 [Live](seam_core::live::Live) 的文档\n        pub fn all() -> HashMap<String, Arc<dyn Live>> {\n            let mut map: HashMap<String, Arc<dyn Live>> = HashMap::new();\n            #(#lives)*\n            map\n        }\n    }\n    .into()\n}\n\n#[proc_macro]\npub fn gen_test(input: TokenStream) -> TokenStream {\n    let arg: String = input.to_string();\n    let code = quote! {\n        mod tests {\n            use super::*;\n            #[tokio::test]\n            async fn test_get() {\n                let cli = Client;\n                match cli.get(#arg, None).await {\n                    Ok(node) => println!(\"{}\", node.json()),\n                    Err(e) => println!(\"{e}\"),\n                }\n            }\n        }\n    };\n    code.into()\n}\n"
  },
  {
    "path": "crates/status/Cargo.toml",
    "content": "[package]\nname = \"seam_status\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\nthiserror = \"1\"\nserde_json = \"1\"\ntokio = { version = \"1\", features = [\"full\"] }\nreqwest = { version = \"0.11\", features = [\"json\"] }\nonce_cell = \"1\"\nasync-trait = \"0.1\"\nregex = \"1\"\nurlencoding = \"2\"\n\n\n[target.'cfg(unix)'.dependencies]\nopenssl = { version = '0.10', features = [\"vendored\"] }\n"
  },
  {
    "path": "crates/status/src/common.rs",
    "content": "use once_cell::sync::Lazy;\nuse reqwest::Client;\n\n#[allow(dead_code)]\npub 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\";\n\npub static CLIENT: Lazy<Client> = Lazy::new(Client::new);\n"
  },
  {
    "path": "crates/status/src/error.rs",
    "content": "use thiserror::Error;\n\npub type Result<T> = std::result::Result<T, SeamStatusError>;\n\n#[derive(Error, Debug)]\npub enum SeamStatusError {\n    #[error(\"Request error: {0}\")]\n    Request(#[from] reqwest::Error),\n    #[error(\"Type error: {0}\")]\n    Type(String),\n    #[error(\"Serde json error: {0}\")]\n    Json(#[from] serde_json::Error),\n    #[error(\"regex error: {0}\")]\n    Regex(#[from] regex::Error),\n    #[error(\"urlencoding error: {0}\")]\n    Decode(#[from] std::string::FromUtf8Error),\n    #[error(\"InvalidHeaderValue error: {0}\")]\n    InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),\n    #[error(\"ParseInt error: {0}\")]\n    ParseInt(#[from] std::num::ParseIntError),\n    #[error(\"{0}\")]\n    Plugin(String),\n    #[error(\"Error msg: {0}\")]\n    Unknown(String),\n}\n"
  },
  {
    "path": "crates/status/src/lib.rs",
    "content": "//! 直播状态检测相关模块。\n//!\n//! 本模块提供了标准化的直播状态检测的 async trait\n\nuse async_trait::async_trait;\n\nuse error::Result;\n\nmod common;\npub mod error;\npub mod status;\n\n#[async_trait]\npub trait StatusTrait {\n    // 获取直播间状态\n    // rid: 直播间号\n    async fn status(rid: &str) -> Result<bool>;\n}\n"
  },
  {
    "path": "crates/status/src/status/bili.rs",
    "content": "use crate::{common::CLIENT, error::Result, StatusTrait};\nuse async_trait::async_trait;\n\npub struct Status;\n\nconst URL: &str = \"https://api.live.bilibili.com/room/v1/Room/room_init\";\n\n#[async_trait]\nimpl StatusTrait for Status {\n    async fn status(rid: &str) -> Result<bool> {\n        let resp: serde_json::Value = CLIENT\n            .get(URL)\n            .query(&[(\"id\", rid)])\n            .send()\n            .await?\n            .json()\n            .await?;\n        println!(\"{:#?}\", resp);\n        Ok(resp[\"data\"][\"live_status\"].as_i64() == Some(1))\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[tokio::test]\n    async fn test_status() {\n        match Status::status(\"6\").await {\n            Ok(true) => println!(\"已开播\"),\n            _ => println!(\"未开播\"),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/status/src/status/cc.rs",
    "content": "use crate::{common::CLIENT, error::Result, StatusTrait};\nuse async_trait::async_trait;\n\npub struct Status;\n\nconst URL: &str = \"https://cc.163.com/\";\n\n#[async_trait]\nimpl StatusTrait for Status {\n    async fn status(rid: &str) -> Result<bool> {\n        let text = CLIENT\n            .get(format!(\"{URL}{rid}\"))\n            .send()\n            .await\n            .unwrap()\n            .text()\n            .await\n            .unwrap();\n        Ok(text.contains(\"quickplay\"))\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[tokio::test]\n    async fn test_status() {\n        match Status::status(\"361433\").await {\n            Ok(true) => println!(\"已开播\"),\n            _ => println!(\"未开播\"),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/status/src/status/douyin.rs",
    "content": "use crate::{\n    common::{CLIENT, USER_AGENT},\n    error::Result,\n    StatusTrait,\n};\nuse async_trait::async_trait;\nuse regex::Regex;\nuse reqwest::header::HeaderMap;\nuse urlencoding::decode;\n\npub struct Status;\n\nconst URL: &str = \"https://live.douyin.com/\";\n\n#[async_trait]\nimpl StatusTrait for Status {\n    async fn status(rid: &str) -> Result<bool> {\n        let mut header_map = HeaderMap::new();\n        // 更新 cookie\n        header_map.insert(\"user-agent\", USER_AGENT.parse()?);\n        let resp = CLIENT\n            .get(format!(\"{URL}{rid}\"))\n            .headers(header_map.clone())\n            .send()\n            .await?;\n        header_map.insert(\"cookie\", resp.headers().get(\"set-cookie\").unwrap().clone());\n        // 通过网页内容获取直播地址\n        let resp = CLIENT\n            .get(format!(\"{URL}{rid}\"))\n            .headers(header_map)\n            .send()\n            .await?;\n        let resp_text = resp.text().await?;\n\n        let re =\n            Regex::new(r#\"<script id=\"RENDER_DATA\" type=\"application/json\">([\\s\\S]*?)</script>\"#)?;\n        let json = decode(re.captures(&resp_text).unwrap().get(1).unwrap().as_str())?;\n        let json: serde_json::Value = serde_json::from_str(&json)?;\n        let status = &json[\"app\"][\"initialState\"][\"roomStore\"][\"roomInfo\"][\"room\"][\"status\"];\n        Ok(status.as_i64().is_some())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[tokio::test]\n    async fn test_status() {\n        match Status::status(\"82671945773\").await {\n            Ok(true) => println!(\"已开播\"),\n            _ => println!(\"未开播\"),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/status/src/status/mod.rs",
    "content": "pub mod bili;\npub mod cc;\npub mod douyin;\n"
  },
  {
    "path": "doc/配置说明.md",
    "content": "## 获取 cookie\n\n1. 打开直播网页, 登录\n2. 按 `F12` , 或 `Shift + Ctrl + i` 或 菜单打开`开发者工具`\n3. 顶部标签切换到`网络`或`network`\n4. 刷新网页\n5. 找到和你地址栏一致的请求\n6. 点击该请求\n7. 在右侧的请求标头找到`cookie`字段\n"
  },
  {
    "path": "doc/额外安装.md",
    "content": "## 播放\n\n### mpv\n"
  },
  {
    "path": "justfile",
    "content": "[private]\ndefault:\n    @just --list\n\n# 编译 CLI\ncb:\n    cargo build --package seam -r\n\n# 编译 GUI\ngb:\n    cd ./crates/gui; \\\n    yarn tauri build\n\n# 调试 GUI\ngd:\n    cd ./crates/gui; \\\n    yarn tauri dev\n\n# 更新 GUI 版本号\ngv:\n    @lua ./script/gui_version.lua\n\n# 更新 GUI 依赖\ngu:\n    cd ./crates/gui; \\\n    yarn upgrade-interactive  --latest; \\\n    cargo update\n"
  },
  {
    "path": "script/gui_version.lua",
    "content": "Root = io.popen(\"git rev-parse --show-toplevel\"):read(\"*l\")\nFile = Root .. \"/crates/gui/src-tauri/tauri.conf.json\"\nContext = io.open(File, \"r\"):read(\"*a\")\nLastVersion = string.match(Context, '\"version\": \"([%d+.]*%d+)\"')\nio.write(\"Last version: \" .. LastVersion .. \"\\n\" .. \"NewVersion: \")\nNewVersion = io.read()\n\n--- 更新 tauri.conf.json 文件中的版本号\nContext = string.gsub(Context, '\"version\": \"([%d+.]*%d+)\"', '\"version\": \"' .. NewVersion .. '\"', 1)\nio.open(File, \"w\"):write(Context)\n\n--- 更新 Cargo.toml 文件中的版本号\nFile = Root .. \"/crates/gui/src-tauri/Cargo.toml\"\nContext = io.open(File, \"r\"):read(\"*a\")\nContext = string.gsub(Context, 'version = \"([%d+.]*%d+)\"', 'version = \"' .. NewVersion .. '\"', 1)\nio.open(File, \"w\"):write(Context)\n\n--- 更新 App.tsx 文件中的版本号\nFile = Root .. \"/crates/gui/src/App.tsx\"\nContext = io.open(File, \"r\"):read(\"*a\")\nContext = string.gsub(Context, '<div class=\"version\">([%d+.]*%d+)</div>',\n    '<div class=\"version\">' .. NewVersion .. '</div>', 1)\nio.open(File, \"w\"):write(Context)\n\n--- 更新 package.json 文件中的版本号\nFile = Root .. \"/crates/gui/package.json\"\nContext = io.open(File, \"r\"):read(\"*a\")\nContext = string.gsub(Context, '\"version\": \"([%d+.]*%d+)\"', '\"version\": \"' .. NewVersion .. '\"', 1)\nio.open(File, \"w\"):write(Context)\n"
  }
]