Full Code of Borber/SBtream for AI

master 9c8ccdd04788 cached
142 files
198.2 KB
66.8k tokens
208 symbols
1 requests
Download .txt
Showing preview only (238K chars total). Download the full file or copy to clipboard to get everything.
Repository: Borber/SBtream
Branch: master
Commit: 9c8ccdd04788
Files: 142
Total size: 198.2 KB

Directory structure:
gitextract_1s2z3e_6/

├── .github/
│   ├── dependabot.yml
│   └── workflows/
│       ├── release_cli.yml
│       ├── release_gui.yml
│       └── rust-clippy.yml
├── .gitignore
├── Cargo.toml
├── LICENSE-MIT
├── LICENSE-UNLICENSE
├── README.md
├── TODO.md
├── build/
│   ├── build-host-release
│   ├── build-host-release.ps1
│   ├── build-release
│   └── build-release-zigbuild
├── config.toml
├── crates/
│   ├── cli/
│   │   ├── CHANGELOG.md
│   │   ├── Cargo.toml
│   │   ├── README.md
│   │   └── src/
│   │       ├── common.rs
│   │       ├── config.rs
│   │       ├── main.rs
│   │       └── util.rs
│   ├── core/
│   │   ├── Cargo.toml
│   │   └── src/
│   │       ├── common.rs
│   │       ├── error.rs
│   │       ├── lib.rs
│   │       ├── live/
│   │       │   ├── afreeca.rs
│   │       │   ├── bili.rs
│   │       │   ├── cc.rs
│   │       │   ├── douyin.rs
│   │       │   ├── douyu.rs
│   │       │   ├── flex.rs
│   │       │   ├── huajiao.rs
│   │       │   ├── huya.rs
│   │       │   ├── inke.rs
│   │       │   ├── kk.rs
│   │       │   ├── ks.rs
│   │       │   ├── mht.rs
│   │       │   ├── mod.rs
│   │       │   ├── now.rs
│   │       │   ├── panda.rs
│   │       │   ├── qf.rs
│   │       │   ├── twitch.rs
│   │       │   ├── wink.rs
│   │       │   └── yqs.rs
│   │       └── util.rs
│   ├── danmu/
│   │   ├── Cargo.toml
│   │   └── src/
│   │       ├── danmu/
│   │       │   ├── afreeca.rs
│   │       │   ├── bili.rs
│   │       │   ├── cc.rs
│   │       │   ├── douyin.rs
│   │       │   ├── douyu.rs
│   │       │   ├── flex.rs
│   │       │   ├── huajiao.rs
│   │       │   ├── huya.rs
│   │       │   ├── inke.rs
│   │       │   ├── kk.rs
│   │       │   ├── ks.rs
│   │       │   ├── mht.rs
│   │       │   ├── mod.rs
│   │       │   ├── now.rs
│   │       │   ├── panda.rs
│   │       │   ├── qf.rs
│   │       │   ├── wink.rs
│   │       │   └── yqs.rs
│   │       ├── error.rs
│   │       └── lib.rs
│   ├── gui/
│   │   ├── .eslintrc.json
│   │   ├── .gitignore
│   │   ├── .vscode/
│   │   │   └── extensions.json
│   │   ├── CHANGELOG.md
│   │   ├── README.md
│   │   ├── index.html
│   │   ├── package.json
│   │   ├── src/
│   │   │   ├── App.css
│   │   │   ├── App.tsx
│   │   │   ├── components/
│   │   │   │   ├── Control.tsx
│   │   │   │   ├── GoodItem.tsx
│   │   │   │   ├── Live.tsx
│   │   │   │   ├── Panel.tsx
│   │   │   │   ├── SideBar.tsx
│   │   │   │   ├── SideItem.tsx
│   │   │   │   └── TopBar.tsx
│   │   │   ├── css/
│   │   │   │   ├── Chart.css
│   │   │   │   ├── Control.css
│   │   │   │   ├── Good.css
│   │   │   │   ├── GoodItem.css
│   │   │   │   ├── Home.css
│   │   │   │   ├── Live.css
│   │   │   │   ├── Panel.css
│   │   │   │   ├── Setting.css
│   │   │   │   ├── SideBar.css
│   │   │   │   ├── SideItem.css
│   │   │   │   └── TopBar.css
│   │   │   ├── icon/
│   │   │   │   └── icon.tsx
│   │   │   ├── index.tsx
│   │   │   ├── model/
│   │   │   │   ├── Live.tsx
│   │   │   │   ├── Record.tsx
│   │   │   │   └── Resp.tsx
│   │   │   ├── pages/
│   │   │   │   ├── Chart.tsx
│   │   │   │   ├── Good.tsx
│   │   │   │   ├── Home.tsx
│   │   │   │   └── Setting.tsx
│   │   │   └── styles.css
│   │   ├── src-tauri/
│   │   │   ├── .gitignore
│   │   │   ├── Cargo.toml
│   │   │   ├── build.rs
│   │   │   ├── icons/
│   │   │   │   └── icon.icns
│   │   │   ├── src/
│   │   │   │   ├── command/
│   │   │   │   │   ├── live.rs
│   │   │   │   │   ├── mod.rs
│   │   │   │   │   ├── refresh.rs
│   │   │   │   │   └── subscribe.rs
│   │   │   │   ├── common.rs
│   │   │   │   ├── config.rs
│   │   │   │   ├── database/
│   │   │   │   │   ├── mod.rs
│   │   │   │   │   └── subscribe.rs
│   │   │   │   ├── main.rs
│   │   │   │   ├── manager/
│   │   │   │   │   ├── mod.rs
│   │   │   │   │   └── refresh.rs
│   │   │   │   ├── model.rs
│   │   │   │   ├── resp.rs
│   │   │   │   ├── service/
│   │   │   │   │   ├── mod.rs
│   │   │   │   │   └── subscribe.rs
│   │   │   │   ├── setup.rs
│   │   │   │   └── util.rs
│   │   │   └── tauri.conf.json
│   │   ├── tsconfig.json
│   │   └── vite.config.ts
│   ├── marcos/
│   │   ├── Cargo.toml
│   │   └── src/
│   │       └── lib.rs
│   └── status/
│       ├── Cargo.toml
│       └── src/
│           ├── common.rs
│           ├── error.rs
│           ├── lib.rs
│           └── status/
│               ├── bili.rs
│               ├── cc.rs
│               ├── douyin.rs
│               └── mod.rs
├── doc/
│   ├── 配置说明.md
│   └── 额外安装.md
├── justfile
└── script/
    └── gui_version.lua

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: daily
  - package-ecosystem: cargo
    directory: /
    schedule:
      interval: daily


================================================
FILE: .github/workflows/release_cli.yml
================================================
name: Build CLI Releases

permissions:
  contents: write

on:
  push:
    tags:
      - v_cli.*

env:
  CARGO_TERM_COLOR: always

jobs:
  create_release:
    runs-on: ubuntu-latest
    outputs:
      changes: ${{ steps.changelog_reader.outputs.changes }}
      version: ${{ steps.changelog_reader.outputs.VERSION }}
    steps:
      - uses: actions/checkout@v4
      - name: Get version number
        id: get_version
        run: |
          VERSION=${GITHUB_REF#refs/tags/}
          VERSION=${VERSION/v_cli./}
          echo "::set-output name=version::$VERSION"
      - name: Changelog Reader
        id: changelog_reader
        uses: mindsers/changelog-reader-action@v2.2.2
        with:
          path: './crates/cli/CHANGELOG.md'
          version: ${{ steps.get_version.outputs.version }}

  build-cross:
    needs: create_release
    runs-on: ubuntu-latest
    env:
      RUST_BACKTRACE: full
    strategy:
      fail-fast: false
      matrix:
        target:
          - i686-unknown-linux-musl
          - x86_64-pc-windows-gnu
          - x86_64-unknown-linux-gnu
          - x86_64-unknown-linux-musl
          - armv7-unknown-linux-musleabihf
          - armv7-unknown-linux-gnueabihf
          - arm-unknown-linux-gnueabi
          - arm-unknown-linux-gnueabihf
          - arm-unknown-linux-musleabi
          - arm-unknown-linux-musleabihf
          - aarch64-unknown-linux-gnu
          - aarch64-unknown-linux-musl

    steps:
      - uses: actions/checkout@v4

      - name: Install Rust
        uses: actions-rs/toolchain@v1
        with:
          profile: minimal
          target: ${{ matrix.target }}
          toolchain: stable
          default: true
          override: true

      - name: Install cross
        run: cargo install cross

      - name: Build ${{ matrix.target }}
        timeout-minutes: 120
        run: |
          compile_target=${{ matrix.target }}

          # if [[ "$compile_target" == *"-linux-"* || "$compile_target" == *"-apple-"* ]]; then
          #   compile_features="-f local-redir -f local-tun"
          # fi

          if [[ "$compile_target" == "mips-"* || "$compile_target" == "mipsel-"* || "$compile_target" == "mips64-"* || "$compile_target" == "mips64el-"* ]]; then
            sudo apt-get update -y && sudo apt-get install -y upx;
            if [[ "$?" == "0" ]]; then
              compile_compress="-u"
            fi
          fi

          cd build
          chmod +x build-release
          ./build-release -t ${{ matrix.target }} $compile_features $compile_compress

      - name: Upload Github Assets
        uses: softprops/action-gh-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          name: Seam CLI ${{ needs.create_release.outputs.version }}
          body: |
            ${{ needs.create_release.outputs.changes }}
          files: build/release/*
          draft: false
          prerelease: false

  build-unix:
    needs: create_release
    runs-on: ${{ matrix.os }}
    env:
      BUILD_EXTRA_FEATURES: ''
      RUST_BACKTRACE: full
    strategy:
      matrix:
        os: [macos-latest]
        target:
          - x86_64-apple-darwin
          - aarch64-apple-darwin
    steps:
      - uses: actions/checkout@v4

      - name: Install GNU tar
        if: runner.os == 'macOS'
        run: |
          brew install gnu-tar
          echo "/usr/local/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH

      - name: Install Rust
        uses: actions-rs/toolchain@v1
        with:
          profile: minimal
          target: ${{ matrix.target }}
          toolchain: stable
          default: true
          override: true

      - name: Build release
        shell: bash
        run: |
          chmod +x ./build/build-host-release
          ./build/build-host-release -t ${{ matrix.target }}

      - name: Upload Github Assets
        uses: softprops/action-gh-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          name: Seam CLI ${{ needs.create_release.outputs.version }}
          body: |
            ${{ needs.create_release.outputs.changes }}
          files: build/release/*
          draft: false
          prerelease: false

  build-windows:
    needs: create_release
    runs-on: windows-latest
    env:
      RUSTFLAGS: '-C target-feature=+crt-static'
      RUST_BACKTRACE: full
    steps:
      - uses: actions/checkout@v4

      - name: Install Rust
        uses: actions-rs/toolchain@v1
        with:
          profile: minimal
          toolchain: stable
          default: true
          override: true

      - name: Build release
        run: |
          pwsh ./build/build-host-release.ps1

      - name: Upload Github Assets
        uses: softprops/action-gh-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          name: Seam CLI ${{ needs.create_release.outputs.version }}
          body: |
            ${{ needs.create_release.outputs.changes }}
          files: build/release/*
          draft: false
          prerelease: false


================================================
FILE: .github/workflows/release_gui.yml
================================================
name: Build GUI Release

permissions:
  contents: write

on:
  push:
    tags:
      - v_gui.*

jobs:
  create_release:
    runs-on: ubuntu-latest
    outputs:
      changes: ${{ steps.changelog_reader.outputs.changes }}
      version: ${{ steps.changelog_reader.outputs.VERSION }}
    steps:
      - uses: actions/checkout@v4
      - name: Get version number
        id: get_version
        run: |
          VERSION=${GITHUB_REF#refs/tags/}
          VERSION=${VERSION/v_gui./}
          echo "::set-output name=version::$VERSION"
      - name: Changelog Reader
        id: changelog_reader
        uses: mindsers/changelog-reader-action@v2.2.2
        with:
          path: './crates/gui/CHANGELOG.md'
          version: ${{ steps.get_version.outputs.version }}

  build_seam:
    needs: create_release
    strategy:
      fail-fast: false
      matrix:
        platform: [macos-latest, ubuntu-latest, windows-latest]
    runs-on: ${{ matrix.platform }}
    steps:
      - uses: actions/checkout@v4
      - name: Set up node
        uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'yarn'
          cache-dependency-path: 'crates/gui/yarn.lock'
      - name: Install Rust stable
        uses: dtolnay/rust-toolchain@stable
      - name: install dependencies (ubuntu only)
        if: matrix.platform == 'ubuntu-latest'
        run: |
          sudo apt-get update
          sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
      - name: install frontend dependencies
        run: |
          cd crates/gui
          yarn install --frozen-lockfile
      - uses: tauri-apps/tauri-action@v0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          projectPath: 'crates/gui'
          tagName: '${{github.ref_name}}'
          releaseName: 'Seam GUI ${{ needs.create_release.outputs.version }}'
          releaseBody: '${{ needs.create_release.outputs.changes }}'
          releaseDraft: false
          prerelease: false


================================================
FILE: .github/workflows/rust-clippy.yml
================================================
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# rust-clippy is a tool that runs a bunch of lints to catch common
# mistakes in your Rust code and help improve your Rust code.
# More details at https://github.com/rust-lang/rust-clippy
# and https://rust-lang.github.io/rust-clippy/

name: rust-clippy analyze

on:
  push:
    branches: [ "master" ]
  pull_request:
    # The branches below must be a subset of the branches above
    branches: [ "master" ]
  schedule:
    - cron: '31 23 * * 3'

jobs:
  rust-clippy-analyze:
    name: Run rust-clippy analyzing
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
      actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Rust toolchain
        uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af #@v1
        with:
          profile: minimal
          toolchain: stable
          components: clippy
          override: true

      - name: Install required cargo
        run: cargo install clippy-sarif sarif-fmt

      - name: Run rust-clippy
        run:
          cargo clippy
          --all-features
          --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt
        continue-on-error: true

      - name: Upload analysis results to GitHub
        uses: github/codeql-action/upload-sarif@v2
        with:
          sarif_file: rust-clippy-results.sarif
          wait-for-processing: true


================================================
FILE: .gitignore
================================================
/target
/.idea
/bak

================================================
FILE: Cargo.toml
================================================
[workspace]
members = [
    "crates/cli",
    "crates/gui/src-tauri",
    "crates/core",
    "crates/danmu",
    "crates/marcos",
    "crates/status",
]
resolver = "2"

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = "symbols"


================================================
FILE: LICENSE-MIT
================================================
MIT License

Copyright (c) 2023 Borber

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: LICENSE-UNLICENSE
================================================
This is free and unencumbered software released into the public domain.

Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.

In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

For more information, please refer to <http://unlicense.org>


================================================
FILE: README.md
================================================
<p align="center">
    <img src="./assets/icon.png" style="width: 150px;" alt="Seam" />
</p>

<h2 align="center">
  Seam
</h2>

<h2 align="center">
  <a href="https://github.com/Borber/seam">
    <img src="https://img.shields.io/badge/github-Borber/seam-8da0cb.svg?style=for-the-badge&logo=github" alt="Github"/>
  </a>
  <a href="https://github.com/Borber/seam/releases/latest">
    <img src="https://img.shields.io/github/downloads/Borber/seam/total.svg?style=for-the-badge&color=82E0AA&logo=github" alt="Downloads"/>
  </a>
  <img src="https://img.shields.io/github/license/borber/seam?color=%2398cbed&logo=rust&style=for-the-badge" alt="LICENSE"/>
</h2>

> 原 `SBtream` 项目, 经历 python 不成熟的模仿, Java 重构烂尾, 目前使用 rust 进行重构开发

多平台直播源地址获取

# 待办

欢迎各位大佬 PR , 积极响应, 友善沟通, 快速 CR, 给您最好的开源体验

-   [ ] GUI 从获取模式切换为订阅模式
-   [ ] 添加日志模块, 以便于用户反馈问题
    -   [ ] 输出日志文件
-   [ ] 链接识别
    -   规定每个平台都需要实现判断一个链接是否是自己的, 并返回正确的 rid
-   [ ] 提取 CLI GUI 公共模块
    -   [ ] util
    -   [ ] config
        -   即使 cli 和 gui 有部分不重叠的部分, 但应该还是重叠部分更多
-   [ ] I18N
-   [ ] GUI action 添加便携版本, 方便已经安装了 WebView2 的用户使用

# GUI

![GUI](assets/gui.png)

## [详情](crates/gui/README.md)

# CLI

```bash
❯ .\seam.exe -l douyu -i 88080
http://url1

http://url2
```

## [详情](crates/cli/README.md)

# 下载

[Releases · seam](https://github.com/Borber/seam/releases) 下载 `GUI`/`CLI`可执行文件

# 使用

| **平台**                              | **代号** | **`<RID>` 位置**                                                         | **详情** | **弹幕** |
| ------------------------------------- | -------- | ------------------------------------------------------------------------ | ------------ | -------- |
| [B 站](https://live.bilibili.com/)    | bili     | `https://live.bilibili.com/<RID>`                                        | ✅           | ✅       |
| [斗鱼](https://www.douyu.com/)        | douyu    | `https://www.douyu.com/<RID>` 或 `https://www.douyu.com/xx/xx?rid=<RID>` | ✅           |          |
| [抖音](https://live.douyin.com/)      | douyin   | `https://live.douyin.com/<RID>`                                          | ✅           |          |
| [虎牙](https://huya.com/)             | huya     | `https://huya.com/<RID>`                                                 |              |          |
| [快手](https://live.kuaishou.com/)    | ks       | `https://live.kuaishou.com/u/<RID>`                                      |              |          |
| [CC](https://cc.163.com/)             | cc       | `https://cc.163.com/<RID>`                                               |              |          |
| [花椒](https://www.huajiao.com/)      | huajiao  | `https://www.huajiao.com/l/<RID>`                                        |              |          |
| [艺气山](https://www.173.com/)        | yqs      | `https://www.173.com/<RID>`                                              |              |          |
| [棉花糖](https://www.2cq.com/)        | mht      | `https://www.2cq.com/<RID>`                                              |              |          |
| [kk](https://www.kktv5.com/)          | kk       | `https://www.kktv5.com/show/<RID>`                                       |              |          |
| [千帆](https://qf.56.com/)            | qf       | `https://qf.56.com/<RID>`                                                |              |          |
| [Now](https://now.qq.com/)            | now      | `https://now.qq.com/pcweb/story.html?roomid=<RID>`                       |              |          |
| [映客](https://www.inke.cn/)          | inke     | `https://www.inke.cn/liveroom/index.html?uid=<RID>`                      |              |          |
| [afreeca](https://afreecatv.com/)     | afreeca  | `https://bj.afreecatv.com/<RID>`                                         |              |          |
| [panda](https://www.pandalive.co.kr/) | panda    | `https://www.pandalive.co.kr/channel/<RID>`                              |              |          |
| [flex](https://www.flextv.co.kr/)     | flex     | `https://www.flextv.co.kr/channels/<RID>`                                |              |          |
| [wink](https://www.winktv.co.kr/)     | wink     | `https://www.winktv.co.kr/channel/<RID>`                                 |              |          |

# 配置

`config.toml` 放置在 `seam` 可执行文件所在目录下

```toml
# 播放器路径或命令
# 请自行安装播放器, 请确认它可以通过命令行+链接打开视频文件
[play]
# potplayer 样例
# bin = "C:\\Program Files (x86)\\Pure Codec\\x64\\PotPlayerMini64.exe"
bin = "mpv"
# 播放器参数
args = []

# headers 支持所有合法 http 请求头字段
# global 为全局请求头, 会被各平台请求头覆盖
# 请注意 不要覆盖虎牙的 user-agent, 否则会导致获取失败
[headers.global]
# user-agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.200"

# 抖音 cookie 必须
[headers.douyin]
cookie = "xxxx"

# 快手 cookie 必须
[headers.ks]
cookie = "xxxx"

# 斗鱼设置登录账户 cookie 情况下可以获取到备用线路高清源
[headers.douyu]
cookie = "xxxx"

# [rid]: 房间号
# [title]: 标题
# [time]: 时间戳
# [date]: 日期
[file_name]
# 录制文件标题
video = "[rid]-[title]-[date]-[time]"
# danmu文件标题
danmu = "[rid]-[title]-[date]-[time]"


```

> cookie 获取方法: [额外说明](./doc/配置说明.md)

# 路线

[seam](https://github.com/users/Borber/projects/4/views/1)

# 相关项目

-   [seamui](https://github.com/kirito41dd/seamui) 由 [kirito41dd](https://github.com/kirito41dd) 开发的`seam`图形化界面
-   [SeamPotPlayer](https://github.com/chen310/SeamPotPlayer/) 由[chen310](https://github.com/chen310) 开发, 直接在 PotPlayer 中调用 seam 播放直播

## 贡献者

[![GitHub Contributors](https://contrib.rocks/image?repo=Borber/seam)](https://github.com/Borber/seam/graphs/contributors)

# 感谢

-   [wbt5/real-url](https://github.com/wbt5/real-url/)
-   [banner](https://textkool.com/en/ascii-art-generator?hl=default&vl=default&font=Chunky&text=SEAM)
-   [手把手教你破解斗鱼 sign 算法](https://zhuanlan.zhihu.com/p/107330805)

## Star History

<a href="https://github.com/Borber/seam/stargazers">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Borber/seam&type=Date&theme=dark" />
    <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Borber/seam&type=Date" />
    <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Borber/seam&type=Date" />
  </picture>
</a>


================================================
FILE: TODO.md
================================================
-   给 Client 添加函数返回其函数调用信息, 命令, 平台名称


================================================
FILE: build/build-host-release
================================================
#!/bin/bash

BUILD_TARGET=""
BUILD_FEATURES=()
while getopts "t:f:" opt; do
    case $opt in
    t)
        BUILD_TARGET=$OPTARG
        ;;
    f)
        BUILD_FEATURES+=($OPTARG)
        ;;
    ?)
        echo "Usage: $(basename $0) [-t <target-triple>] [-f <feature>]"
        ;;
    esac
done

BUILD_FEATURES+=${BUILD_EXTRA_FEATURES}

ROOT_DIR=$(cd $(dirname $0) && pwd)
VERSION=$(grep -E '^version' "${ROOT_DIR}/../crates/cli/Cargo.toml" | awk '{print $3}' | sed 's/"//g')
HOST_TRIPLE=$(rustc -Vv | grep 'host:' | awk '{print $2}')

echo "Started build release ${VERSION} for ${HOST_TRIPLE} (target: ${BUILD_TARGET}) with features \"${BUILD_FEATURES}\"..."

if [[ "${BUILD_TARGET}" != "" ]]; then
    if [[ "${BUILD_FEATURES}" != "" ]]; then
        cargo build --package seam --release --features "${BUILD_FEATURES}" --target "${BUILD_TARGET}"
    else
        cargo build --package seam --release --target "${BUILD_TARGET}"
    fi
else
    if [[ "${BUILD_FEATURES}" != "" ]]; then
        cargo build --package seam --release --features "${BUILD_FEATURES}"
    else
        cargo build --package seam --release
    fi
fi

if [[ "$?" != "0" ]]; then
    exit 1
fi

if [[ "${BUILD_TARGET}" == "" ]]; then
    BUILD_TARGET=$HOST_TRIPLE
fi

TARGET_SUFFIX=""
if [[ "${BUILD_TARGET}" == *"-windows-"* ]]; then
    TARGET_SUFFIX=".exe"
fi

TARGETS=("seam${TARGET_SUFFIX}" )

RELEASE_FOLDER="${ROOT_DIR}/release"
RELEASE_PACKAGE_NAME="seam-v${VERSION}.${BUILD_TARGET}"

mkdir -p "${RELEASE_FOLDER}"

# Into release folder
if [[ "${BUILD_TARGET}" != "" ]]; then
    cd "${ROOT_DIR}/../target/${BUILD_TARGET}/release"
else
    cd "${ROOT_DIR}/../target/release"
fi

cp "${ROOT_DIR}/../config.toml" ./

if [[ "${BUILD_TARGET}" == *"-windows-"* ]]; then
    # For Windows, use zip

    RELEASE_PACKAGE_FILE_NAME="${RELEASE_PACKAGE_NAME}.zip"
    RELEASE_PACKAGE_FILE_PATH="${RELEASE_FOLDER}/${RELEASE_PACKAGE_FILE_NAME}"
    zip "${RELEASE_PACKAGE_FILE_PATH}" "${TARGETS[@]}" "config.toml"

    if [[ $? != "0" ]]; then
        exit 1
    fi

    # Checksum
    cd "${RELEASE_FOLDER}"
    shasum -a 256 "${RELEASE_PACKAGE_FILE_NAME}" >"${RELEASE_PACKAGE_FILE_NAME}.sha256"
else
    # For others, Linux, OS X, uses tar.xz

    # For Darwin, .DS_Store and other related files should be ignored
    if [[ "$(uname -s)" == "Darwin" ]]; then
        export COPYFILE_DISABLE=1
    fi

    RELEASE_PACKAGE_FILE_NAME="${RELEASE_PACKAGE_NAME}.tar.xz"
    RELEASE_PACKAGE_FILE_PATH="${RELEASE_FOLDER}/${RELEASE_PACKAGE_FILE_NAME}"
    tar -cJf "${RELEASE_PACKAGE_FILE_PATH}" "${TARGETS[@]}" "config.toml"

    if [[ $? != "0" ]]; then
        exit 1
    fi

    # Checksum
    cd "${RELEASE_FOLDER}"
    shasum -a 256 "${RELEASE_PACKAGE_FILE_NAME}" >"${RELEASE_PACKAGE_FILE_NAME}.sha256"
fi

echo "Finished build release ${RELEASE_PACKAGE_FILE_PATH}"


================================================
FILE: build/build-host-release.ps1
================================================
#!pwsh
<#
    OpenSSL is already installed on windows-latest virtual environment.
    If you need OpenSSL, consider install it by:

    choco install openssl
#>
param(
    [Parameter(HelpMessage = "extra features")]
    [Alias('f')]
    [string]$Features
)

$ErrorActionPreference = "Stop"

$TargetTriple = (rustc -Vv | Select-String -Pattern "host: (.*)" | ForEach-Object { $_.Matches.Value }).split()[-1]

Write-Host "Started building release for ${TargetTriple} ..."

if ([string]::IsNullOrEmpty($Features)) {
    cargo build --package seam --release
}
else {
    cargo build --package seam --release --features "${Features}"
}

if (!$?) {
    exit $LASTEXITCODE
}

$Version = (Select-String -Pattern '^version *= *"([^"]*)"$' -Path "${PSScriptRoot}\..\crates\cli\Cargo.toml" | ForEach-Object { $_.Matches.Value }).split()[-1]
$Version = $Version -replace '"'

$PackageReleasePath = "${PSScriptRoot}\release"
$PackageName = "seam-v${Version}.${TargetTriple}.zip"
$PackagePath = "${PackageReleasePath}\${PackageName}"

Write-Host $Version
Write-Host $PackageReleasePath
Write-Host $PackageName
Write-Host $PackagePath

Push-Location "${PSScriptRoot}\..\target\release"
Copy-Item "${PSScriptRoot}\..\config.toml" -Destination "${PSScriptRoot}\..\target\release"

$ProgressPreference = "SilentlyContinue"
New-Item "${PackageReleasePath}" -ItemType Directory -ErrorAction SilentlyContinue
$CompressParam = @{
    LiteralPath     = "seam.exe", "config.toml"
    DestinationPath = "${PackagePath}"
}
Compress-Archive @CompressParam

Write-Host "Created release packet ${PackagePath}"

$PackageChecksumPath = "${PackagePath}.sha256"
$PackageHash = (Get-FileHash -Path "${PackagePath}" -Algorithm SHA256).Hash
"${PackageHash}  ${PackageName}" | Out-File -FilePath "${PackageChecksumPath}"

Write-Host "Created release packet checksum ${PackageChecksumPath}"

================================================
FILE: build/build-release
================================================
#!/bin/bash
# Path: build\release

CUR_DIR=$(cd $(dirname $0) && pwd)
VERSION=$(grep -E '^version' ${CUR_DIR}/../crates/cli/Cargo.toml | awk '{print $3}' | sed 's/"//g')

## Disable macos ACL file
if [[ "$(uname -s)" == "Darwin" ]]; then
    export COPYFILE_DISABLE=1
fi

targets=()
features=()
use_upx=false

while getopts "t:f:u" opt; do
    case $opt in
    t)
        targets+=($OPTARG)
        ;;
    f)
        features+=($OPTARG)
        ;;
    u)
        use_upx=true
        ;;
    ?)
        echo "Usage: $(basename $0) [-t <target-triple>] [-f features] [-u]"
        ;;
    esac
done

features+=${EXTRA_FEATURES}

if [[ "${#targets[@]}" == "0" ]]; then
    echo "Specifying compile target with -t <target-triple>"
    exit 1
fi

if [[ "${use_upx}" = true ]]; then
    if [[ -z "$upx" ]] && command -v upx &>/dev/null; then
        upx="upx -9"
    fi

    if [[ "x$upx" == "x" ]]; then
        echo "Couldn't find upx in PATH, consider specifying it with variable \$upx"
        exit 1
    fi
fi

function build() {
    cd "$CUR_DIR/.."

    TARGET=$1

    RELEASE_DIR="target/${TARGET}/release"
    TARGET_FEATURES="${features[@]}"

    if [[ "${TARGET_FEATURES}" != "" ]]; then
        echo "* Building ${TARGET} package ${VERSION} with features \"${TARGET_FEATURES}\" ..."

        cross build --package seam --target "${TARGET}" \
            --features "${TARGET_FEATURES}" \
            --release
    else
        echo "* Building ${TARGET} package ${VERSION} ..."

        cross build --package seam --target "${TARGET}" \
            --release
    fi

    if [[ $? != "0" ]]; then
        exit 1
    fi

    PKG_DIR="${CUR_DIR}/release"
    mkdir -p "${PKG_DIR}"
    cp "${CUR_DIR}/../config.toml" "${RELEASE_DIR}"
    if [[ "$TARGET" == *"-linux-"* ]]; then
        PKG_NAME="seam-v${VERSION}.${TARGET}.tar.xz"
        PKG_PATH="${PKG_DIR}/${PKG_NAME}"

        cd ${RELEASE_DIR}

        if [[ "${use_upx}" = true ]]; then
            # Enable upx for MIPS.
            $upx seam #>/dev/null
        fi

        echo "* Packaging XZ in ${PKG_PATH} ..."
        tar -cJf ${PKG_PATH} \
            "seam" \
            "config.toml"

        if [[ $? != "0" ]]; then
            exit 1
        fi

        cd "${PKG_DIR}"
        shasum -a 256 "${PKG_NAME}" >"${PKG_NAME}.sha256"
    elif [[ "$TARGET" == *"-windows-"* ]]; then
        PKG_NAME="seam-v${VERSION}.${TARGET}.zip"
        PKG_PATH="${PKG_DIR}/${PKG_NAME}"

        echo "* Packaging ZIP in ${PKG_PATH} ..."
        cd ${RELEASE_DIR}
        zip ${PKG_PATH} \
            "seam.exe" \
            "config.toml"

        if [[ $? != "0" ]]; then
            exit 1
        fi

        cd "${PKG_DIR}"
        shasum -a 256 "${PKG_NAME}" >"${PKG_NAME}.sha256"
    fi

    echo "* Done build package ${PKG_NAME}"
}

for target in "${targets[@]}"; do
    build "$target"
done


================================================
FILE: build/build-release-zigbuild
================================================
#!/bin/bash

CUR_DIR=$(cd $(dirname $0) && pwd)
VERSION=$(grep -E '^version' ${CUR_DIR}/../crates/cli/Cargo.toml | awk '{print $3}' | sed 's/"//g')

## Disable macos ACL file
if [[ "$(uname -s)" == "Darwin" ]]; then
    export COPYFILE_DISABLE=1
fi

targets=()
features=()
use_upx=false

while getopts "t:f:u" opt; do
    case $opt in
    t)
        targets+=($OPTARG)
        ;;
    f)
        features+=($OPTARG)
        ;;
    u)
        use_upx=true
        ;;
    ?)
        echo "Usage: $(basename $0) [-t <target-triple>] [-f features] [-u]"
        ;;
    esac
done

features+=${EXTRA_FEATURES}

if [[ "${#targets[@]}" == "0" ]]; then
    echo "Specifying compile target with -t <target-triple>"
    exit 1
fi

if [[ "${use_upx}" = true ]]; then
    if [[ -z "$upx" ]] && command -v upx &>/dev/null; then
        upx="upx -9"
    fi

    if [[ "x$upx" == "x" ]]; then
        echo "Couldn't find upx in PATH, consider specifying it with variable \$upx"
        exit 1
    fi
fi

function build() {
    cd "$CUR_DIR/.."

    TARGET=$1

    RELEASE_DIR="target/${TARGET}/release"
    TARGET_FEATURES="${features[@]}"

    cp "config.toml" "${RELEASE_DIR}"

    if [[ "${TARGET_FEATURES}" != "" ]]; then
        echo "* Building ${TARGET} package ${VERSION} with features \"${TARGET_FEATURES}\" ..."

        cargo zigbuild --package seam --target "${TARGET}" \
        --features "${TARGET_FEATURES}" \
        --release
    else
        echo "* Building ${TARGET} package ${VERSION} ..."

        cargo zigbuild --package seam --target "${TARGET}" \
        --release
    fi

    if [[ $? != "0" ]]; then
        exit 1
    fi

    PKG_DIR="${CUR_DIR}/release"
    mkdir -p "${PKG_DIR}"

    if [[ "$TARGET" == *"-linux-"* ]]; then
        PKG_NAME="seam-v${VERSION}.${TARGET}.tar.xz"
        PKG_PATH="${PKG_DIR}/${PKG_NAME}"

        cd ${RELEASE_DIR}

        if [[ "${use_upx}" = true ]]; then
            # Enable upx for MIPS.
            $upx sslocal ssserver ssurl ssmanager ssservice #>/dev/null
        fi

        echo "* Packaging XZ in ${PKG_PATH} ..."
        tar -cJf ${PKG_PATH} \
        "seam" \
        "config.toml"

        if [[ $? != "0" ]]; then
            exit 1
        fi

        cd "${PKG_DIR}"
        shasum -a 256 "${PKG_NAME}" >"${PKG_NAME}.sha256"
    elif [[ "$TARGET" == *"-windows-"* ]]; then
        PKG_NAME="seam-v${VERSION}.${TARGET}.zip"
        PKG_PATH="${PKG_DIR}/${PKG_NAME}"

        echo "* Packaging ZIP in ${PKG_PATH} ..."
        cd ${RELEASE_DIR}
        zip ${PKG_PATH} \
        "seam.exe" \
        "config.toml"

        if [[ $? != "0" ]]; then
            exit 1
        fi

        cd "${PKG_DIR}"
        shasum -a 256 "${PKG_NAME}" >"${PKG_NAME}.sha256"
    fi

    echo "* Done build package ${PKG_NAME}"
}

for target in "${targets[@]}"; do
    build "$target"
done


================================================
FILE: config.toml
================================================
# 播放器路径或命令
# 请自行安装播放器, 请确认它可以通过命令行+链接打开视频文件
[play]
# potplayer 样例
# bin = "C:\\Program Files (x86)\\Pure Codec\\x64\\PotPlayerMini64.exe"
bin = "mpv"
# 播放器参数
args = []

# headers 支持所有合法 http 请求头字段
# global 为全局请求头, 会被各平台请求头覆盖
# 请注意 不要覆盖虎牙的 user-agent, 否则会导致获取失败
[headers.global]
# user-agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.200"

# 抖音 cookie 必须
[headers.douyin]
cookie = "xxxx"

# 快手 cookie 必须
[headers.ks]
cookie = "xxxx"

# 斗鱼设置登录账户 cookie 情况下可以获取到备用线路高清源
[headers.douyu]
cookie = "xxxx"


# [rid]: 房间号
# [title]: 标题
# [time]: 时间戳
# [date]: 日期
[file_name]
# 录制文件标题
video = "[rid]-[title]-[date]-[time]"
# danmu文件标题
danmu = "[rid]-[title]-[date]-[time]"


================================================
FILE: crates/cli/CHANGELOG.md
================================================
# Changelog

## [0.1.39]

修复 bili 无法获取原画 [#277](https://github.com/Borber/seam/issues/277)

## [0.1.38]

修复 斗鱼部分房间获取不到 [#255](https://github.com/Borber/seam/issues/255)

## [0.1.37]

清除调试代码

## [0.1.36]

**请重新获取最新的快手 cookie**

### 修复

-   修复 kuaishou 直播源获取

## [0.1.35]

**请重新获取最新的抖音 cookie**

### 修复

-   修复 douyin 直播源获取

### 更新

-   douyu 支持 直播间封面, 主播名,主播头像获取

## [0.1.34]

### 更新

-   修复抖音, 获取最高清晰度

## [0.1.33]

### 更新

-   调整 cli 默认显示,仅显示直播源
-   新增 all 参数

## [0.1.32]

### 更新

-   目前所有支持平台,房间名获取均可用

## [0.1.31]

### 修复

-   斗鱼获取最清晰线路

### 更新

-   斗鱼在设置 cookie 的情况下尝试获取 备用线路

## [0.1.30]

### 更新

-   调整子命令
-   拆分未开播与解析错误

## [0.1.29]

### 更新

-   捕获不支持的平台
-   直播名获取
    -   千帆直播
    -   映客直播
-   更新依赖
-   引入 cookie , 但目前还不能设置
-   调整报错信息输出

### 更改

-   动态派发 Client
-   调整代码结构
-   js 运行时重新引入 douyu 不用再额外下载 jin 了

## [0.1.28]

### Changed

-   拆分 core danmu status 模块
-   调整 clap 结构
-   添加 过程宏模块
-   优化代码结构
-   暂时移除弹幕文件输出功能
-   暂时移除录播功能, 待后续重构完整版功能
-   更新依赖

## [0.1.27]

### Fixed

-   修复抖音提前获取直播标题名

## [0.1.26]

### Fixed

-   修复快手直播源获取

## [0.1.25]

### Fixed

-   修复斗鱼直播源获取

### Changed

-   js runtime 拆分, 优化体积

## [0.1.24]

### Fixed

-   修复抖音错误输出

## [0.1.23]

### Fixed

-   修复斗鱼 CDN

## [0.1.22]

### Fixed

-   修复虎牙 gzip 解压

## [0.1.21]

### Fixed

-   修复抖音 m3u 获取 full_hd 资源

## [0.1.20]

### Fixed

-   修复抖音直播源格式

## [0.1.19]

### Fixed

-   修复抖音直播源获取

## [0.1.18]

### Added

-   映客直播源获取
-   抖音加入即时 cookie, 避免 cookie 检测

## [0.1.17]

### Added

-   添加斗鱼直播间标题获取

### Changed

-   更改 js 运行时为 boa_engine 去除在线 js 运行接口
-   添加编译参数, 减小二进制体积
-   斗鱼使用移动端接口获取直播源

## [0.1.16]

### Added

-   (尝试支持) 视频录制, 但目前需要自行放置 ffmpeg 文件到 `seam` 可执行文件所在目录下
-   弹幕录制 CSV 支持
-   开始支持设置
    -   弹幕, 视频 目前支持 rid title time 字段替换

### Changed

-   改进 CI 脚本, 提供更多平台/版本支持

### Fixed

-   修复抖音直播源获取
-   B 站弹幕解压情况下的顺序问题

## [0.1.15]

### Added

-   虎牙直播间标题字段支持

### Fixed

-   删除虎牙多余信息输出

## [0.1.14]

### Added

-   添加 bili 直播间 标题获取, 标题字段初步支持
-   支持 抖音, cc 直播标题获取

### Fixd

-   修复抖音直播源获取

### Changed

-   抖音去除画质标签

### Changed

-   弹幕功能调整

## [0.1.13]

### Added

-   添加 kk 直播源获取
-   添加 千帆直播源获取
-   bili 直播弹幕获取-预览版

## [0.1.12]

### Added

-   添加 now 直播源获取

### Changed

-   Format 添加 rtmp 格式
-   删除斗鱼, 虎牙多余打印信息

### Fixed

-   修复斗鱼, 虎牙平台直播源获取

## [0.1.11]

### Added

-   添加 winktv 直播源获取

### Changed

-   修改 Node 结构
-   规范化 format 判定, 规范化输出方法
-   简化代码

## [0.1.10]

### Added

-   添加 flex 直播源获取

## [0.1.9]

### Added

-   添加 pandalive 直播源获取

## [0.1.8]

### Added

-   添加 afreeca 直播源获取

### Changed

-   引入宏简化代码 感谢 [@eweca-d](https://github.com/eweca-d)
-   删除部分注释及说明信息

### Fixed

-   model 拼写错误修正

## [0.1.7]

### Added

-   支持网易 CC 直播源获取

### Changed

-   使用 `super` 替代绝对位置

## [0.1.6]

### Added

-   支持快手直播源获取

## [0.1.5] - 2023-01-11

### Fixed

-   修复斗鱼直播源获取

## [0.1.4] - 2023-01-11

### Added

-   添加全平台自动编译发布工作流
-   支持花椒直播源获取

### Changed

-   后续 tag 将采用 vX.X.X 的格式

### Fixed

-   修改代码格式

## [0.1.3] - 2023-01-9

### Added

-   原创虎牙直播源获取

### Changed

-   同时输出阿里 腾讯 华为 CDN 和 flv hls 两种直播源

### Fixed

-   从预览接口转换为标准接口, 修复部分直播间无法获取链接而显示未开播的问题


================================================
FILE: crates/cli/Cargo.toml
================================================
[package]
name = "seam"
authors = ["Borber"]
version = "0.1.39"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
seam_core = { path = "../core" }
seam_danmu = { path = "../danmu" }
seam_status = { path = "../status" }

anyhow = "1.0"
tokio = { version = "1", features = ["full"] }
clap = { version = "4", features = ["derive"] }
once_cell = "1"
serde = { version = "1", features = ["derive"] }
basic-toml = "0.1"
parking_lot = { version = "0.12", features = ["nightly"] }


================================================
FILE: crates/cli/README.md
================================================
```
 _______ _______ _______ _______
|     __|    ___|   _   |   |   |
|__     |    ___|       |       |
|_______|_______|___|___|__|_|__|
```

```bash
❯ .\seam.exe -l douyu -i 88080
http://url1

http://url2
```

-   `-l` 代表平台, 目前支持的平台见下表
-   `-i` 代表直播间号, 也就是直播间链接中的 `rid`
-   `-a` 显示全部信息, 包括直播间标题, 主播名, 封面图等

> 因为数据具有时效性, 所以具体链接使用 `url` 进行替换

**注意事项: 目前抖音和快手因为 cookie 模块的加入进行了较大修改, 所以目前不支持获取这两个平台的直播源**


================================================
FILE: crates/cli/src/common.rs
================================================
use std::{collections::HashMap, sync::Arc};

use once_cell::sync::Lazy;
use seam_core::live::{self, Live};

pub static GLOBAL_CLIENT: Lazy<HashMap<String, Arc<dyn Live>>> = Lazy::new(live::all);

#[cfg(test)]
mod tests {
    #[tokio::test]
    async fn test() {
        println!(
            "{:#?}",
            super::GLOBAL_CLIENT
                .get("bili")
                .unwrap()
                .get("6", None)
                .await
                .unwrap()
        );
    }
}


================================================
FILE: crates/cli/src/config.rs
================================================
use std::collections::HashMap;

use once_cell::sync::Lazy;
use serde::Deserialize;

use crate::util::bin_dir;

#[derive(Deserialize, Debug)]
pub struct ConfigOption {
    pub file_name: Option<FileNameOption>,
    pub headers: Option<HashMap<String, HashMap<String, String>>>,
}

#[derive(Debug)]
pub struct Config {
    pub file_name: FileNameConfig,
    pub headers: HashMap<String, HashMap<String, String>>,
}

#[derive(Debug)]
pub struct FileNameConfig {
    pub video: String,
    pub danmu: String,
}

#[derive(Deserialize, Debug)]
pub struct FileNameOption {
    pub video: Option<String>,
    pub danmu: Option<String>,
}

/// 配置文件
pub static CONFIG: Lazy<Config> = Lazy::new(|| {
    let config =
        std::fs::read_to_string(format!("{}config.toml", bin_dir(),)).unwrap_or("".to_owned());
    let config_file = basic_toml::from_str::<ConfigOption>(&config).unwrap();
    Config {
        file_name: {
            let FileNameOption { video, danmu } = config_file.file_name.unwrap_or(FileNameOption {
                video: None,
                danmu: None,
            });
            let video = video.unwrap_or("[rid]-[title]-[date]-[time]".to_string());
            let danmu = danmu.unwrap_or("[rid]-[title]-[date]-[time]".to_string());
            FileNameConfig { video, danmu }
        },
        headers: config_file.headers.unwrap_or_default(),
    }
});

pub fn headers(live: &str) -> HashMap<String, String> {
    let global = CONFIG
        .headers
        .get("global")
        .unwrap_or(&HashMap::new())
        .clone();
    let mut live = CONFIG.headers.get(live).unwrap_or(&HashMap::new()).clone();
    live.extend(global);
    live
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_config() {
        // 初始化 CONFIG
        let _ = CONFIG.file_name.video;
        println!("{:#?}", CONFIG);
    }
}


================================================
FILE: crates/cli/src/main.rs
================================================
mod common;
mod config;
mod util;

use crate::common::GLOBAL_CLIENT;
use anyhow::{anyhow, Result};
use clap::Parser;
use seam_core::error::SeamError;

/// 获取直播源
#[derive(Parser)]
#[command(name = "seam")]
#[command(about ="
________ _______ _______ _______
|     __|    ___|   _   |   |   |
|__     |    ___|       |       |
|_______|_______|___|___|__|_|__|", long_about = None)]
#[command(subcommand_negates_reqs = true)]
#[command(arg_required_else_help = true)]
struct Cli {
    /// 平台名称
    #[arg(short = 'l', required = true)]
    live: Option<String>,
    /// 直播间号
    #[arg(short = 'i', required = true)]
    rid: Option<String>,
    /// 显示详细信息
    #[arg(short = 'a')]
    all: bool,
    /// 直接录播功能
    #[arg(short = 'r')]
    record: bool,
    /// 自动监控录播功能
    #[arg(short = 'R')]
    auto_record: bool,
    /// 输出到终端的弹幕功能
    #[arg(short = 'd')]
    danmu: bool,
    /// 根据参数指定的文件地址输出弹幕
    #[arg(short = 'D')]
    config_danmu: bool,

    #[command(subcommand)]
    command: Option<Commands>,
}

#[derive(Parser, Debug)]
enum Commands {
    /// 显示所有支持的平台
    List,
}

// 获取直播源的实现
pub async fn cli() -> Result<()> {
    let Cli {
        live,
        rid,
        all,
        record,
        auto_record,
        danmu,
        config_danmu,
        command,
    } = Cli::parse();

    // 获取参数
    let live = live.ok_or(anyhow!("请指定平台名称"))?;
    let rid = rid.ok_or(anyhow!("请指定直播间号"))?;

    // 处理子命令
    if let Some(command) = command {
        return match command {
            Commands::List => {
                println!(
                    "可用平台:{}",
                    GLOBAL_CLIENT.keys().cloned().collect::<Vec<_>>().join(", ")
                );
                Ok(())
            }
        };
    }

    let node = match GLOBAL_CLIENT.get(&live) {
        Some(client) => client.get(&rid, Some(config::headers(&live))).await,
        None => {
            return Err(anyhow!(
                "请检查 {} 是否为可用平台, 或前往 https://github.com/Borber/seam/issues 申请支持",
                live
            ))
        }
    };

    // 无参数情况下,直接输出直播源信息
    if !(danmu || config_danmu || record || auto_record) {
        match node {
            Ok(node) => {
                if all {
                    println!("{}", node.json())
                } else {
                    println!(
                        "{}",
                        node.urls
                            .into_iter()
                            .map(|item| item.url)
                            .collect::<Vec<_>>()
                            .join("\n\n")
                    )
                }
            }
            Err(SeamError::None) => println!("未开播"),
            Err(e) => println!("{}", e),
        }
        return Ok(());
    }

    // 收集不同参数功能的异步线程 handler
    let mut thread_handlers = vec![];

    // 处理参数-d,直接输出弹幕。
    // 由于该函数为cli层,所以出错可以直接panic。
    if danmu {
        let h = tokio::spawn(async move {
            // args.command
            //     .danmu(vec![&Terminal::try_new(None).unwrap()])
            //     .await
            //     .unwrap();
            println!("弹幕功能正在重构中,敬请期待") // TODO
        });
        thread_handlers.push(h);
    }
    tokio::select! {
        _ = async {
            for h in thread_handlers {
                h.await.unwrap();
            }
        } => {}
    }

    Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
    cli().await?;
    Ok(())
}


================================================
FILE: crates/cli/src/util.rs
================================================
#[cfg(windows)]
const SEPARATOR: &str = "\\";

#[cfg(not(windows))]
const SEPARATOR: &str = "/";

pub fn bin_dir() -> String {
    let p = std::env::current_exe()
        .unwrap()
        .parent()
        .unwrap()
        .to_str()
        .unwrap()
        .to_owned();
    format!("{p}{SEPARATOR}")
}


================================================
FILE: crates/core/Cargo.toml
================================================
[package]
name = "seam_core"
version = "0.1.20"
edition = "2021"

[dependencies]
macros = { package = "seam_marcos", path = "../marcos" }

thiserror = "1"
once_cell = "1"
async-trait = "0.1"

tokio = { version = "1", features = ["full"] }


serde = { version = "1", features = ["derive"] }
serde_json = "1"
regex = "1"
urlencoding = "2"
chrono = "0.4"
md-5 = "0.10"
hex = "0.4"
rand = "0.8"
base64 = "0.21"


reqwest = { version = "0.11", features = ["json", "gzip", "deflate"] }

boa_engine = { version = "0.17", features = ["annex-b"] }


[target.'cfg(unix)'.dependencies]
openssl = { version = '0.10', features = ["vendored"] }


================================================
FILE: crates/core/src/common.rs
================================================
use once_cell::sync::Lazy;
use reqwest::Client;

// TODO 这玩意也应该额外传入
pub const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 Edg/108.0.1462.54";

pub static CLIENT: Lazy<Client> = Lazy::new(Client::new);


================================================
FILE: crates/core/src/error.rs
================================================
use thiserror::Error;

pub type Result<T> = std::result::Result<T, SeamError>;

// 需要添加
#[derive(Error, Debug)]
pub enum SeamError {
    #[error("Request error: {0}")]
    Request(#[from] reqwest::Error),
    #[error("Type error: {0}")]
    Type(String),
    #[error("Serde json error: {0}")]
    Json(#[from] serde_json::Error),
    #[error("Regex error: {0}")]
    Regex(#[from] regex::Error),
    #[error("Urlencoding error: {0}")]
    Decode(#[from] std::string::FromUtf8Error),
    #[error("InvalidHeaderValue error: {0}")]
    InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
    #[error("ParseInt error: {0}")]
    ParseInt(#[from] std::num::ParseIntError),
    #[error("Base64 error: {0}")]
    Base64Error(#[from] base64::DecodeError),
    #[error("SystemTime error: {0}")]
    SystemTimeError(#[from] std::time::SystemTimeError),
    #[error("{0}")]
    Plugin(String),
    #[error("Need fix {0}")]
    NeedFix(&'static str),
    #[error("Not on")]
    None,
    #[error("Error msg: {0}")]
    Unknown(String),
}


================================================
FILE: crates/core/src/lib.rs
================================================
pub mod common;
pub mod error;
pub mod live;
pub mod util;


================================================
FILE: crates/core/src/live/afreeca.rs
================================================
use std::collections::HashMap;

use async_trait::async_trait;
use regex::Regex;

use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{hash2header, parse_url},
};

use super::{Live, Node};

const URL: &str = "https://play.afreecatv.com/";
const PLAY_URL: &str = "https://live.afreecatv.com/afreeca/player_live_api.php?bjid=";
const CDN: &str = "https://live-global-cdn-v02.afreecatv.com/live-stmc-32/auth_playlist.m3u8?aid=";

/// afreecatv直播
///
/// https://www.afreecatv.com/
pub struct Client;

#[async_trait]
impl Live for Client {
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let text = CLIENT
            .get(format!("{URL}{rid}"))
            .headers(hash2header(headers))
            .send()
            .await?
            .text()
            .await?;
        let re = Regex::new(r#"var nBroadNo = ([0-9]{9})"#)?;
        let bno = match re.captures(&text) {
            Some(rap) => rap.get(1).ok_or(SeamError::NeedFix("bno regex"))?.as_str(),
            None => {
                return Err(SeamError::None);
            }
        };
        let re = Regex::new(r#"var szBroadTitle   = "([\s\S]*?)";"#)?;
        let title = match re.captures(&text) {
            Some(rap) => match rap.get(1) {
                Some(rap) => rap.as_str(),
                None => "获取失败",
            },
            None => "获取失败",
        };
        let mut form = HashMap::new();
        form.insert("bid", rid);
        form.insert("bno", bno);
        form.insert("mode", "landing");
        form.insert("player_type", "html5");
        form.insert("stream_type", "common");
        form.insert("from_api", "0");
        form.insert("type", "aid");
        form.insert("quality", "original");
        let json: serde_json::Value = CLIENT
            .post(format!("{PLAY_URL}{rid}"))
            .form(&form)
            .send()
            .await?
            .json()
            .await?;
        let urls = vec![parse_url(format!(
            "{CDN}{}",
            json["CHANNEL"]["AID"]
                .as_str()
                .ok_or(SeamError::NeedFix("channel aid"))?
        ))];
        Ok(Node {
            rid: rid.to_owned(),
            title: title.to_owned(),
            cover: "".to_owned(),
            anchor: "".to_owned(),
            head: "".to_owned(),
            urls,
        })
    }
}

#[cfg(test)]
macros::gen_test!(suji0624);


================================================
FILE: crates/core/src/live/bili.rs
================================================
use std::collections::HashMap;

use async_trait::async_trait;
use reqwest::header::HeaderValue;
use serde_json::Value;

use super::{Live, Node};
use crate::error::{Result, SeamError};
use crate::util::hash2header;
use crate::{
    common::{CLIENT, USER_AGENT},
    util::parse_url,
};

const INIT_URL: &str = "https://api.live.bilibili.com/room/v1/Room/room_init";
const INFO_URL: &str =
    "https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom?room_id=";
const PLAY_URL: &str = "https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo";

/// bilibili直播
///
/// https://live.bilibili.com/
pub struct Client;

#[async_trait]
impl Live for Client {
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let resp = CLIENT
            .get(INIT_URL)
            .query(&[("id", rid)])
            .headers(hash2header(headers.clone()))
            .send()
            .await?
            .json::<Value>()
            .await?;

        // 获取真实房间号
        let rid = match resp["data"]["live_status"].as_i64() {
            Some(1) => resp["data"]["room_id"]
                .as_u64()
                .ok_or(SeamError::NeedFix("room_id"))?
                .to_string(),
            _ => return Err(SeamError::None),
        };

        let mut stream_info = get_bili_stream_info(&rid, 10000, headers.clone()).await?;

        let max = stream_info
            .as_array()
            .ok_or(SeamError::NeedFix("stream_info"))?
            .iter()
            .map(|data| {
                data["format"][0]["codec"][0]["accept_qn"]
                    .as_array()
                    .unwrap()
                    .iter()
                    .map(|item| item.as_u64().unwrap())
                    .max()
                    .unwrap()
            })
            .max()
            .ok_or(SeamError::NeedFix("max"))?;

        if max != 10000 {
            stream_info = get_bili_stream_info(&rid, max, headers.clone()).await?;
        }

        let mut urls = vec![];
        for obj in stream_info.as_array().ok_or(SeamError::NeedFix("obj"))? {
            for format in obj["format"]
                .as_array()
                .ok_or(SeamError::NeedFix("format"))?
            {
                for codec in format["codec"]
                    .as_array()
                    .ok_or(SeamError::NeedFix("codec"))?
                {
                    let base_url = codec["base_url"]
                        .as_str()
                        .ok_or(SeamError::NeedFix("base_url"))?;
                    for url_info in codec["url_info"]
                        .as_array()
                        .ok_or(SeamError::NeedFix("url_info"))?
                    {
                        let host = url_info["host"]
                            .as_str()
                            .ok_or(SeamError::NeedFix("host"))?;
                        let extra = url_info["extra"]
                            .as_str()
                            .ok_or(SeamError::NeedFix("extra"))?;
                        urls.push(parse_url(format!("{host}{base_url}{extra}")));
                    }
                }
            }
        }

        let json = CLIENT
            .get(format!("{}{}", INFO_URL, rid))
            .send()
            .await?
            .json::<Value>()
            .await?;

        let title = json["data"]["room_info"]["title"]
            .as_str()
            .unwrap_or("获取失败")
            .to_owned();

        let cover = json["data"]["room_info"]["cover"]
            .as_str()
            .unwrap_or("")
            .to_owned();

        let anchor = json["data"]["anchor_info"]["base_info"]["uname"]
            .as_str()
            .unwrap_or("")
            .to_owned();

        let head = json["data"]["anchor_info"]["base_info"]["face"]
            .as_str()
            .unwrap_or("")
            .to_owned();

        Ok(Node {
            rid,
            title,
            cover,
            anchor,
            head,
            urls,
        })
    }
}

/// 通过真实房间号获取直播源信息
/// 不带 cookie 只给 480P, 带 cookie 才给原画画质
pub async fn get_bili_stream_info(
    rid: &str,
    qn: u64,
    headers: Option<HashMap<String, String>>,
) -> Result<serde_json::Value> {
    let mut headers = hash2header(headers);
    headers.append("User-Agent", HeaderValue::from_static(USER_AGENT));
    Ok(CLIENT
        .get(PLAY_URL)
        .headers(headers)
        .query(&[
            ("room_id", rid),
            ("protocol", "0,1"),
            ("format", "0,1,2"),
            ("codec", "0,1"),
            ("qn", qn.to_string().as_str()),
            ("platform", "h5"),
            ("ptype", "8"),
        ])
        .send()
        .await?
        .json::<serde_json::Value>()
        .await?["data"]["playurl_info"]["playurl"]["stream"]
        .to_owned())
}

#[cfg(test)]
macros::gen_test!(6);


================================================
FILE: crates/core/src/live/cc.rs
================================================
use std::collections::HashMap;

use super::{Live, Node};
use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{hash2header, parse_url},
};
use async_trait::async_trait;
use regex::Regex;

const URL: &str = "https://cc.163.com/";

/// 网易CC直播
///
/// https://cc.163.com/
pub struct Client;

#[async_trait]
impl Live for Client {
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let text = CLIENT
            .get(format!("{URL}{rid}"))
            .headers(hash2header(headers))
            .send()
            .await?
            .text()
            .await?;
        let re = Regex::new(
            r#"<script id="__NEXT_DATA__" type="application/json" crossorigin="anonymous">([\s\S]*?)</script>"#,
        )?;
        let json = match re.captures(&text) {
            Some(rap) => rap.get(1).ok_or(SeamError::NeedFix("json re"))?.as_str(),
            None => {
                return Err(SeamError::None);
            }
        };
        let json: serde_json::Value = serde_json::from_str(json)?;
        let resolution = match &json["props"]["pageProps"]["roomInfoInitData"]["live"]["quickplay"]
            ["resolution"]
        {
            serde_json::Value::Null => return Err(SeamError::NeedFix("resolution")),
            v => v,
        };
        let title = json["props"]["pageProps"]["roomInfoInitData"]["live"]["title"]
            .as_str()
            .unwrap_or("获取失败")
            .to_owned();
        let mut urls = vec![];
        for vbr in ["blueray", "ultra", "high", "standard"] {
            if resolution[vbr] != serde_json::Value::Null {
                if resolution[vbr]["cdn"]["ali"] != serde_json::Value::Null {
                    urls.push(parse_url(
                        resolution[vbr]["cdn"]["ali"]
                            .as_str()
                            .ok_or(SeamError::NeedFix("cdn ali"))?
                            .to_string(),
                    ));
                }
                if resolution[vbr]["cdn"]["ks"] != serde_json::Value::Null {
                    urls.push(parse_url(
                        resolution[vbr]["cdn"]["ks"]
                            .as_str()
                            .ok_or(SeamError::NeedFix("cdn ks"))?
                            .to_string(),
                    ));
                }
                break;
            }
        }
        Ok(Node {
            rid: rid.to_owned(),
            title,
            cover: "".to_owned(),
            anchor: "".to_owned(),
            head: "".to_owned(),
            urls,
        })
    }
}

#[cfg(test)]
macros::gen_test!(361433);


================================================
FILE: crates/core/src/live/douyin.rs
================================================
use std::collections::HashMap;

use async_trait::async_trait;
use reqwest::header::HeaderValue;
use serde_json::Value;

use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{hash2header, parse_url},
};

use super::{Live, Node};

const URL: &str = "https://live.douyin.com/";
const ENTER_URL: &str =
    "https://live.douyin.com/webcast/room/web/enter/?aid=6383&live_id=1&device_platform=web&language=zh-CN&enter_from=web_live&cookie_enabled=true&screen_width=1536&screen_height=864&browser_language=zh-CN&browser_platform=Win32&browser_name=Chrome&browser_version=94.0.4606.81&room_id_str=&enter_source=&web_rid=";

/// 抖音直播
///
/// https://live.douyin.com/
pub struct Client;

#[async_trait]
impl Live for Client {
    /// `headers`: cookie 必须, 但不需要是登录状态
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let mut headers = hash2header(headers);
        headers.append("referer", HeaderValue::from_static(URL));
        // 通过网页内容获取直播地址
        let json = CLIENT
            .get(format!("{ENTER_URL}{rid}"))
            .headers(headers)
            .send()
            .await?
            .json::<Value>()
            .await?;

        let data = &json["data"]["data"][0];

        let status = &data["status"];

        if status.as_i64().unwrap_or(0) != 2 {
            return Err(SeamError::None);
        }

        let title = data["title"].as_str().unwrap_or("获取失败").to_string();
        let cover = data["cover"]["url_list"][0]
            .as_str()
            .unwrap_or("")
            .to_string();
        let anchor = data["owner"]["nickname"].as_str().unwrap_or("").to_string();
        let head = data["owner"]["avatar_thumb"]["url_list"][0]
            .as_str()
            .unwrap_or("")
            .to_string();

        let stream_data = data["stream_url"]["live_core_sdk_data"]["pull_data"]["stream_data"]
            .as_str()
            .ok_or(SeamError::NeedFix("stream_data"))?;

        let new_json = serde_json::from_str::<Value>(stream_data)?;
        // 返回最高清晰度的直播地址 flv 和 hls
        let urls = vec![
            parse_url(
                new_json["data"]["origin"]["main"]["flv"]
                    .as_str()
                    .ok_or(SeamError::NeedFix("flv_pull_url"))?
                    .to_owned(),
            ),
            parse_url(
                new_json["data"]["origin"]["main"]["hls"]
                    .as_str()
                    .ok_or(SeamError::NeedFix("hls_pull_url_map"))?
                    .to_owned(),
            ),
        ];
        Ok(Node {
            rid: rid.to_string(),
            title,
            cover,
            anchor,
            head,
            urls,
        })
    }
}

#[cfg(test)]
macros::gen_test!(7274955926023686967);


================================================
FILE: crates/core/src/live/douyu.rs
================================================
use std::collections::{HashMap, HashSet};

use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{eval, hash2header, parse_url},
};

use async_trait::async_trait;
use chrono::prelude::*;
use md5::{Digest, Md5};
use regex::Regex;
use reqwest::header::HeaderMap;
use serde_json::Value;

use super::{Live, Node};

const URL: &str = "https://www.douyu.com/";
const PLAY_URL: &str = "https://www.douyu.com/lapi/live/getH5Play/";
const BETARD_URL: &str = "https://www.douyu.com/betard/";
const DID: &str = "10000000000000000000000000001501";

/// 斗鱼直播
///
/// https://www.douyu.com/
pub struct Client;

#[async_trait]
impl Live for Client {
    /// `headers`: cookie 不必须, 登录状态下可以获取备用路线高清源
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let headers = hash2header(headers);
        // 构造时间戳
        let binding = Local::now().timestamp_millis().to_string();
        let dt = &binding.as_str()[0..10];

        // 获取指定直播间的首页源代码, 认证的sign和直播间是绑定的
        let text = CLIENT
            .get(format!("{URL}{rid}"))
            .headers(headers.clone())
            .send()
            .await?
            .text()
            .await?;

        // 获取直播间的真实ID
        let re = Regex::new(r"\$ROOM\.room_id\s?=\s?(\d+);")?;
        let rid = match re.captures(&text) {
            Some(cap) => cap
                .get(1)
                .ok_or(SeamError::NeedFix("room_id capture"))?
                .as_str(),
            None => return Err(SeamError::NeedFix("room_id")),
        };

        // 正则匹配固定位置的js代码
        let re = Regex::new(r#"<script type="text/javascript">([\s\S]*?)</script>"#)?;
        let mut func = String::new();
        let mut v = "";
        for cap in re.captures_iter(&text) {
            let script = cap.get(1).ok_or(SeamError::NeedFix("script"))?.as_str();
            let re2 = Regex::new("\"([0-9]{12})\"")?;
            match re2.captures(script) {
                Some(t_cap) => {
                    v = t_cap
                        .get(1)
                        .ok_or(SeamError::NeedFix("script captures"))?
                        .as_str();
                    func = script.to_owned();
                }
                None => continue,
            }
        }

        // 将eval运行字符串更改为直接返回字符串
        let re3 = Regex::new(r"eval\(strc\)[\s\S]*?\)")?;
        let func = re3.replace_all(&func, "strc").to_string();
        let func = format!("{func}ub98484234(0,0,0)");

        // 获取eval实际运行的字符串
        let res = eval(&func);
        let res = res.trim_matches('"');

        // 构建函数, 替换数值
        let res = res.replace("(function", "let ccc = function");
        let res = res.replace(
            "rt;})",
            format!("rt;}}; ccc({rid}, \"{DID}\", {dt})").as_str(),
        );

        // 替换md5值避免js依赖
        let cb = format!("{rid}{DID}{dt}{v}");
        let rb = {
            let mut h = Md5::new();
            h.update(cb);
            hex::encode(h.finalize())
        };

        let res = res.replace(
            "CryptoJS.MD5(cb).toString();",
            format!("\"{}\";", &rb).as_str(),
        );

        // 运行js获取签名值
        let sign = eval(&res);
        let sign = sign.trim_matches('"');
        let sign = sign.rsplit_once('=').ok_or(SeamError::NeedFix("sign"))?.1;

        let mut params = HashMap::new();
        params.insert("v", v);
        params.insert("did", DID);
        params.insert("tt", dt);
        params.insert("sign", sign);

        let json = CLIENT
            .post(format!("{PLAY_URL}{rid}"))
            .form(&params)
            .headers(headers.clone())
            .send()
            .await?
            .json::<Value>()
            .await?;

        match json["error"]
            .as_i64()
            .ok_or(SeamError::NeedFix("error code"))?
        {
            0 => {
                let info = get_info(rid, headers.clone()).await?;

                let cdns = json["data"]["cdnsWithName"]
                    .as_array()
                    .ok_or(SeamError::NeedFix("cdns"))?;
                let cdns = cdns
                    .iter()
                    .map(|x| x["cdn"].as_str().unwrap_or("").to_owned())
                    .collect::<HashSet<_>>();
                let rtmp_cdn = json["data"]["rtmp_cdn"]
                    .as_str()
                    .ok_or(SeamError::NeedFix("rtmp_cdn"))?
                    .to_owned();

                let mut jsons = vec![json];

                if headers.get("cookie").is_some() {
                    for cdn in cdns {
                        if cdn == rtmp_cdn {
                            continue;
                        }
                        let mut tmp = params.clone();
                        let headers_tmp = headers.clone();
                        tmp.insert("cdn", &cdn);

                        let json = CLIENT
                            .post(format!("{PLAY_URL}{rid}"))
                            .form(&tmp)
                            .headers(headers_tmp)
                            .send()
                            .await?
                            .json::<Value>()
                            .await?;

                        jsons.push(json);
                    }
                }

                let nodes = jsons
                    .iter()
                    .map(|json| {
                        let key = json["data"]["rtmp_live"].as_str().unwrap_or("需要修复");
                        let url = json["data"]["rtmp_url"].as_str().unwrap_or("需要修复");
                        parse_url(format!("{url}/{key}"))
                    })
                    .collect::<Vec<_>>();

                Ok(Node {
                    rid: rid.to_owned(),
                    title: info.title,
                    cover: info.cover,
                    anchor: info.anchor,
                    head: info.head,
                    urls: nodes,
                })
            }
            _ => Err(SeamError::None),
        }
    }
}

struct DouyuInfo {
    title: String,
    cover: String,
    anchor: String,
    head: String,
}

async fn get_info(rid: &str, headers: HeaderMap) -> Result<DouyuInfo> {
    let json = CLIENT
        .get(format!("{BETARD_URL}{rid}"))
        .headers(headers)
        .send()
        .await?
        .json::<Value>()
        .await?;
    let title = json["room"]["room_name"]
        .as_str()
        .unwrap_or("获取失败")
        .to_string();
    let cover = json["room"]["room_pic"].as_str().unwrap_or("").to_string();
    let anchor = json["room"]["owner_name"]
        .as_str()
        .unwrap_or("")
        .to_string();
    let head = json["room"]["owner_avatar"]
        .as_str()
        .unwrap_or("")
        .to_string();
    Ok(DouyuInfo {
        title,
        cover,
        anchor,
        head,
    })
}

#[cfg(test)]
macros::gen_test!(100);


================================================
FILE: crates/core/src/live/flex.rs
================================================
use std::collections::HashMap;

use async_trait::async_trait;

use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{hash2header, parse_url},
};

use super::{Live, Node};

const URL: &str = "https://api.flextv.co.kr/api/channels/rid/stream?option=all";

/// flextv
///
/// https://www.flextv.co.kr/
pub struct Client;

#[async_trait]
impl Live for Client {
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let json: serde_json::Value = CLIENT
            .get(URL.replace("rid", rid))
            .headers(hash2header(headers))
            .send()
            .await?
            .json()
            .await?;
        match &json["sources"][0]["url"] {
            serde_json::Value::Null => Err(SeamError::None),
            url => {
                let urls = vec![parse_url(
                    url.as_str().ok_or(SeamError::NeedFix("url"))?.to_string(),
                )];

                let title = match &json["stream"]["title"] {
                    serde_json::Value::Null => "获取失败",
                    title => title.as_str().unwrap_or("获取失败"),
                };

                Ok(Node {
                    rid: rid.to_owned(),
                    title: title.to_owned(),
                    cover: "".to_owned(),
                    anchor: "".to_owned(),
                    head: "".to_owned(),
                    urls,
                })
            }
        }
    }
}

#[cfg(test)]
macros::gen_test!(437149);


================================================
FILE: crates/core/src/live/huajiao.rs
================================================
use std::collections::HashMap;

use async_trait::async_trait;
use regex::Regex;

use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{hash2header, parse_url},
};

use super::{Live, Node};

const URL: &str = "https://www.huajiao.com/l/";

/// 花椒直播
///
/// https://www.huajiao.com/
pub struct Client;

#[async_trait]
impl Live for Client {
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let text = CLIENT
            .get(format!("{URL}{rid}"))
            .headers(hash2header(headers))
            .send()
            .await?
            .text()
            .await?;

        let re1 = Regex::new(r#"sn":"([\s\S]*?)""#)?;
        let re2 = Regex::new(r#""replay_status":([0-9]*)"#)?;
        let sn = match re1.captures(&text) {
            Some(cap) => cap.get(1).ok_or(SeamError::NeedFix("sn"))?.as_str(),
            None => return Err(SeamError::None),
        };

        let re_title = Regex::new(r#"content="【(.+)】"#)?;
        let title = match re_title.captures(&text) {
            Some(cap) => match cap.get(1) {
                Some(title) => title.as_str().to_owned(),
                None => "获取失败".to_owned(),
            },
            None => "获取失败".to_owned(),
        };

        let pls: Vec<&str> = sn.split('_').collect();
        let pl = pls[2].to_lowercase();

        let captures = re2.captures(&text).ok_or(SeamError::None)?;
        let code = captures.get(1).ok_or(SeamError::NeedFix("code"))?.as_str();

        if code == "0" {
            Ok(Node {
                rid: rid.to_owned(),
                title,
                cover: "".to_owned(),
                anchor: "".to_owned(),
                head: "".to_owned(),
                urls: vec![parse_url(format!(
                    "https://{pl}-flv.live.huajiao.com/live_huajiao_v2/{sn}.m3u8"
                ))],
            })
        } else {
            Err(SeamError::None)
        }
    }
}

#[cfg(test)]
macros::gen_test!(337633032);


================================================
FILE: crates/core/src/live/huya.rs
================================================
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};

use async_trait::async_trait;
use base64::{engine::general_purpose, Engine as _};
use md5::{Digest, Md5};
use rand::Rng;
use regex::Regex;
use serde_json::json;

use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{hash2header, parse_url},
};

use super::{Live, Node};

const URL: &str = "https://m.huya.com/";

/// 虎牙直播
///
/// https://huya.com/
pub struct Client;

#[async_trait]
impl Live for Client {
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
        let rand = rand::thread_rng().gen_range(0..1000);

        let uid = get_anonymous_uid().await?;

        let text = CLIENT
            .get(format!("{URL}{rid}"))
            .header("Content-Type", "application/x-www-form-urlencoded")
            .header("User-Agent", "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Mobile Safari/537.36")
            .headers(hash2header(headers))
            .send()
            .await?
            .text()
            .await?;

        let re = Regex::new(r"<script> window.HNF_GLOBAL_INIT = ([\s\S]*) </script>")?;

        let stream = match re.captures(&text) {
            Some(caps) => caps.get(1).ok_or(SeamError::NeedFix("stream"))?.as_str(),
            None => return Err(SeamError::NeedFix("stream none")),
        };

        let json: serde_json::Value = serde_json::from_str(stream)?;

        let status = json["roomInfo"]["eLiveStatus"]
            .as_i64()
            .ok_or(SeamError::NeedFix("eLiveStatus"))?;

        if status != 2 {
            return Err(SeamError::None);
        }

        let title = match json["roomInfo"]["tLiveInfo"]["sIntroduction"] {
            serde_json::Value::String(ref title) => title.as_str(),
            _ => "获取失败",
        };

        let mut urls = vec![];

        let streams = json["roomInfo"]["tLiveInfo"]["tLiveStreamInfo"]["vStreamInfo"]["value"]
            .as_array()
            .ok_or(SeamError::NeedFix("vStreamInfo"))?;

        for stream in streams {
            let flv = stream["sFlvUrl"]
                .as_str()
                .ok_or(SeamError::NeedFix("sFlvUrl"))?;
            let hls = stream["sHlsUrl"]
                .as_str()
                .ok_or(SeamError::NeedFix("sHlsUrl"))?;

            let anti_code = process_anticode(
                stream["sFlvAntiCode"]
                    .as_str()
                    .ok_or(SeamError::NeedFix("sFlvAntiCode"))?,
                stream["sStreamName"]
                    .as_str()
                    .ok_or(SeamError::NeedFix("sStreamName"))?,
                uid,
                now,
                rand,
            )?;
            urls.push(parse_url(format!(
                "{}/{}.{}?{}",
                flv,
                stream["sStreamName"]
                    .as_str()
                    .ok_or(SeamError::NeedFix("sStreamName"))?,
                stream["sFlvUrlSuffix"]
                    .as_str()
                    .ok_or(SeamError::NeedFix("sFlvUrlSuffix"))?,
                anti_code
            )));

            let anti_code = process_anticode(
                stream["sHlsAntiCode"]
                    .as_str()
                    .ok_or(SeamError::NeedFix("sHlsAntiCode"))?,
                stream["sStreamName"]
                    .as_str()
                    .ok_or(SeamError::NeedFix("sStreamName"))?,
                uid,
                now,
                rand,
            )?;
            urls.push(parse_url(format!(
                "{}/{}.{}?{}",
                hls,
                stream["sStreamName"]
                    .as_str()
                    .ok_or(SeamError::NeedFix("sStreamName"))?,
                stream["sHlsUrlSuffix"]
                    .as_str()
                    .ok_or(SeamError::NeedFix("sHlsUrlSuffix"))?,
                anti_code
            )));
        }

        Ok(Node {
            rid: rid.to_owned(),
            title: title.to_owned(),
            cover: "".to_owned(),
            anchor: "".to_owned(),
            head: "".to_owned(),
            urls,
        })
    }
}

fn get_uuid(now: u128, rand: u32) -> u128 {
    (now % 10000000000 * 1000 + rand as u128) % 4294967295
}

async fn get_anonymous_uid() -> Result<u128> {
    let resp: HashMap<String, serde_json::Value> = CLIENT
        .post("https://udblgn.huya.com/web/anonymousLogin")
        .json(&json!({
            "appId": 5002,
            "byPass": 3,
            "context": "",
            "version": "2.4",
            "data": {}
        }))
        .send()
        .await?
        .json()
        .await?;

    let uid = resp["data"]["uid"]
        .as_str()
        .ok_or(SeamError::NeedFix("uid"))?
        .to_string()
        .parse()?;
    Ok(uid)
}

fn process_anticode(
    anticode: &str,
    stream_name: &str,
    uid: u128,
    now: u128,
    rand: u32,
) -> Result<String> {
    let anticode = urlencoding::decode(anticode)?.to_string();
    let mut anti_map = anticode.split('&').fold(HashMap::new(), |mut map, s| {
        let (k, v) = s.split_once('=').unwrap_or_default();
        map.insert(k.to_owned(), v.to_owned());
        map
    });

    anti_map.insert("ver".to_string(), "1".to_string());
    anti_map.insert("sv".to_string(), "2110211124".to_string());
    anti_map.insert("seqid".to_string(), format!("{}", uid + now * 1_000));
    anti_map.insert("uid".to_string(), uid.to_string());
    anti_map.insert("uuid".to_string(), get_uuid(now, rand).to_string());

    let seqid = anti_map["seqid"].as_str();
    let ctype = anti_map["ctype"].as_str();
    let t = anti_map["t"].as_str();

    let result = {
        let mut h = Md5::new();
        h.update(format!("{}|{}|{}", seqid, ctype, t));
        hex::encode(h.finalize())
    };

    let fm = anti_map["fm"].as_str();

    let fm = general_purpose::STANDARD.decode(fm)?;
    let fm = String::from_utf8(fm)?;

    let fm = fm.replace("$0", &anti_map["uid"]);
    let fm = fm.replace("$1", stream_name);
    let fm = fm.replace("$2", &result);
    let fm = fm.replace("$3", &anti_map["wsTime"]);

    let secret = {
        let mut h = Md5::new();
        h.update(fm);
        hex::encode(h.finalize())
    };

    anti_map
        .insert("wsSecret".to_string(), secret)
        .ok_or(SeamError::NeedFix("wsSecret"))?;

    anti_map.remove("fm");

    let mut s = String::new();
    for (k, v) in anti_map {
        s = format!("{}&{}={}", s, k, v);
    }
    Ok(s)
}

#[cfg(test)]
macros::gen_test!(660000);


================================================
FILE: crates/core/src/live/inke.rs
================================================
use std::collections::HashMap;

use async_trait::async_trait;

use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{hash2header, parse_url},
};

use super::{Live, Node};

const URL: &str = "https://webapi.busi.inke.cn/web/live_share_pc?uid=";

/// 映客直播
///
/// https://www.inke.cn/
pub struct Client;

#[async_trait]
impl Live for Client {
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let json: serde_json::Value = CLIENT
            .get(format!("{URL}{rid}"))
            .headers(hash2header(headers))
            .send()
            .await?
            .json()
            .await?;

        match &json["data"]["status"].as_i64() {
            Some(1) => {
                let title = json["data"]["live_name"]
                    .as_str()
                    .unwrap_or("inke")
                    .to_string();
                let mut urls = vec![];
                for s in ["stream_addr", "hls_stream_addr", "rtmp_stream_addr"] {
                    if !json["data"]["live_addr"][0][s].is_null() {
                        urls.push(parse_url(
                            json["data"]["live_addr"][0][s]
                                .as_str()
                                .ok_or(SeamError::NeedFix("live_addr"))?
                                .to_string(),
                        ));
                    }
                }

                Ok(Node {
                    rid: rid.to_owned(),
                    title,
                    cover: "".to_owned(),
                    anchor: "".to_owned(),
                    head: "".to_owned(),
                    urls,
                })
            }
            _ => Err(SeamError::None),
        }
    }
}

#[cfg(test)]
macros::gen_test!(713935849);


================================================
FILE: crates/core/src/live/kk.rs
================================================
use std::collections::HashMap;

use async_trait::async_trait;
use regex::Regex;

use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{hash2header, parse_url},
};

use super::{Live, Node};

const URL: &str = "https://www.kktv5.com/show/";

/// kk直播
///
/// https://www.kktv5.com/
pub struct Client;

// TODO 简化后半部分逻辑, 仅判断开播与标题, 最后拼接node
#[async_trait]
impl Live for Client {
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let text = CLIENT
            .get(format!("{URL}{rid}"))
            .headers(hash2header(headers))
            .send()
            .await?
            .text()
            .await?;

        let re = Regex::new(r"window.acotor_simple_info = ([\s\S]*?);")?;
        let re2 = Regex::new(r"var __actor_info__ = ([\s\S]*?) var")?;

        let node1 = match re.captures(&text) {
            Some(cap) => {
                let json = cap.get(1).ok_or(SeamError::NeedFix("captures"))?.as_str();
                let json = serde_json::from_str::<serde_json::Value>(json)?;

                let title = json["roomTheme"].as_str().unwrap_or("获取失败").to_owned();

                let live = json["liveType"]
                    .as_i64()
                    .ok_or(SeamError::NeedFix("liveType"))?;

                match live {
                    1 => {
                        let urls = vec![parse_url(format!(
                            "https://pull.kktv8.com/livekktv/{}.flv",
                            rid
                        ))];
                        Some(Node {
                            rid: rid.to_owned(),
                            title,
                            cover: "".to_owned(),
                            anchor: "".to_owned(),
                            head: "".to_owned(),
                            urls,
                        })
                    }
                    _ => None,
                }
            }
            None => None,
        };
        let node2 = match re2.captures(&text) {
            Some(cap) => {
                let json = cap.get(1).ok_or(SeamError::NeedFix("captures"))?.as_str();
                let json = serde_json::from_str::<serde_json::Value>(json)?;

                let title = json["roomTheme"].as_str().unwrap_or("获取失败").to_owned();

                let live = json["liveType"]
                    .as_i64()
                    .ok_or(SeamError::NeedFix("liveType"))?;

                match live {
                    1 => {
                        let urls = vec![parse_url(format!(
                            "https://pull.kktv8.com/livekktv/{}.flv",
                            rid
                        ))];
                        Some(Node {
                            rid: rid.to_owned(),
                            title,
                            cover: "".to_owned(),
                            anchor: "".to_owned(),
                            head: "".to_owned(),
                            urls,
                        })
                    }
                    _ => None,
                }
            }
            None => None,
        };
        if let Some(node) = node1 {
            return Ok(node);
        }
        if let Some(node) = node2 {
            return Ok(node);
        }
        Err(SeamError::None)
    }
}

#[cfg(test)]
macros::gen_test!(521);


================================================
FILE: crates/core/src/live/ks.rs
================================================
use std::collections::HashMap;

use async_trait::async_trait;
use regex::Regex;

use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{hash2header, parse_url},
};

use super::{Live, Node};

const URL: &str = "https://live.kuaishou.com/u/";

/// 快手直播
///
/// https://live.kuaishou.com/
pub struct Client;

#[async_trait]
impl Live for Client {
    // TODO 说明所需 cookie
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let text = CLIENT
            .get(format!("{URL}{rid}"))
            .headers(hash2header(headers))
            .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36 Edg/117.0.2045.31")
            .send()
            .await?
            .text()
            .await?;
        let re = Regex::new(r"<script>window.__INITIAL_STATE__=([\s\S]*?);\(function")?;
        let stream = match re.captures(&text) {
            Some(caps) => caps.get(1).ok_or(SeamError::NeedFix("stream"))?.as_str(),
            None => {
                return Err(SeamError::NeedFix("stream none"));
            }
        };
        let json: serde_json::Value = serde_json::from_str(stream)?;

        let title = json["liveroom"]["playList"][0]["liveStream"]["caption"]
            .as_str()
            .unwrap_or("获取失败")
            .to_owned();

        let cover = json["liveroom"]["playList"][0]["liveStream"]["poster"]
            .as_str()
            .unwrap_or("")
            .to_owned();

        let head = json["liveroom"]["playList"][0]["author"]["avatar"]
            .as_str()
            .unwrap_or("")
            .to_owned();

        let anchor = json["liveroom"]["playList"][0]["author"]["name"]
            .as_str()
            .unwrap_or("获取失败")
            .to_owned();

        match &json["liveroom"]["playList"][0]["liveStream"]["playUrls"][0]["adaptationSet"]
            ["representation"]
        {
            serde_json::Value::Null => Err(SeamError::None),
            reps => {
                let list = reps.as_array().ok_or(SeamError::NeedFix("list"))?;
                let url = list[list.len() - 1]["url"]
                    .as_str()
                    .ok_or(SeamError::NeedFix("url"))?;
                let urls = vec![parse_url(url.to_string())];
                Ok(Node {
                    rid: rid.to_owned(),
                    title,
                    cover,
                    anchor,
                    head,
                    urls,
                })
            }
        }
    }
}

#[cfg(test)]
macros::gen_test!(Bd20210915);


================================================
FILE: crates/core/src/live/mht.rs
================================================
use std::collections::HashMap;

use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{hash2header, parse_url},
};

use async_trait::async_trait;
use serde_json::Value;

use super::{Live, Node};

const URL: &str = "https://www.2cq.com/proxy/room/room/info";

/// 棉花糖直播
///
/// https://www.2cq.com/
pub struct Client;

// TODO 似乎某些房间有额外的 flv 地址
#[async_trait]
impl Live for Client {
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let resp: serde_json::Value = CLIENT
            .get(URL)
            .query(&[("roomId", rid), ("appId", "1004")])
            .headers(hash2header(headers))
            .send()
            .await?
            .json()
            .await?;
        match &resp["errorMsg"] {
            Value::Null => {
                // 不报错的情况必然有结果返回 直接提取
                let result = &resp["result"];
                let title = result["roomName"].as_str().unwrap_or("获取失败");
                match result["liveState"].to_string().parse::<usize>()? {
                    // 开播状态
                    1 => {
                        let urls = vec![parse_url(
                            result["pullUrl"]
                                .as_str()
                                .ok_or(SeamError::NeedFix("pull url"))?
                                .to_owned(),
                        )];
                        Ok(Node {
                            rid: rid.to_owned(),
                            title: title.to_owned(),
                            cover: "".to_owned(),
                            anchor: "".to_owned(),
                            head: "".to_owned(),
                            urls,
                        })
                    }
                    _ => Err(SeamError::None),
                }
            }
            // 房间不存在或其他错误
            msg => Err(SeamError::Unknown(msg.to_string())),
        }
    }
}

#[cfg(test)]
macros::gen_test!(911038);


================================================
FILE: crates/core/src/live/mod.rs
================================================
//! 直播相关模块。
//!
//! 本模块提供了标准化的直播获取方式和直播状态检测的async trait 以及
//! 标准化的直播源信息和直播状态enum

use async_trait::async_trait;
use macros::gen_all;
use serde::{Serialize, Serializer};
use std::collections::HashMap;
use std::sync::Arc;

use crate::error::{Result, SeamError};

pub mod afreeca;
pub mod bili;
pub mod cc;
pub mod douyin;
pub mod douyu;
pub mod flex;
pub mod huajiao;
pub mod huya;
pub mod inke;
pub mod kk;
pub mod ks;
pub mod mht;
pub mod now;
pub mod panda;
pub mod qf;
pub mod twitch;
pub mod wink;
pub mod yqs;

// TODO 调整平台名称缩写, 尽量使用官方完整名称

/// 直播信息模块
#[async_trait]
pub trait Live: Send + Sync {
    /// 获取直播源
    ///
    /// rid: 直播间号
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node>;
}

// 返回所有受支持的直播平台 对应的 hashmap
gen_all!();

#[cfg(test)]
mod test {
    use super::all;

    #[tokio::test]
    async fn test_get() {
        println!(
            "{:#?}",
            all().get("bili").unwrap().get("6", None).await.unwrap()
        );
    }
}

/// TODO 拆分独立模块
/// 1. 检测是否开播, 仅返回是否开播
/// 2. 直播间信息模块,
///     1. 直播间标题
///     2. 直播间封面
///     3. 主播头像
/// 3. 直播源地址模块
/// 4. 弹幕模块

// TODO 整理代码中的注释, 使其更加规范

// TODO title 可以弄成&str吗?

/// 直播源
///
/// - rid: 直播间号
/// - title: 直播间标题
/// - nodes: 直播源列表
#[derive(Serialize, Debug, Clone, PartialEq)]
pub struct Node {
    pub rid: String,
    pub title: String,
    pub cover: String,
    pub anchor: String,
    pub head: String,
    pub urls: Vec<Url>,
}

impl Node {
    pub fn json(&self) -> String {
        serde_json::to_string_pretty(&self).unwrap_or("序列化失败".to_owned())
    }
}

#[derive(Serialize, Debug, Clone, PartialEq)]
pub struct Url {
    /// 直播源格式
    pub format: Format,
    /// 直播源地址, 默认均为最高清晰度, 故而无需额外标注清晰度
    pub url: String,
}

impl Url {
    pub fn is_m3u8(&self) -> Result<String> {
        match self.format {
            Format::M3U => Ok(self.url.clone()),
            _ => Err(SeamError::Type("not m3u8".to_string())),
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub enum Format {
    Flv,
    M3U,
    Rtmp,
    Other(String),
}
/// 自定义序列化方法
impl Serialize for Format {
    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let str = match self {
            Format::Flv => "flv",
            Format::M3U => "m3u",
            Format::Rtmp => "rtmp",
            Format::Other(s) => s.as_str(),
        };
        serializer.serialize_str(str)
    }
}


================================================
FILE: crates/core/src/live/now.rs
================================================
use std::collections::HashMap;

use async_trait::async_trait;

use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{hash2header, parse_url},
};

use super::{Live, Node};

const ROOM_URL: &str =
    "https://now.qq.com/cgi-bin/now/web/room/get_live_room_url?platform=8&room_id=";
const URL: &str = "https://now.qq.com/pcweb/story.html?roomid=";
/// NOW直播
///
/// https://now.qq.com/
pub struct Client;

#[async_trait]
impl Live for Client {
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let json: serde_json::Value = CLIENT
            .get(format!("{ROOM_URL}{rid}"))
            .headers(hash2header(headers.clone()))
            .send()
            .await?
            .json()
            .await?;

        match &json["result"]["is_on_live"]
            .as_bool()
            .ok_or(SeamError::NeedFix("result"))?
        {
            true => {
                let mut urls = vec![];
                for f in ["raw_flv_url", "raw_hls_url", "raw_rtmp_url"] {
                    if let Some(url) = json["result"][f].as_str() {
                        urls.push(parse_url(url.to_string()));
                    }
                }
                let title = get_title(rid, headers)
                    .await
                    .unwrap_or("获取失败".to_owned());
                Ok(Node {
                    rid: rid.to_owned(),
                    title,
                    cover: "".to_owned(),
                    anchor: "".to_owned(),
                    head: "".to_owned(),
                    urls,
                })
            }
            false => Err(SeamError::None),
        }
    }
}

async fn get_title(rid: &str, headers: Option<HashMap<String, String>>) -> Result<String> {
    let json = CLIENT
        .get(format!("{URL}{rid}"))
        .headers(hash2header(headers))
        .send()
        .await?
        .text()
        .await?;

    let re = regex::Regex::new(r#""anchorName":"([\s\S]*?)""#).unwrap();

    match re.captures(&json) {
        Some(caps) => {
            let title = caps
                .get(1)
                .ok_or(SeamError::NeedFix("captures"))?
                .as_str()
                .to_owned();
            Ok(title)
        }
        None => Err(SeamError::NeedFix("title")),
    }
}

#[cfg(test)]
macros::gen_test!(1351697153);


================================================
FILE: crates/core/src/live/panda.rs
================================================
use std::collections::HashMap;

const URL: &str = "https://api.pandalive.co.kr/v1/live/play/";

use async_trait::async_trait;

use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{hash2header, parse_url},
};

use super::{Live, Node};

/// pandalive
///
/// https://www.pandalive.co.kr/
pub struct Client;

#[async_trait]
impl Live for Client {
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let mut form = HashMap::new();
        form.insert("action", "watch");
        form.insert("userId", rid);
        let json: serde_json::Value = CLIENT
            .post(URL)
            .form(&form)
            .headers(hash2header(headers))
            .send()
            .await?
            .json()
            .await?;
        match &json["PlayList"] {
            serde_json::Value::Null => Err(SeamError::None),
            list => {
                let mut urls = vec![];
                for item in ["hls", "hls2", "hls3", "rtmp"] {
                    if list.get(item).is_some() {
                        urls.push(parse_url(
                            list[item][0]["url"]
                                .as_str()
                                .ok_or(SeamError::NeedFix("list"))?
                                .to_string(),
                        ));
                    }
                }

                let title = match &json["media"]["title"] {
                    serde_json::Value::Null => "获取失败",
                    title => title.as_str().unwrap_or("获取失败"),
                };

                Ok(Node {
                    rid: rid.to_owned(),
                    title: title.to_owned(),
                    cover: "".to_owned(),
                    anchor: "".to_owned(),
                    head: "".to_owned(),
                    urls,
                })
            }
        }
    }
}

#[cfg(test)]
macros::gen_test!(wert681800);


================================================
FILE: crates/core/src/live/qf.rs
================================================
use std::collections::HashMap;

use async_trait::async_trait;
use regex::Regex;

use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{hash2header, parse_url},
};

use super::{Live, Node};

const URL: &str = "https://qf.56.com/";

/// 千帆直播
///
/// https://qf.56.com/
pub struct Client;

#[async_trait]
impl Live for Client {
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let text = CLIENT
            .get(format!("{URL}{rid}"))
            .headers(hash2header(headers))
            .send()
            .await?
            .text()
            .await?;
        let re_title = Regex::new(r"nickName: '(.+)'")?;
        let title = match re_title.captures(&text) {
            Some(cap) => cap
                .get(1)
                .ok_or(SeamError::NeedFix("title"))?
                .as_str()
                .to_owned(),
            None => "qf".to_owned(),
        };
        let re = Regex::new(r"flvUrl:'([\s\S]*?)'")?;
        match re.captures(&text) {
            Some(cap) => {
                let urls = vec![parse_url(
                    cap.get(1)
                        .ok_or(SeamError::NeedFix("captures"))?
                        .as_str()
                        .to_string(),
                )];
                Ok(Node {
                    rid: rid.to_owned(),
                    title,
                    cover: "".to_owned(),
                    anchor: "".to_owned(),
                    head: "".to_owned(),
                    urls,
                })
            }
            None => Err(SeamError::None),
        }
    }
}

#[cfg(test)]
macros::gen_test!(520006);


================================================
FILE: crates/core/src/live/twitch.rs
================================================
use std::collections::HashMap;

use async_trait::async_trait;
use reqwest::header::HeaderMap;
use serde_json::Value;

use super::{Live, Node, Url};
use crate::common::{CLIENT, USER_AGENT};
use crate::error::{Result, SeamError};
use crate::util::hash2header;

/// twitch直播
///
/// https://www.twitch.tv
pub struct Client;

#[async_trait]
impl Live for Client {
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let rid = rid.to_ascii_lowercase();

        let mut headers = hash2header(headers);
        headers.insert("Referer", "https://www.twitch.tv".parse()?);
        headers.insert("Origin", "https://www.twitch.tv".parse()?);
        headers.insert("Client-ID", "kimne78kx3ncx6brgo4mv6wki5h1ko".parse()?);

        if !headers.contains_key("User-Agent") {
            headers.insert("User-Agent", USER_AGENT.parse()?);
        }

        let metadata = self.get_channel_metadata(&rid, headers.clone()).await?;

        if metadata[1]["data"]["user"]["stream"].is_null() {
            Err(SeamError::None)
        } else {
            let (signature, token) = self.get_access_token(&rid, headers.clone()).await?;
            let urls = self
                .get_live_streams(&rid, &signature, &token, headers.clone())
                .await?
                .into_iter()
                .map(|url| Url {
                    format: super::Format::M3U,
                    url,
                })
                .collect();

            let get_string = |v: &Value| v.as_str().map(|s| s.to_string()).unwrap_or_default();

            Ok(Node {
                rid: rid.to_string(),
                title: get_string(&metadata[1]["data"]["user"]["lastBroadcast"]["title"]),
                cover: format!(
                    "https://static-cdn.jtvnw.net/previews-ttv/live_user_{rid}-320x180.jpg"
                ),
                anchor: get_string(&metadata[0]["data"]["userOrError"]["displayName"]),
                head: get_string(&metadata[1]["data"]["user"]["profileImageURL"]),
                urls,
            })
        }
    }
}

impl Client {
    async fn get_access_token(
        &self,
        rid: &str,
        mut headers: HeaderMap,
    ) -> Result<(String, String)> {
        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"}}"#;
        let data = data.replace("___rid___", rid);

        headers.insert("Content-Type", "application/json".parse()?);
        let json: Value = CLIENT
            .post("https://gql.twitch.tv/gql")
            .headers(headers.clone())
            .body(data)
            .send()
            .await?
            .json()
            .await?;
        let signature = json["data"]["streamPlaybackAccessToken"]["signature"]
            .as_str()
            .map(|s| s.to_string())
            .ok_or_else(|| SeamError::NeedFix("twitch signature"))?;
        let token = json["data"]["streamPlaybackAccessToken"]["value"]
            .as_str()
            .map(|s| s.to_string())
            .ok_or_else(|| SeamError::NeedFix("twitch token"))?;
        Ok((signature, token))
    }

    async fn get_live_streams(
        &self,
        rid: &str,
        signature: &str,
        token: &str,
        headers: HeaderMap,
    ) -> Result<Vec<String>> {
        let query = [
            ("cdm", "wv"),
            ("allow_source", "true"),
            ("fast_bread", "true"),
            ("player_backend", "mediaplayer"),
            ("player_version", "1.23.0"),
            ("playlist_include_framerate", "true"),
            ("reassignments_supported", "true"),
            ("sig", signature),
            ("supported_codecs", "avc1"),
            ("token", token),
        ];

        let req = CLIENT
            .get(format!(
                "https://usher.ttvnw.net/api/channel/hls/{rid}.m3u8"
            ))
            .headers(headers)
            .query(&query);

        let urls = req
            .send()
            .await?
            .text()
            .await?
            .lines()
            .filter(|l| l.starts_with("https://"))
            .map(|l| l.to_string())
            .collect();

        Ok(urls)
    }

    async fn get_channel_metadata(&self, rid: &str, headers: HeaderMap) -> Result<Value> {
        let query = r#"
        [
            {
                "operationName": "ChannelShell",
                "variables": {
                    "login": "___rid___"
                },
                "extensions": {
                    "persistedQuery": {
                        "version": 1,
                        "sha256Hash": "580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"
                    }
                }
            },
            {
                "operationName": "StreamMetadata",
                "variables": {
                    "channelLogin": "___rid___"
                },
                "extensions": {
                    "persistedQuery": {
                        "version": 1,
                        "sha256Hash": "252a46e3f5b1ddc431b396e688331d8d020daec27079893ac7d4e6db759a7402"
                    }
                }
            }
        ]
        "#
        .replace("___rid___", rid);
        let json: Value = serde_json::from_str(&query)?;

        let rsp = CLIENT
            .post("https://gql.twitch.tv/gql")
            .headers(headers)
            .json(&json)
            .send()
            .await?
            .json()
            .await?;

        Ok(rsp)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_twitch() {
        let c = Client;
        match c.get("nl_Kripp", None).await {
            Ok(r) => println!("{r:?}"),
            Err(SeamError::None) => {}
            Err(e) => {
                println!("{e}");
                assert!(false);
            }
        }
    }
}


================================================
FILE: crates/core/src/live/wink.rs
================================================
use std::collections::HashMap;

const URL: &str = "https://api.winktv.co.kr/v1/live/play";

use async_trait::async_trait;

use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{hash2header, parse_url},
};

use super::{Live, Node};

/// winktv
///
/// https://www.winktv.co.kr/
pub struct Client;

#[async_trait]
impl Live for Client {
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let mut form = HashMap::new();
        form.insert("action", "watch");
        form.insert("userId", rid);
        let json: serde_json::Value = CLIENT
            .post(URL)
            .form(&form)
            .headers(hash2header(headers))
            .send()
            .await?
            .json()
            .await?;
        match &json["PlayList"] {
            serde_json::Value::Null => Err(SeamError::None),
            list => {
                let mut urls = vec![];
                for item in ["hls", "hls2", "hls3", "rtmp"] {
                    if list.get(item).is_some() {
                        urls.push(parse_url(
                            list[item][0]["url"]
                                .as_str()
                                .ok_or(SeamError::NeedFix("url"))?
                                .to_string(),
                        ));
                    }
                }

                let title = match &json["media"]["title"] {
                    serde_json::Value::Null => "获取失败",
                    title => title.as_str().unwrap_or("获取失败"),
                };

                Ok(Node {
                    rid: rid.to_owned(),
                    title: title.to_owned(),
                    cover: "".to_owned(),
                    anchor: "".to_owned(),
                    head: "".to_owned(),
                    urls,
                })
            }
        }
    }
}

#[cfg(test)]
macros::gen_test!(roeunlove);


================================================
FILE: crates/core/src/live/yqs.rs
================================================
use async_trait::async_trait;
use regex::Regex;

use crate::{
    common::CLIENT,
    error::{Result, SeamError},
    util::{hash2header, parse_url},
};

use std::collections::HashMap;

use super::{Live, Node};

const URL: &str = "https://www.173.com/";
const ROOM_URL: &str = "https://www.173.com/room/getVieoUrl";

/// 艺气山直播
///
/// https://www.173.com/
pub struct Client;

#[async_trait]
impl Live for Client {
    async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>) -> Result<Node> {
        let mut params = HashMap::new();
        params.insert("roomId", rid);
        let resp: serde_json::Value = CLIENT
            .post(ROOM_URL)
            .form(&params)
            .headers(hash2header(headers.clone()))
            .send()
            .await?
            .json()
            .await?;
        let data = &resp["data"];
        match data["status"].as_i64() {
            Some(2) => {
                let urls = vec![parse_url(
                    data["url"]
                        .as_str()
                        .ok_or(SeamError::NeedFix("url"))?
                        .to_owned(),
                )];
                let title = match get_title(rid, headers).await {
                    Ok(title) => title,
                    Err(_) => "获取失败".to_owned(),
                };
                Ok(Node {
                    rid: rid.to_owned(),
                    title,
                    cover: "".to_owned(),
                    anchor: "".to_owned(),
                    head: "".to_owned(),
                    urls,
                })
            }
            _ => Err(SeamError::None),
        }
    }
}

// TODO 主播名和ID 需要 websocket 获取 就很离谱, 目前先获取标题
// TODO 异步同时请求
async fn get_title(rid: &str, headers: Option<HashMap<String, String>>) -> Result<String> {
    let resp = CLIENT
        .post(format!("{}{}", URL, rid))
        .headers(hash2header(headers))
        .send()
        .await?
        .text()
        .await?;

    let re = Regex::new(r"var room = JSON\.parse\('([\s\S]*?)'\);")?;

    let caps = re.captures(&resp).ok_or(SeamError::None)?;
    let data = caps.get(1).ok_or(SeamError::None)?.as_str();

    let data: serde_json::Value = serde_json::from_str(data)?;
    let title = data["name"].as_str().ok_or(SeamError::None)?;

    Ok(title.to_owned())
}

#[cfg(test)]
macros::gen_test!(96);


================================================
FILE: crates/core/src/util.rs
================================================
use boa_engine::Context;
use boa_engine::Source;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderName;
use reqwest::header::HeaderValue;

use crate::live::Format;
use crate::live::Url;
use std::collections::HashMap;
use std::str::FromStr;

/// js运行时
pub fn eval(js: &str) -> String {
    let mut context = Context::default();
    match context.eval(Source::from_bytes(js)) {
        Ok(result) => result.display().to_string(),
        Err(e) => e.to_string(),
    }
}

pub fn match_format(url: &str) -> Format {
    if url.contains(".m3u8") {
        Format::M3U
    } else if url.contains(".flv") {
        Format::Flv
    } else if url.contains("rtmp:") {
        Format::Rtmp
    } else {
        Format::Other("unknown".to_owned())
    }
}

pub fn parse_url(url: String) -> Url {
    Url {
        format: match_format(&url),
        url: url.to_owned(),
    }
}

/// 获取当前时间
/// 格式:20230121-000000-000 (年月日-时分秒-毫秒)
#[inline]
pub fn get_datetime() -> String {
    chrono::Local::now().format("%Y%m%d-%H%M%S-%3f").to_string()
}

pub fn hash2header(map: Option<HashMap<String, String>>) -> HeaderMap {
    if let Some(map) = map {
        let mut headers = HeaderMap::new();
        for (k, v) in map.iter() {
            if let Ok(k) = HeaderName::from_str(k) {
                match v.parse() {
                    Ok(v) => {
                        headers.insert(k, v);
                    }
                    Err(_) => {
                        headers.insert(k, HeaderValue::from_static(""));
                    }
                }
            }
        }
        headers
    } else {
        HeaderMap::default()
    }
}


================================================
FILE: crates/danmu/Cargo.toml
================================================
[package]
name = "seam_danmu"
version = "0.1.1"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
seam_status = { path = "../status" }
thiserror = "1"
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
futures-sink = "0.3"
futures-util = { version = "0.3", default-features = false, features = [
    "sink",
    "std",
] }
miniz_oxide = "0.7"
colored = "2"
rand = "0.8"
async-trait = "0.1"
paste = "1.0"


================================================
FILE: crates/danmu/src/danmu/afreeca.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Afreeca);


================================================
FILE: crates/danmu/src/danmu/bili.rs
================================================
use async_trait::async_trait;
use miniz_oxide::inflate::decompress_to_vec_zlib;
use rand::Rng;
use seam_status::status::bili::Status;
use seam_status::StatusTrait;
use serde_json::json;

use crate::error::Result;

use crate::{websocket_danmu_work_flow, DanmuBody, DanmuRecorder, DanmuTrait};

const WSS_URL: &str = "wss://broadcastlv.chat.bilibili.com/sub";
const 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 ";
const HEART_BEAT_INTERVAL: u64 = 60;

fn init_msg_generator(rid: &str) -> Vec<Vec<u8>> {
    let mut reg_data = vec![];

    let room_id = rid.parse::<i64>().unwrap();
    let random_uid: u64 = rand::thread_rng().gen_range(100_000_000_000_000..300_000_000_000_000);
    let data = json!({
        "roomid": room_id,
        "uid": random_uid,
        "protover": 1
    })
    .to_string();
    let data = vec![
        (data.len() as i32 + 16).to_be_bytes().to_vec(),
        vec![0x00, 0x10, 0x00, 0x01],
        7i32.to_be_bytes().to_vec(),
        1i32.to_be_bytes().to_vec(),
        data.as_bytes().to_vec(),
    ];
    reg_data.push(data.concat());
    reg_data
}

fn decode_and_record_danmu(data: &[u8]) -> Result<Vec<DanmuBody>> {
    if data.len() < 16 {
        return Ok(vec![]);
    }

    let mut msgs = vec![];

    let data_to_danmu_body = |sliced_data: &[u8]| -> Option<DanmuBody> {
        let j: serde_json::Value = serde_json::from_slice(sliced_data).unwrap();
        let msg_type = j.get("cmd").unwrap().as_str().unwrap();
        if msg_type == "DANMU_MSG" {
            let user = j["info"][2][1].as_str().unwrap().trim().to_string();
            let content = j["info"][1].as_str().unwrap().trim().to_string();
            Some(DanmuBody::new(user, content))
        } else {
            None
        }
    };

    let decompress_data_to_danmu_body = |compressed_data: &[u8]| -> Vec<DanmuBody> {
        let decompressed = decompress_to_vec_zlib(compressed_data).unwrap();
        let mut sptr = 0;
        let mut danmu_bodies = vec![];

        loop {
            let packet_len = u32::from_be_bytes(decompressed[sptr..sptr + 4].try_into().unwrap());
            let op = u32::from_be_bytes(decompressed[sptr + 8..sptr + 12].try_into().unwrap());

            if decompressed[sptr..].len() < packet_len as usize {
                break;
            }

            if op == 5 {
                if let Some(danmu_body) =
                    data_to_danmu_body(&decompressed[sptr + 16..sptr + packet_len as usize])
                {
                    danmu_bodies.push(danmu_body);
                }
            }

            if decompressed[sptr..].len() == packet_len as usize {
                break;
            } else {
                sptr += packet_len as usize;
            }
        }

        danmu_bodies
    };

    let mut sptr = 0;
    loop {
        let packet_len = u32::from_be_bytes(data[sptr..sptr + 4].try_into().unwrap());
        let ver = u16::from_be_bytes(data[sptr + 6..sptr + 8].try_into().unwrap());
        let op = u32::from_be_bytes(data[sptr + 8..sptr + 12].try_into().unwrap());

        if data[sptr..].len() < packet_len as usize {
            break;
        }

        if (ver == 1 || ver == 0) && (op == 5) {
            if let Some(danmu_body) =
                data_to_danmu_body(&data[sptr + 16..sptr + packet_len as usize])
            {
                msgs.push(danmu_body);
            }
        } else if ver == 2 {
            msgs.extend(decompress_data_to_danmu_body(
                &data[sptr + 16..sptr + packet_len as usize],
            ));
        }

        if data[sptr..].len() == packet_len as usize {
            break;
        } else {
            sptr += packet_len as usize;
        }
    }

    Ok(msgs)
}

pub struct Danmu;

#[async_trait]
impl DanmuTrait for Danmu {
    async fn start(rid: &str, recorder: Vec<&dyn DanmuRecorder>) -> Result<()> {
        let heart_beat_msg_generator = || HEART_BEAT.as_bytes().to_vec();
        let heart_beat_interval = HEART_BEAT_INTERVAL;

        let is_closed_room = || async { Status::status(rid).await.unwrap() };

        websocket_danmu_work_flow(
            rid,
            WSS_URL,
            recorder,
            init_msg_generator,
            is_closed_room,
            heart_beat_msg_generator,
            heart_beat_interval,
            decode_and_record_danmu,
        )
        .await?;

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use crate::Terminal;

    use super::*;

    #[tokio::test]
    async fn test_danmu_terminal() {
        Danmu::start("6", vec![&Terminal::try_new(None).unwrap()])
            .await
            .unwrap();
    }
}


================================================
FILE: crates/danmu/src/danmu/cc.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Cc);


================================================
FILE: crates/danmu/src/danmu/douyin.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Douyu);


================================================
FILE: crates/danmu/src/danmu/douyu.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Douyin);


================================================
FILE: crates/danmu/src/danmu/flex.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Flex);


================================================
FILE: crates/danmu/src/danmu/huajiao.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Huajiao);


================================================
FILE: crates/danmu/src/danmu/huya.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Huya);


================================================
FILE: crates/danmu/src/danmu/inke.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Inke);


================================================
FILE: crates/danmu/src/danmu/kk.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Kk);


================================================
FILE: crates/danmu/src/danmu/ks.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Ks);


================================================
FILE: crates/danmu/src/danmu/mht.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Mht);


================================================
FILE: crates/danmu/src/danmu/mod.rs
================================================
pub mod afreeca;
pub mod bili;
pub mod cc;
pub mod douyin;
pub mod douyu;
pub mod flex;
pub mod huajiao;
pub mod huya;
pub mod inke;
pub mod kk;
pub mod ks;
pub mod mht;
pub mod now;
pub mod panda;
pub mod qf;
pub mod wink;
pub mod yqs;


================================================
FILE: crates/danmu/src/danmu/now.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Now);


================================================
FILE: crates/danmu/src/danmu/panda.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Panda);


================================================
FILE: crates/danmu/src/danmu/qf.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Qf);


================================================
FILE: crates/danmu/src/danmu/wink.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Wink);


================================================
FILE: crates/danmu/src/danmu/yqs.rs
================================================
use crate::default_danmu_client;
use crate::error::Result;

default_danmu_client!(Yqs);


================================================
FILE: crates/danmu/src/error.rs
================================================
use thiserror::Error;

pub type Result<T> = std::result::Result<T, SeamDanmuError>;

#[derive(Error, Debug)]
pub enum SeamDanmuError {
    #[error("IO error: {0}")]
    IO(#[from] std::io::Error),
    #[error("Path error: {0}")]
    Path(String),
    #[error("WebSocket error: {0}")]
    WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
    #[error("unknown data store error")]
    Unknown,
}


================================================
FILE: crates/danmu/src/lib.rs
================================================
//! 弹幕相关模块。
//!
//! 本模块提供了标准化的弹幕记录的async trait 以及
//! 标准化的弹幕记录方式enum。
//!
//! 本模块提供了基于websocket的标准弹幕工作流。
//! 如无定制需求,可以直接使用本模块提供的工作流。

pub mod danmu;
pub mod error;

use std::fs::{File, OpenOptions};
use std::future::Future;
use std::io::prelude::*;
use std::path::PathBuf;
use std::pin::Pin;

use async_trait::async_trait;
use colored::Colorize;
use error::{Result, SeamDanmuError};
use futures_sink::Sink;
use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{SinkExt, StreamExt};
use tokio::net::TcpStream;
use tokio_tungstenite::{tungstenite::protocol::Message, MaybeTlsStream, WebSocketStream};

/// 标准化弹幕记录异步接口。
#[async_trait]
pub trait DanmuTrait {
    /// 运行弹幕记录服务。
    ///
    /// 本函数通常将运行websocket长连接,并按指定方式记录弹幕。
    /// 由于websocket的机制,本函数需要`&mut self`作为参数。
    ///
    /// # Errors
    ///
    /// 发生不可继续运行的错误的情况下,返回错误。
    async fn start(rid: &str, recorder: Vec<&dyn DanmuRecorder>) -> Result<()>;
}

/// 标准化弹幕记录trait。
///
/// 本trait提供了标准化的弹幕记录方式。
///
/// - try_new: 尝试使用给定的地址初始化弹幕记录器,None地址可以被终端记录器接受,其他必须有文件地址。
/// - path: 获取弹幕记录的地址,输出到终端为None。
/// - init: 初始化弹幕记录器,如创建文件,创建表头,创建文件格式信息(如BOM头等)。
/// - formatter: 格式化弹幕,将弹幕转换为字符串。
/// - record: 记录弹幕(自动调用自带的formatter函数,所以入参为`&DanmuBody`)。
pub trait DanmuRecorder: Send + Sync {
    fn try_new(path: Option<PathBuf>) -> Result<Self>
    where
        Self: Sized;

    fn path(&self) -> Option<&PathBuf>;

    fn init(&self) -> Result<()> {
        let path = self.path().ok_or_else(|| {
            SeamDanmuError::Path("Path does not exist or failed to open".to_owned())
        })?;
        File::create(path)?;
        Ok(())
    }

    fn formatter(&self, danmu: &DanmuBody) -> String {
        format!(
            "{}{}    {}",
            danmu.user.yellow(),
            ":".yellow(),
            danmu.content.green().bold()
        )
    }

    fn record(&self, danmu: &DanmuBody) -> Result<()> {
        let path = self.path().ok_or_else(|| {
            SeamDanmuError::Path("Path does not exist or failed to open".to_owned())
        })?;
        let mut file = OpenOptions::new().append(true).open(path)?;
        file.write_all(self.formatter(danmu).as_bytes())?;
        file.write_all(b"\n")?;
        Ok(())
    }
}

/// CSV弹幕记录器。
pub struct Csv {
    path: PathBuf,
}

impl DanmuRecorder for Csv {
    fn try_new(path: Option<PathBuf>) -> Result<Self> {
        let file_stem = path.ok_or_else(|| {
            SeamDanmuError::Path("初始化CSV弹幕记录器时未指定文件地址".to_owned())
        })?;
        let path = file_stem.with_extension("csv");
        Ok(Self { path })
    }

    fn path(&self) -> Option<&PathBuf> {
        Some(&self.path)
    }

    /// 初始化csv文件
    /// - 添加BOM头
    /// - 添加表头
    fn init(&self) -> Result<()> {
        let mut file = File::create(&self.path)?;
        let mut init_info: Vec<u8> = vec![0xEF, 0xBB, 0xBF];
        init_info.extend(b"user, content\n");
        file.write_all(&init_info)?;
        Ok(())
    }

    fn formatter(&self, danmu: &DanmuBody) -> String {
        format!("{}, {}", danmu.user, danmu.content)
    }
}

pub struct Terminal;

impl DanmuRecorder for Terminal {
    fn try_new(_path: Option<PathBuf>) -> Result<Self> {
        Ok(Self)
    }

    fn path(&self) -> Option<&PathBuf> {
        None
    }

    fn init(&self) -> Result<()> {
        println!("即将在终端输出弹幕:");
        Ok(())
    }

    fn record(&self, danmu: &DanmuBody) -> Result<()> {
        println!("{}", &self.formatter(danmu));
        Ok(())
    }
}

/// 标准弹幕格式
// TODO: 时间戳
pub struct DanmuBody {
    pub user: String,
    pub content: String,
}

impl DanmuBody {
    pub fn new(user: String, content: String) -> Self {
        Self { user, content }
    }
}

/// 基于websocket的标准弹幕工作流。
///
/// 本函数将会运行websocket长连接,并按指定方式记录弹幕。
///
//
// # 本函数接管的工作流
//
// 1. 连接websocket服务器。
// 2. 发送初始化消息。
// 3. 维持心跳/接收websocket返回的消息。
//
// # 本函数未接管的工作
//
// 1. 检查recorder选项是否支持。
// 2. 生成websocket使用的初始化消息。
// 3. 生成心跳消息。
// 4. 解码并按照recorder的要求记录弹幕。
//
// # 本函数的参数设计
//
// - recorder_checker: 检查recorder选项是否支持,不支持请返回错误。
//
// - init_msg_generator: 生成初始化消息,返回一个Vec<Vec<u8>>,每个Vec<u8>为一条消息。
//   生成的消息将逐条发送给服务器以供初始化websocket。
//
// - heart_beat_msg_generator: 生成心跳消息,返回一个Vec<u8>,为一条消息。
//   生成的消息将按照`heart_beat_interval`的间隔发送给服务器以保持websocket长连接。
//
// - heart_beat_interval: 心跳间隔,单位为秒。
//
// - decode_and_record_danmu: 解码并按照recorder的要求记录弹幕。
//
// - 特别说明:heart_beat与decode_and_record_danmu将在同一线程异步并行。
pub async fn websocket_danmu_work_flow<B>(
    room_id: &str,
    url: &str,
    recorder: Vec<&dyn DanmuRecorder>,
    init_msg_generator: fn(&str) -> Vec<Vec<u8>>,
    is_closed_room: impl Fn() -> B,
    heart_beat_msg_generator: fn() -> Vec<u8>,
    heart_beat_interval: u64,
    decode_and_record_danmu: fn(&[u8]) -> Result<Vec<DanmuBody>>,
) -> Result<()>
where
    B: Future<Output = bool>,
{
    // 初始化websocket连接
    let reg_datas = init_msg_generator(room_id);
    let (mut ws, _) = tokio_tungstenite::connect_async(url).await?;
    for data in reg_datas {
        Pin::new(&mut ws).start_send(Message::Binary(data))?;
    }

    // 分开websocket的读写
    let (mut write, mut read) = ws.split();

    // 异步执行心跳机制和弹幕获取
    // 需要检测直播间是否关闭,如果关闭则停止心跳机制和弹幕获取
    tokio::select! {
        _ = closed_room_checker(is_closed_room) => { }
        _ = heart_beat(&mut write, heart_beat_msg_generator, heart_beat_interval) => { println!("websocket已关闭"); }
        e = fetch_danmu(&mut read, decode_and_record_danmu, recorder) => { e?; }
    }

    Ok(())
}

// 检测直播间是否关闭
async fn closed_room_checker<B>(is_closed_room: impl Fn() -> B)
where
    B: Future<Output = bool>,
{
    loop {
        match is_closed_room().await {
            // 间隔10秒检测一次直播间是否关闭
            true => tokio::time::sleep(tokio::time::Duration::from_secs(10)).await,
            false => {
                println!("直播间已关闭");
                break;
            }
        }
    }
}

// 心跳机制
async fn heart_beat(
    ws_write: &mut SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>,
    heart_beat_msg_generator: fn() -> Vec<u8>,
    heart_beat_interval: u64,
) {
    loop {
        let msg = heart_beat_msg_generator();
        if Pin::new(&mut *ws_write)
            .send(Message::Binary(msg))
            .await
            .is_ok()
        {
            tokio::time::sleep(tokio::time::Duration::from_secs(heart_beat_interval)).await;
        } else {
            let short_rebeat_interval = if heart_beat_interval / 10 > 3 {
                heart_beat_interval / 10
            } else {
                3
            };
            tokio::time::sleep(tokio::time::Duration::from_secs(short_rebeat_interval)).await;
        }
    }
}

// 解码并记录弹幕
async fn fetch_danmu(
    ws_read: &mut SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>,
    decode_and_record_danmu: fn(&[u8]) -> Result<Vec<DanmuBody>>,
    recorder: Vec<&dyn DanmuRecorder>,
) -> Result<()> {
    // 初始化recorder
    for r in recorder.iter() {
        r.init()?;
    }

    let ws_to_stdout = {
        ws_read.for_each(|message| async {
            let data = message.unwrap().into_data();
            let msgs = decode_and_record_danmu(&data).unwrap();
            for msg in msgs.iter() {
                for r in recorder.iter() {
                    if let Err(e) = r.record(msg) {
                        println!("记录弹幕失败: {e}");
                        println!("弹幕内容: {:?}", msg.content);
                    }
                }
            }
        })
    };

    ws_to_stdout.await;
    Ok(())
}

// 为没有实现弹幕功能的直播平台添加默认空白实现
#[macro_export]
macro_rules! default_danmu_client {
    ($name: ident) => {
        use paste::paste;

        paste! {
            use async_trait::async_trait;
            use $crate::{DanmuTrait, DanmuRecorder};

            pub struct Danmu;

            #[async_trait]
            impl DanmuTrait for Danmu {
                async fn start(_rid: &str, _recorder: Vec<&dyn DanmuRecorder>) -> Result<()> {
                    println!("该直播平台暂未实现弹幕功能。");
                    Ok(())
                }
            }
        }
    };
}


================================================
FILE: crates/gui/.eslintrc.json
================================================
{
    "env": {
        "browser": true,
        "es2021": true
    },
    "parser": "@typescript-eslint/parser",
    "plugins": ["@typescript-eslint", "solid", "simple-import-sort"],
    "extends": [
        "eslint:recommended",
        "plugin:@typescript-eslint/recommended",
        "plugin:solid/typescript"
    ],
    "parserOptions": {
        "ecmaVersion": "latest",
        "sourceType": "module"
    },
    "ignorePatterns": ["src/assets", "src/css", "src/*.css"],
    "rules": {
        "semi": ["error", "never"],
        "simple-import-sort/imports": "error",
        "simple-import-sort/exports": "error"
    }
}


================================================
FILE: crates/gui/.gitignore
================================================
node_modules
dist
data

================================================
FILE: crates/gui/.vscode/extensions.json
================================================
{
  "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}


================================================
FILE: crates/gui/CHANGELOG.md
================================================
# Changelog

## [0.1.8]

**请重新获取最新的抖音 cookie**

### 修复

-   修复 douyin 直播源获取

### 更新

-   douyu 支持 直播间封面, 主播名,主播头像获取

## [0.1.7]

### 更新

-   修复抖音, 获取最高清晰度

## [0.1.6]

### 更新

-   目前所有支持平台,房间名获取均可用

## [0.1.5]

### 修复

-   斗鱼获取最清晰线路

### 更新

-   斗鱼在设置 cookie 的情况下尝试获取 备用线路

## [0.1.4]

### 更新

-   新增播放按钮
    -   请自行安装播放器, 请确认它可以通过命令行+链接打开视频文件
    -   需要配置 `play.bin`
    -   获取成功后点击直接播放

## [0.1.3]

### 修复

-   快手代号错误导致的闪退

### 更新

-   抖音,快手直播修复, 但需要新增配置文件, 详情请查看 README 配置模块

## [0.1.2]

### 修复

-   虎牙直播

## [0.1.1]

### 更新

-   调整样式
-   新增发布 action


================================================
FILE: crates/gui/README.md
================================================
<p align="center">
    <img src="../../assets/icon.png" style="width: 150px;" alt="Seam" />
</p>

<h2 align="center">
  Seam
</h2>

目前提供了简单的 GUI 界面, 进一步降低使用门槛

如果你是 win11, 或 win10 以下但安装过 webview2 可以直接使用, 否则你应该安装它, 下载链接: [WebView2](https://developer.microsoft.com/zh-cn/microsoft-edge/webview2/#download-section)

当前 GUI 界面 仅为早期版本, 后期会进行较大修改, 主播头像,直播封面,主播名称,全平台订阅,开播通知,自动录播 都会有的.

使用中出现任何问题都可以提 issue, 或加入 TG 群进行反馈: [Telegram](https://t.me/seam_rust)

下载链接: [Releases](https://github.com/Borber/seam/releases) 自行下载最新 Seam GUI 版本


================================================
FILE: crates/gui/index.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="theme-color" content="#000000" />
        <link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
        <meta name="referrer" content="no-referrer" />
        <title>Seam</title>
    </head>

    <body>
        <div id="root"></div>
        <script src="/src/index.tsx" type="module"></script>
    </body>
</html>


================================================
FILE: crates/gui/package.json
================================================
{
    "name": "seam",
    "version": "0.1.4",
    "description": "",
    "scripts": {
        "start": "vite",
        "dev": "vite",
        "build": "vite build",
        "serve": "vite preview",
        "tauri": "tauri",
        "fix": "eslint --fix src/**"
    },
    "license": "MIT",
    "dependencies": {
        "@solidjs/router": "^0.8.3",
        "@tauri-apps/api": "^1.5.1",
        "solid-js": "^1.8.3",
        "solid-spinner": "^0.2.0",
        "solid-toast": "^0.5.0",
        "solid-transition-group": "^0.2.3"
    },
    "devDependencies": {
        "@tauri-apps/cli": "^1.5.6",
        "@types/node": "^20.8.8",
        "@typescript-eslint/eslint-plugin": "^6.9.0",
        "@typescript-eslint/parser": "^6.9.0",
        "eslint": "^8.52.0",
        "eslint-plugin-simple-import-sort": "^10.0.0",
        "eslint-plugin-solid": "^0.13.0",
        "typescript": "^5.2.2",
        "vite": "^4.5.0",
        "vite-plugin-solid": "^2.7.2"
    }
}


================================================
FILE: crates/gui/src/App.css
================================================
.not-draggable {
  user-select: none;
}

.container {
  width: 100%;
  height: calc(100% - 40px);
  display: flex;
  flex-direction: row;
}


input,
button {
  text-align: center;
  border-radius: 8px;
  border: 1px solid transparent;
  font-weight: 500;
  font-family: inherit;
  color: #ffffff;
  background-color: #0f0f0fc9;
  transition: all 0.3s ease;
  box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6), 2px 2px 5px 0px rgba(0, 0, 0, 0.7);
}

button {
  cursor: pointer;
}

input:hover,
button:hover {
  filter: brightness(1.4);
}



.content {
  flex: 1;
  height: 100%;
  background-color: #373737;
  overflow: auto;
}

/* 美化滚动条 */
::-webkit-scrollbar {
  width: 0;
}

::-webkit-scrollbar-track {
  background-color: #3f3f3f;
}

::-webkit-scrollbar-thumb {
  background-color: #555;
}

================================================
FILE: crates/gui/src/App.tsx
================================================
import "./App.css"
import "./css/TopBar.css"

import { useRoutes } from "@solidjs/router"
import { lazy, onMount } from "solid-js"
import { Toaster } from "solid-toast"

import Control from "./components/Control"
import SideBar from "./components/SideBar"
import TopBar from "./components/TopBar"

const App = () => {
    onMount(async () => {
        // 生产环境, 全局取消右键菜单;
        if (!import.meta.env.DEV) {
            document.oncontextmenu = (event) => {
                event.preventDefault()
            }
        }

        // 避免窗口闪烁, 等待500ms再显示窗口
        // 这个该死的bug什么时候才能修
        setTimeout(() => {
            setupWindow()
        }, 500)
    })

    const setupWindow = async () => {
        const appWindow = (await import("@tauri-apps/api/window")).appWindow
        appWindow.show()
    }

    const routes = [
        { path: "/", component: lazy(() => import("./pages/Home")) },
        { path: "/good", component: lazy(() => import("./pages/Good")) },
        { path: "/chart", component: lazy(() => import("./pages/Chart")) },
        { path: "/setting", component: lazy(() => import("./pages/Setting")) },
    ]

    const Routes = useRoutes(routes)

    return (
        <>
            <Control maximize={false} />
            <TopBar />
            <div class="container  not-draggable">
                <SideBar />
                <div class="content">
                    <Routes />
                </div>
            </div>
            <Toaster
                position="bottom-center"
                gutter={8}
                toastOptions={{
                    className: "",
                    duration: 5000,
                    style: {
                        background: "#0f0f0fc9",
                        color: "#fff",
                    },
                }}
            />
        </>
    )
}

export default App


================================================
FILE: crates/gui/src/components/Control.tsx
================================================
import "../css/Control.css"

import { appWindow } from "@tauri-apps/api/window"
import { Show } from "solid-js"

// TODO 修复最小化, 隐藏时的渲染bug

const Minimize = () => {
    return (
        <svg aria-hidden="false" width="10" height="10" viewBox="0 0 12 12">
            <rect fill="currentColor" width="10" height="1" x="1" y="6" />
        </svg>
    )
}

const Maximize = () => {
    return (
        <svg aria-hidden="false" width="10" height="10" viewBox="0 0 12 12">
            <rect
                width="9"
                height="9"
                x="1.5"
                y="1.5"
                fill="none"
                stroke="currentColor"
            />
        </svg>
    )
}

const Close = () => {
    return (
        <svg aria-hidden="false" width="10" height="10" viewBox="0 0 12 12">
            <polygon
                fill="currentColor"
                fill-rule="evenodd"
                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"
            />
        </svg>
    )
}

export interface BarProps {
    minimize?: boolean
    maximize?: boolean
    close?: boolean
}

const defaultProps: BarProps = {
    minimize: true,
    maximize: true,
    close: true,
}

const Control = (props: BarProps) => {
    const setting = { ...defaultProps, ...props }
    return (
        <>
            <div class="control">
                <Show when={setting.minimize}>
                    <div
                        class="control-item"
                        title="最小化"
                        onClick={() => appWindow.minimize()}>
                        {Minimize()}
                    </div>
                </Show>
                <Show when={setting.maximize}>
                    <div class="control-item" title="最大化">
                        {Maximize()}
                    </div>
                </Show>
                <Show when={setting.close}>
                    <div
                        class="control-item control-item-close"
                        onClick={() => appWindow.close()}
                        title="关闭">
                        {Close()}
                    </div>
                </Show>
            </div>
        </>
    )
}

export default Control


================================================
FILE: crates/gui/src/components/GoodItem.tsx
================================================
import "../css/GoodItem.css"

import { AddIcon, CopyIcon, PlayIcon } from "../icon/icon"

interface Url {
    format: string;
    url: string;
}

interface GoodItemProps {
    live: string;
    rid: string;
    title: string;
    anchor: string;
    urls: Url[];
    img?: string;
}

const GoodItem = (props: GoodItemProps) => {
    return (
        <div class="good-item">
            <img class="good-img" src={props.img ?? "/src/assets/no_img.png"} />
            <div class="good-panel">
                <div class="good-title">{props.title}</div>
                <div class="good-info">快来看</div>
                <div class="good-control">
                    <button class="good-control-btn">
                        <AddIcon size={15} />
                    </button>
                    <button class="good-control-btn">
                        <CopyIcon size={15} />
                    </button>
                    <button class="good-control-btn">
                        <PlayIcon size={15} />
                    </button>
                </div>
            </div>
        </div>
    )
}

export default GoodItem


================================================
FILE: crates/gui/src/components/Live.tsx
================================================
import "../css/Live.css"

import { CopyIcon, PlayIcon } from "../icon/icon"

interface Url {
    format: string;
    url: string;
}

interface LiveProps {
    live: string;
    rid: string;
    title: string;
    anchor: string;
    urls: Url[];
    img?: string;
}

// TODO 减少内阴影的使用, 按钮的样式应该简洁一些

const Live = (props: LiveProps) => {
    return (
        <div class="live">
            <img class="live-img" src={props.img ?? "/src/assets/no_img.png"} />
            <div class="live-panel">
                <div class="live-title">{props.title}</div>
                <div class="live-control">
                    <button class="live-control-btn">
                        <CopyIcon size={15} />
                    </button>
                    <button class="live-control-btn">
                        <PlayIcon size={15} />
                    </button>
                </div>
            </div>
        </div>
    )
}

export default Live


================================================
FILE: crates/gui/src/components/Panel.tsx
================================================
import "../css/Panel.css"

import { Accessor, For, Setter } from "solid-js"

import allLives from "../model/Live"

interface LiveProps {
    flag: Setter<boolean>;
    live: Accessor<string>;
    setLive: Setter<string>;
}

const Panel = (props: LiveProps) => {
    return (
        <div
            class="not-draggable panel"
            onMouseEnter={() => props.flag(true)}
            onMouseLeave={() => props.flag(false)}>
            <div class="panel-container">
                <For each={allLives()}>
                    {(item) => (
                        <div
                            class="panel-item"
                            classList={{
                                "panel-item-activate":
                                    props.live() === item.cmd,
                            }}
                            onClick={() => props.setLive(item.cmd)}>
                            {item.name}
                        </div>
                    )}
                </For>
            </div>
        </div>
    )
}

export default Panel


================================================
FILE: crates/gui/src/components/SideBar.tsx
================================================
import "../css/SideBar.css"

import { useLocation } from "@solidjs/router"
import { createMemo } from "solid-js"

import { ChartIcon, GoodIcon, HomeIcon, SettingIcon } from "../icon/icon"
import SideItem from "./SideItem"

const SideBar = () => {
    const pathname = createMemo(() => {
        return useLocation().pathname
    })
    return (
        <div data-tauri-drag-region class="side-bar">
            <SideItem path="/" pathname={pathname}>
                <HomeIcon size={30} />
            </SideItem>
            <SideItem path="/good" pathname={pathname}>
                <GoodIcon size={30} />
            </SideItem>
            <SideItem path="/chart" pathname={pathname}>
                <ChartIcon size={30} />
            </SideItem>
            <SideItem path="/setting" pathname={pathname} bottom={true}>
                <SettingIcon size={30} />
            </SideItem>
        </div>
    )
}

export default SideBar


================================================
FILE: crates/gui/src/components/SideItem.tsx
================================================
import "../css/SideItem.css"

import { A } from "@solidjs/router"
import { JSX } from "solid-js/jsx-runtime"

const SideItem = (props: {
    children: JSX.Element;
    path: string;
    bottom?: boolean;
    pathname: () => string;
}) => {
    return (
        <A href={props.path} class="side-link">
            <div
                class="side-item"
                classList={{
                    "side-item-bottom": props.bottom,
                    "side-item-selected": props.pathname() == props.path,
                }}>
                {props.children}
            </div>
        </A>
    )
}

export default SideItem


================================================
FILE: crates/gui/src/components/TopBar.tsx
================================================
import { invoke } from "@tauri-apps/api"
import { createSignal } from "solid-js"
import { Spinner, SpinnerType } from "solid-spinner"
import toast from "solid-toast"
import { Transition } from "solid-transition-group"

import { AddIcon, SyncIcon } from "../icon/icon"
import { Resp } from "../model/Resp"
import Panel from "./Panel"

const TopBar = () => {
    const [refresh, setRefresh] = createSignal(false)
    const [rid, setRid] = createSignal("")
    const [onInput, setInput] = createSignal(false)
    const [onPanel, setPanel] = createSignal(false)
    const [live, setLive] = createSignal("bili")

    // TODO 添加成功后应该发布事件, 让 Chart 页面刷新,
    // 当然如果Chart没有接收到消息,说明当前并没有打开Chart页面, 那么就不需要刷新了

    const add = async () => {
        await invoke<Resp<boolean>>("subscribe_add", {
            live: live(),
            rid: rid(),
        }).then((p) => {
            if (p.code === 0) {
                console.log(p.data)
                toast.success("添加成功")
            } else {
                toast.error(p.msg)
            }
        })
    }

    return (
        <div data-tauri-drag-region class="top-bar">
            <button class="top-bar-btn">
                <div class="refresh" onClick={() => setRefresh(!refresh())}>
                    {refresh() ? (
                        <Spinner
                            type={SpinnerType.oval}
                            width={16}
                            height={16}
                        />
                    ) : (
                        <SyncIcon size={20} />
                    )}
                </div>
            </button>
            <input
                placeholder="房间号"
                class="top-bar-input"
                onFocusIn={() => {
                    setInput(true)
                }}
                onFocusOut={() => {
                    setInput(false)
                }}
                onInput={async (e) => {
                    setRid(e.target.value)
                }}
            />
            <button
                class="top-bar-btn"
                onClick={async () => {
                    await add()
                }}>
                <AddIcon size={16} />
            </button>
            <Transition name="slide-fade">
                {(onInput() || onPanel()) && (
                    <Panel flag={setPanel} live={live} setLive={setLive} />
                )}
            </Transition>
        </div>
    )
}

export default TopBar


================================================
FILE: crates/gui/src/css/Chart.css
================================================
.chart {
    width: 60%;
    height: 100%;
    margin: 0 auto;
    color: #fff;
    padding: 20px;
    display: flex;
    flex-direction: column;
    align-items: center;
}

.chart-kind-container {
    flex-shrink: 0;
    display: flex;
    flex-wrap: wrap;
    border-radius: 8px;
    align-content: space-between;
    overflow: hidden;
    border-radius: 8px;
    background-color: #0f0f0faf;
    backdrop-filter: blur(10px);
    box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6), 2px 2px 5px 0px rgba(0, 0, 0, 0.7);

}

.chart-kind-item {
    flex: 1;
    font-size: 16px;
    font-weight: bold;
    color: #fff;
    padding: 5px 8px;
    cursor: pointer;
    text-align: center;
    box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6);
    white-space: nowrap;
}


.chart-kind-item:not(.chart-kind-item-activate):hover {
    background-color: #5f5f5f;
}

.chart-kind-item-activate {
    background-color: #ff8c00;
    box-shadow: inset 0.3px 0.3px 3px 0.9px rgba(260, 260, 260, 0.9);
}

.chart-table {
    margin: 20px 0;
    border-radius: 8px;
    overflow: hidden;
    background-color: #0f0f0faf;
    backdrop-filter: blur(10px);
    box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6), 2px 2px 5px 0px rgba(0, 0, 0, 0.7);
}

.chart-table th {
    padding: 8px;
    text-align: center;
    font-size: 16px;
    font-weight: bold;
    box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6);
    white-space: nowrap;
}

.chart-table td {
    padding: 5px;
    text-align: center;
    box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6);
    white-space: nowrap;
}

.chart-table tr:hover {
    background-color: #5f5f5f;
}

.chart-table-title-1 {
    text-align: center;
    width: 80px;
}

.chart-table-title-2 {
    text-align: center;
    width: 140px;
}

.chart-table-title-3 {
    text-align: center;
    width: 140px;
}

.chart-table-title-4 {
    text-align: center;
    width: 100px;
}

.chart-table button {
    padding: 5px 8px;
}

.chart-separator {
    flex-shrink: 0;
    width: 100%;
    height: 10px;
}

================================================
FILE: crates/gui/src/css/Control.css
================================================
.control {
    position: absolute;
    right: 0;
    display: flex;
    text-align: center;
    justify-content: center;
}

.control-item {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 40px;
    color: #5b595c;
    box-sizing: border-box;
    padding: 10px;
}

.control-item:hover {
    color: #fff;
    background-color: #373737;
}

.control-item.control-item-close:hover {
    color: #fff;
    background-color: #cd1a2b;
}

================================================
FILE: crates/gui/src/css/Good.css
================================================
.good {
    color: #fff;
    display: flex;
    flex-wrap: wrap;
}

================================================
FILE: crates/gui/src/css/GoodItem.css
================================================
.good-item {
    width: 460px;
    height: 300px;
    position: relative;
    background-color: aliceblue;
}

.good-img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center;
}

.good-panel {
    position: absolute;
    bottom: 0;
    width: 100%;
    height: 100px;
    background: #1d1d1d6d;
    backdrop-filter: blur(10px);
    /* box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6); */
}

.good-title {
    padding: 10px 10px 0 10px;
    font-size: 16px;
    font-weight: bold;
}

.good-info {
    font-size: 14px;
    padding: 5px 0 0 10px;

}

.good-control {
    position: absolute;
    bottom: 0;
    right: 0;
    margin-bottom: 5px;
}

.good-control-btn {
    width: 30px;
    height: 30px;
    margin-right: 5px;
}

================================================
FILE: crates/gui/src/css/Home.css
================================================
.home {
    color: #fff;
    display: flex;
    flex-wrap: wrap;
}

================================================
FILE: crates/gui/src/css/Live.css
================================================
.live {
    width: 230px;
    height: 150px;
    position: relative;
}

.live-img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center;
}

.live-panel {
    position: absolute;
    bottom: 0;
    width: 100%;
    height: 60px;
    background: #1d1d1d6d;
    backdrop-filter: blur(10px);
    /* box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6); */
}

.live-title {
    padding: 5px;
    font-size: 14px;
    font-weight: bold;
}

.live-control {
    text-align: right;
}

.live-control-btn {
    width: 30px;
    height: 30px;
    margin-right: 5px;
}

================================================
FILE: crates/gui/src/css/Panel.css
================================================
.panel {
    width: 400px;
    position: absolute;
    z-index: 10;
    top: 40px;
    left: 300px;
    background-color: #0f0f0fa0;
    backdrop-filter: blur(10px);
    border-radius: 8px;
    box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6);

}

.panel-container {
    display: flex;
    flex-wrap: wrap;
    border-radius: 8px;
    align-content: space-between;
    overflow: hidden;
}

.panel-item {
    flex: 1;
    font-size: 14px;
    font-weight: bold;
    color: #fff;
    padding: 6px 10px;
    cursor: pointer;
    text-align: center;
    box-shadow: inset 0.3px 0.3px 3px 0.1px rgba(160, 160, 160, 0.6);
    white-space: nowrap;
}

.panel-item:not(.panel-item-activate):hover {
    background-color: #5d5d5d5d;
}

.panel-item-activate {
    background-color: #ff8c00;
    box-shadow: inset 0.3px 0.3px 3px 0.9px rgba(260, 260, 260, 0.9);
}

================================================
FILE: crates/gui/src/css/Setting.css
================================================
.setting {
    width: 60%;
    height: 100%;
    margin: 0 auto;
    color: #fff;
    padding: 20px;
    display: flex;
    flex-direction: column;
    align-items: center;
}

.setting-title {
    width: 100%;
    font-size: 20px;
    font-weight: bold;
    padding: 5px;
    border-bottom: 1px solid #fff;
    margin-bottom: 5px;
}

.setting-item {
    width: 100%;
    display: flex;
    justify-content: space-between;
    padding: 10px 5px;
    font-size: 16px;
    font-weight: bold;
}

.setting-item:hover {
    background-color: #525252;
}

.setting-item-title {
    line-height: 30px;
}

.setting-input {
    width: 150px;
    height: 30px;
}

.setting-btn {
    width: 50px;
    height: 30px;
    padding: 5px;
    margin-left: 10px;
}

.setting-arg {
    width: 210px;
}

.setting-textarea {
    width: 100%;
    min-height: 100px;
    border-radius: 8px;
    border: 1px solid transparent;
    font-weight: 500;
    font-family: inherit;
    color: #ffffff;
    background-color: #0f0f0fc9;
    padding: 10px;
    transition: all 0.3s ease;
    box-shadow: inset 0.3px 0.3px 3px 0.3px rgba(160, 160, 160, 0.6), 2px 2px 5px 0px rgba(0, 0, 0, 0.7);
}

.setting-save {
    width: 100px;
    height: 30px;
    padding: 5px;
    margin-top: 30px;
}

================================================
FILE: crates/gui/src/css/SideBar.css
================================================
.side-bar {
    width: 80px;
    height: 100%;
    background-color: #1e1e1e;
}

================================================
FILE: crates/gui/src/css/SideItem.css
================================================
.side-link {
    cursor: default;
}

.side-item {
    width: 80px;
    height: 75px;
    display: flex;
    justify-content: center;
    align-items: center;
}

.side-item:not(.side-item-selected):hover {
    background-color: #373737;
}

.side-item-selected {
    background-color: #575757;
}

.side-item-bottom {
    position: absolute;
    bottom: 0;
}

================================================
FILE: crates/gui/src/css/TopBar.css
================================================
.top-bar {
    display: flex;
    justify-content: center;
    align-items: center;
}

.refresh {
    height: 20px;
    width: 20px;
    display: flex;
    justify-content: center;
    align-items: center;
}

.top-bar-input {
    width: 400px;
    height: 30px;
    margin: 5px;
    font-weight: bold;
    font-size: 14px;
}

.top-bar-btn {
    width: 30px;
    height: 30px;
    padding: 0;
    display: flex;
    justify-content: center;
    align-items: center;
}

.slide-fade-enter-active,
.slide-fade-exit-active {
    transition: opacity 0.3s, transform 0.3s;
}

.slide-fade-enter,
.slide-fade-exit-to {
    opacity: 0;
}

================================================
FILE: crates/gui/src/icon/icon.tsx
================================================
interface IconProps {
    size: number
}

const HomeIcon = (props: IconProps) => {
    return (
        <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 100 100"
            width={props.size}
        >
            <path
                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"
                opacity=".35"
            />
            <path
                fill="#f2f2f2"
                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"
            />
            <polygon
                fill="#d9eeff"
                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"
            />
            <polygon
                fill="#ff7575"
                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"
            />
            <polygon
                fill="none"
                stroke="#40396e"
                stroke-linecap="round"
                stroke-linejoin="round"
                stroke-miterlimit="10"
                stroke-width="3"
                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"
            />
            <polygon
                fill="#40396e"
                points="16,41 17.044,37.069 50,19 82.985,37.078 84,41 50,22.506"
                opacity=".35"
            />
            <rect
                width="62"
                height="10"
                x="19"
                y="75"
                fill="#70bfff"
                opacity=".35"
            />
            <rect width="26" height="35" x="37" y="50" fill="#ff7575" />
            <circle cx="56.5" cy="68.5" r="2.5" fill="#40396e" />
        </svg>
    )
}

const GoodIcon = (props: IconProps) => {
    return (
        <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 100 100"
            width={props.size}
        >
            <path
                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	c-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	c1.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	c1.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	c0.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	c0.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	c0.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	C83.537,91.686,77.715,94,69.508,94z"
                opacity=".35"
            />
            <path
                fill="#f2f2f2"
                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	c-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	c1.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	c1.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	c0.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	c0.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	c0.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	C81.537,89.686,75.715,92,67.508,92z"
            />
            <path
                fill="#ffc7a3"
                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"
            />
            <path
                fill="#70bfff"
                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"
            />
            <path
                fill="none"
                stroke="#40396e"
                stroke-linecap="round"
                stroke-linejoin="round"
                stroke-miterlimit="10"
                stroke-width="3"
                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"
            />
            <circle cx="23.501" cy="75.493" r="3.499" fill="#40396e" />
        </svg>
    )
}

const SettingIcon = (props: IconProps) => {
    return (
        <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 100 100"
            width={props.size}
        >
            <path
                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"
                opacity=".35"
            />
            <path
                fill="#f2f2f2"
                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"
            />
            <path
                fill="#9aa2e6"
                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"
            />
            <path
                fill="none"
                stroke="#40396e"
                stroke-linecap="round"
                stroke-linejoin="round"
                stroke-miterlimit="10"
                stroke-width="3"
                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"
            />
            <path
                fill="none"
                stroke="#40396e"
                stroke-linecap="round"
                stroke-linejoin="round"
                stroke-miterlimit="10"
                stroke-width="3"
                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"
            />
            <path
                fill="none"
                stroke="#40396e"
                stroke-miterlimit="10"
                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"
            />
        </svg>
    )
}

const SyncIcon = (props: IconProps) => {
    return (
        <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 100 100"
            width={props.size}
        >
            <path
                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"
                opacity=".35"
            />
            <path
                fill="#f2f2f2"
                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"
            />
            <path
                fill="#96c362"
                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"
            />
            <path
                fill="#40396e"
                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"
            />
        </svg>
    )
}

const AddIcon = (props: IconProps) => {
    return (
        <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 100 100"
            width={props.size}
        >
            <path
                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	C37.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	h17c3.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"
                opacity=".35"
            />
            <path
                fill="#f2f2f2"
                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	C35.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	c3.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"
            />
            <polygon
                fill="#9aa2e6"
                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"
            />
            <path
                fill="#40396e"
                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	c0-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	c0,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	c-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"
            />
        </svg>
    )
}

const CopyIcon = (props: IconProps) => {
    return (
        <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 100 100"
            width={props.size}
        >
            <path
                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"
                opacity=".35"
            />
            <path
                fill="#f2f2f2"
                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"
            />
            <path
                fill="#d9eeff"
                d="M33.06,79.073v-66l34.051-0.131l17.838,18.73v47.27L33.06,79.073z"
            />
            <path
                fill="#70bfff"
                d="M33.06,79.612v-66l34.051-0.131l17.838,18.73v47.27L33.06,79.612z"
                opacity=".35"
            />
            <path
                fill="#70bfff"
                d="M68,14.946v14.27C68,30.201,68.799,31,69.784,31h14.27l-0.892-2.366L69.784,14.809L68,14.946z"
            />
            <path
                fill="#70bfff"
                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"
            />
            <path
                fill="#70bfff"
                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"
            />
            <path
                fill="#70bfff"
                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"
            />
            <path
                fill="#70bfff"
                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"
            />
            <path
                fill="#70bfff"
                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"
            />
            <path
                fill="#70bfff"
                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"
            />
            <g>
                <path
                    fill="#d9eeff"
                    d="M15.424,86.684L15.44,23H51l16,16l-0.062,47.944L15.424,86.684z"
                />
            </g>
            <path
                fill="#70bfff"
                d="M51,22.946v14.27C51,38.201,51.799,39,52.784,39h14.27L51,22.946z"
            />
            <polygon
                fill="none"
                stroke="#40396e"
                stroke-linejoin="round"
                stroke-miterlimit="10"
                stroke-width="3"
                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"
            />
            <g>
                <path
                    fill="#70bfff"
                    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"
                />
                <path
                    fill="#70bfff"
                    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"
                />
                <path
                    fill="#70bfff"
                    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"
                />
                <path
                    fill="#70bfff"
                    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"
                />
                <path
                    fill="#70bfff"
                    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"
                />
                <path
                    fill="#70bfff"
                    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"
                />
            </g>
        </svg>
    )
}

const PlayIcon = (props: IconProps) => {
    return (
        <svg
            xmlns="http://www.w3.org/2000/svg"
            baseProfile="basic"
            viewBox="0 0 100 100"
            width={props.size}
        >
            <path
                d="M28.5913086,94.0322266c-1.0322266,0-2.0625-0.25-2.9799805-0.7231445	c-2.171875-1.1210938-3.5200195-3.3344727-3.5200195-5.7768555V16.4677734c0-2.4423828,1.3481445-4.6557617,3.5185547-5.7758789	c0.9179688-0.4741211,1.9492188-0.7246094,2.9819336-0.7246094c1.3588867,0,2.6621094,0.4169922,3.769043,1.2050781	l49.9848633,35.5297852C84.0576172,47.9194336,85.0795898,49.8999023,85.0795898,52s-1.0219727,4.0805664-2.7333984,5.2973633	L32.3574219,92.8300781C31.2475586,93.6176758,29.9467773,94.0322266,28.5913086,94.0322266z"
                opacity=".35"
            />
            <path
                fill="#F2F2F2"
                d="M26.5913086,92.0322266c-1.0322266,0-2.0625-0.25-2.9799805-0.7231445	c-2.171875-1.1210938-3.5200195-3.3344727-3.5200195-5.7768555V14.4677734c0-2.4423828,1.3481445-4.6557617,3.5185547-5.7758789	c0.9179688-0.4741211,1.9492188-0.7246094,2.9819336-0.7246094c1.3588867,0,2.6621094,0.4169922,3.769043,1.2050781	l49.9848633,35.5297852C82.0576172,45.9194336,83.0795898,47.8999023,83.0795898,50s-1.0219727,4.0805664-2.7333984,5.2973633	L30.3574219,90.8300781C29.2475586,91.6176758,27.9467773,92.0322266,26.5913086,92.0322266z"
            />
            <polygon
                fill="#70BFFF"
                points="76.5793686,50 26.5912914,14.4676981 26.5912914,85.5323029"
            />
            <path
                fill="#40396E"
                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"
            />
        </svg>
    )
}

const ChartIcon = (props: IconProps) => {
    return (
        <svg
            xmlns="http://www.w3.org/2000/svg"
            baseProfile="basic"
            viewBox="0 0 100 100"
            width={props.size}
        >
            <path
                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"
                opacity=".35"
            />
            <path
                fill="#F2F2F2"
                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"
            />
            <path
                fill="#70BFFF"
                d="M84.5,74.9166641h-69V51.9166679L34.6666679,23.166666L61.5,36.5833321L84.5,15.5V74.9166641z"
            />
            <path
                fill="#707CC0"
                d="M84.5,84.5h-69V65.3333359L34.6666679,50L61.5,53.8333321l23-17.25V84.5z"
            />
            <path
                fill="#40396E"
                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"
            />
        </svg>
    )
}

export {
    AddIcon,
    ChartIcon,
    CopyIcon,
    GoodIcon,
    HomeIcon,
    PlayIcon,
    SettingIcon,
    SyncIcon,
}


================================================
FILE: crates/gui/src/index.tsx
================================================
/* @refresh reload */
import "./styles.css"

import { Router } from "@solidjs/router"
import { render } from "solid-js/web"

import App from "./App"

render(
    () => (
        <Router>
            <App />
        </Router>
    ),
    document.getElementById("root") as HTMLElement
)


================================================
FILE: crates/gui/src/model/Live.tsx
================================================
export interface LiveItem {
    name: string;
    cmd: string;
}

const allLives = (): LiveItem[] => {
    const lives: LiveItem[] = [
        {
            name: "B站",
            cmd: "bili",
        },
        {
            name: "斗鱼",
            cmd: "douyu",
        },
        {
            name: "抖音",
            cmd: "douyin",
        },
        {
            name: "虎牙",
            cmd: "huya",
        },
        {
            name: "快手",
            cmd: "ks",
        },
        {
            name: "CC",
            cmd: "cc",
        },
        {
            name: "花椒",
            cmd: "huajiao",
        },
        {
            name: "艺气山",
            cmd: "yqs",
        },
        {
            name: "棉花糖",
            cmd: "mht",
        },
        {
            name: "KK",
            cmd: "kk",
        },
        {
            name: "千帆",
            cmd: "qf",
        },
        {
            name: "Now",
            cmd: "now",
        },
        {
            name: "映客",
            cmd: "inke",
        },
        {
            name: "afreeca",
            cmd: "afreeca",
        },
        {
            name: "pandalive",
            cmd: "panda",
        },
        {
            name: "flex",
            cmd: "flex",
        },
        {
            name: "wink",
            cmd: "wink",
        },
    ]
    return lives
}

export default allLives


================================================
FILE: crates/gui/src/model/Record.tsx
================================================
export interface SubscribeRecord {
    live: string;
    rid: string;
}


================================================
FILE: crates/gui/src/model/Resp.tsx
================================================
export interface Resp<T> {
    code: number;
    msg: string;
    data: T;
}


================================================
FILE: crates/gui/src/pages/Chart.tsx
================================================
import "../css/Chart.css"

import { invoke } from "@tauri-apps/api"
import { createMemo, createSignal, For, onMount } from "solid-js"
import toast from "solid-toast"

import allLives from "../model/Live"
import { Resp } from "../model/Resp"

interface Record {
    index: number
    live: string
    rid: string
    anchor: string
}

interface Subscribe {
    live: string
    rid: string
}

const Chart = () => {
    const [selected, setSelect] = createSignal("all")
    const [records, setRecords] = createSignal<Record[]>([])

    // 开启页面获取 records 数据

    onMount(async () => {
        const subscribes = await invoke<Resp<Subscribe[]>>("subscribe_all")
        console.log(subscribes)
        const map = subscribes.data.map((item, index) => {
            return {
                index: index,
                live: item.live,
                rid: item.rid,
                anchor: "未知",
            }
            setRecords(map)
        })
        setRecords(map)
    })

    const filterRecords = createMemo(() => {
        if (selected() === "all") {
            return records()
        } else {
            return records().filter((item) => item.live === selected())
        }
    })

    const deleteRecord = async (index: number) => {
        const item = records().find((item) => item.index === index)
        setRecords(records().filter((item) => item.index !== index))
        await invoke<Resp<Subscribe[]>>("subscribe_remove", {
            live: item?.live,
            rid: item?.rid,
        }).then((resp) => {
            if (resp.code === 0) {
                toast.success("删除成功")
            } else {
                toast.error(resp.msg)
            }
        })
    }

    const liveName = (live: string) => {
        const lives = allLives()
        for (let i = 0; i < lives.length; i++) {
            if (lives[i].cmd === live) {
                return lives[i].name
            }
        }
        return "未知"
    }

    return (
        <div class="chart">
            <div class="chart-kind-container">
                <div
                    class="chart-kind-item"
                    classList={{
                        "chart-kind-item-activate": selected() === "all",
                    }}
                    onClick={() => setSelect("all")}>
                    ALL
                </div>
                <For each={allLives()}>
                    {(item) => (
                        <div
                            class="chart-kind-item"
                            classList={{
                                "chart-kind-item-activate":
                                    selected() === item.cmd,
                            }}
                            onClick={() => setSelect(item.cmd)}>
                            {item.name}
                        </div>
                    )}
                </For>
            </div>
            <table class="chart-table">
                <thead>
                    <tr>
                        <th class="chart-table-title-1">平台</th>
                        <th class="chart-table-title-2">房间号</th>
                        <th class="chart-table-title-3">主播</th>
                        <th class="chart-table-title-4">操作</th>
                    </tr>
                </thead>
                <tbody>
                    <For each={filterRecords()}>
                        {(item) => (
                            <tr>
                                <td>{liveName(item.live)}</td>
                                <td>{item.rid}</td>
                                <td>{item.anchor}</td>
                                <td>
                                    <button
                                        onClick={() => {
                                            deleteRecord(item.index)
                                        }}>
                                        删除
                                    </button>
                                </td>
                            </tr>
                        )}
                    </For>
                </tbody>
            </table>
            <div class="chart-separator" />
        </div>
    )
}

export default Chart


================================================
FILE: crates/gui/src/pages/Good.tsx
================================================
import "../css/Good.css"

import GoodItem from "../components/GoodItem"

const Good = () => {
    const goodDemo = {
        live: "douyu",
        rid: "123",
        title: "恭喜你发现了我~",
        anchor: "我是谁",
        urls: [],
        img: undefined,
    }
    return (
        <div class="good">
            <GoodItem {...goodDemo} />
            <GoodItem {...goodDemo} />
            <GoodItem {...goodDemo} />
            <GoodItem {...goodDemo} />
            <GoodItem {...goodDemo} />
        </div>
    )
}

export default Good


================================================
FILE: crates/gui/src/pages/Home.tsx
================================================
import "../css/Home.css"

import Live from "../components/Live"

const Home = () => {
    const liveDemo = {
        live: "douyu",
        rid: "123",
        title: "恭喜你发现了我~",
        anchor: "我是谁",
        urls: [],
        img: undefined,
    }
    return (
        <div class="home">
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
            <Live {...liveDemo} />
        </div>
    )
}

export default Home


================================================
FILE: crates/gui/src/pages/Setting.tsx
================================================
import "../css/Setting.css"

import { open } from "@tauri-apps/api/dialog"
import toast from "solid-toast"

// TODO headers 设置分离, 取消 textarea, 列表,
// 顶部添加, 选择平台, 输入字段, 值, 点击添加
// 列表最右边添加删除按钮, 点击删除
// 列表的值可以修改, 点击修改

const Setting = () => {
    const save = () => {
        toast.success("保存成功")
    }
    return (
        <div class="setting">
            <div class="setting-title">播放</div>
            <div class="setting-item">
                <div class="setting-item-title">播放器</div>
                <div>
                    <input class="setting-input" placeholder="命令/地址" />
                    <button
                        class="setting-btn"
                        onClick={async () => {
                            const file = await open()
                            console.log(file)
                        }}>
                        选择
                    </button>
                </div>
            </div>
            <div class="setting-item">
                <div class="setting-item-title">参数</div>
                <input
                    class="setting-input setting-arg"
                    placeholder="逗号分隔"
                />
            </div>

            {/* TODO 将目前已知需要额外配置的 cookie 写入此处, 让用户知道哪些需要额外配置 */}
            <div class="setting-title">Headers</div>
            <textarea class="setting-textarea" placeholder="按照官网配置" />
            <button class="setting-save" onClick={() => save()}>
                保存
            </button>
        </div>
    )
}

export default Setting


================================================
FILE: crates/gui/src/styles.css
================================================
html,
body {
  height: 100%;
}

html,
body,
h1,
h2,
h3,
h4,
h5,
h6,
div,
dl,
dt,
dd,
ul,
ol,
li,
p,
blockquote,
pre,
hr,
figure,
table,
caption,
th,
td,
form,
fieldset,
legend,
input,
button,
textarea,
menu {
  margin: 0;
  padding: 0;
}

header,
footer,
section,
article,
aside,
nav,
hgroup,
address,
figure,
figcaption,
menu,
details {
  display: block;
}

table {
  border-collapse: collapse;
  border-spacing: 0;
}

caption,
th {
  text-align: left;
  font-weight: normal;
}

html,
body,
fieldset,
img,
iframe,
abbr {
  border: 0;
}

i,
cite,
em,
var,
address,
dfn {
  font-style: normal;
}

[hidefocus],
summary {
  outline: 0;
}

li {
  list-style: none;
}

h1,
h2,
h3,
h4,
h5,
h6,
small {
  font-size: 100%;
}

sup,
sub {
  font-size: 83%;
}

pre,
code,
kbd,
samp {
  font-family: inherit;
}

q:before,
q:after {
  content: none;
}

textarea {
  overflow: auto;
  resize: none;
}

label,
summary {
  cursor: default;
}

a,
button {
  cursor: pointer;
}

h1,
h2,
h3,
h4,
h5,
h6,
em,
strong,
b {
  font-weight: bold;
}

del,
ins,
u,
s,
a,
a:hover {
  text-decoration: none;
}

body,
textarea,
input,
button,
select,
keygen,
legend {
  font: 12px/1.14 Microsoft YaHei, arial, \5b8b\4f53;
  color: #333;
  outline: 0;
}

body {
  background: #1e1e1e;
}

a,
a:hover {
  color: #333;
}

* {
  box-sizing: border-box;
}

input:-webkit-autofill {
  -webkit-box-shadow: 0 0 0px 1000px white inset;
}

#root {
  height: 100%;
}

/*
  禁止双指滑动滚动反弹效果
  参考资料: https://developer.mozilla.org/zh-CN/docs/Web/CSS/overscroll-behavior
*/
body {
  overscroll-behavior: none;
}

/* 选中颜色 */
::selection {
  background: #ff8c00;
  color: #fff;
}

================================================
FILE: crates/gui/src-tauri/.gitignore
================================================
# Generated by Cargo
# will have compiled files and executables
/target/



================================================
FILE: crates/gui/src-tauri/Cargo.toml
================================================
[package]
name = "seam_gui"
version = "0.1.8"
description = "seam"
authors = ["Borber"]
license = ""
repository = ""
edition = "2021"

[build-dependencies]
tauri-build = { version = "1.5", features = [] }

[dependencies]
seam_core = { path = "../../core" }

tauri = { version = "1.5", features = [
    "dialog-all",
    "window-show",
    "window-minimize",
    "window-close",
    "window-maximize",
    "shell-open",
    "window-start-dragging",
] }

tokio = { version = "*", features = ["full"] }

anyhow = "1"
once_cell = "1"

serde = { version = "1", features = ["derive"] }
serde_json = "1"
basic-toml = "0.1"

window-shadows = "0.2"

sea-orm = { version = "0.12", features = [
    "sqlx-sqlite",
    "runtime-tokio-native-tls",
    "macros",
] }

[features]
# this feature is used for production builds or when `devPath` points to the filesystem
# DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]

[target.'cfg(unix)'.dependencies]
openssl = { version = "0.10", features = ["vendored"] }


================================================
FILE: crates/gui/src-tauri/build.rs
================================================
fn main() {
    tauri_build::build()
}


================================================
FILE: crates/gui/src-tauri/src/command/live.rs
================================================
use seam_core::{error::SeamError, live::Node};

use crate::{clients, config::headers, resp::Resp, util};

#[tauri::command]
pub async fn url(live: String, rid: String) -> Resp<Node> {
    let cli = match clients!().get(&live) {
        Some(cli) => cli,
        None => return Resp::fail(0, "目前不支持该平台"),
    };
    match cli.get(&rid, Some(headers(&live))).await {
        Ok(node) => Resp::success(node),
        Err(e) => match e {
            SeamError::None => Resp::fail(1, "未开播"),
            SeamError::NeedFix(msg) => Resp::fail(2, msg),
            _ => Resp::fail(3, &e.to_string()),
        },
    }
}

#[tauri::command]
pub async fn play(url: String) -> Resp<bool> {
    util::play(&url).into()
}


================================================
FILE: crates/gui/src-tauri/src/command/mod.rs
================================================
pub mod live;
pub mod refresh;
pub mod subscribe;


================================================
FILE: crates/gui/src-tauri/src/command/refresh.rs
================================================
use tauri::AppHandle;

use crate::{manager, resp::Resp};

#[tauri::command]
pub async fn refresh_all(app: AppHandle) -> Resp<()> {
    manager::refresh::all(&app).await.into()
}

#[tauri::command]
pub async fn refresh_one(app: AppHandle, live: String, rid: String) -> Resp<()> {
    manager::refresh::one(&app, live, rid).await.into()
}


================================================
FILE: crates/gui/src-tauri/src/command/subscribe.rs
================================================
use crate::{database, resp::Resp, service};

#[tauri::command]
pub async fn subscribe_all() -> Resp<Vec<database::subscribe::Model>> {
    service::subscribe::all().await.into()
}

#[tauri::command]
pub async fn subscribe_add(live: String, rid: String) -> Resp<bool> {
    service::subscribe::add(live, rid).await.into()
}

#[tauri::command]
pub async fn subscribe_remove(live: String, rid: String) -> Resp<bool> {
    service::subscribe::remove(live, rid).await.into()
}


================================================
FILE: crates/gui/src-tauri/src/common.rs
================================================
use std::{collections::HashMap, path::Path, sync::Arc};

use sea_orm::Database;
use sea_orm::DatabaseConnection;
use seam_core::live::{self, Live};
use tokio::sync::OnceCell;

use crate::database;
use crate::util::bin_dir;

#[macro_export]
macro_rules! pool {
    () => {
        &$crate::common::CONTEXT.get().unwrap().pool
    };
}

#[macro_export]
macro_rules! clients {
    () => {
        &$crate::common::CONTEXT.get().unwrap().clients
    };
}

pub static CONTEXT: OnceCell<Context> = OnceCell::const_new();

pub struct Context {
    pub pool: DatabaseConnection,
    pub clients: HashMap<String, Arc<dyn Live>>,
}

pub async fn load() -> Context {
    let path = Path::new(&bin_dir()).join("data.db");
    let flag = path.exists();
    if !flag {
        std::fs::File::create(&path).unwrap();
    }

    // TODO 后续需要优化
    let path = format!("sqlite://{}", path.to_str().unwrap());

    let pool = Database::connect(path)
        .await
        .expect("Connect database failed");
    if !flag {
        // 初始化数据库
        // TODO 打印日志
        match database::init(&pool).await {
            Ok(_) => {
                println!("初始化数据库成功");
            }
            Err(e) => {
                panic!("{}", e.to_string());
            }
        };
    }
    let clients = live::all();
    Context { pool, clients }
}

#[cfg(test)]
mod tests {
    #[tokio::test]
    async fn test() {
        println!(
            "{:#?}",
            &super::CONTEXT
                .get()
                .unwrap()
                .clients
                .get("bili")
                .unwrap()
                .get("6", None)
                .await
                .unwrap()
        );
    }
}


================================================
FILE: crates/gui/src-tauri/src/config.rs
================================================
use std::collections::HashMap;

use once_cell::sync::Lazy;
use serde::Deserialize;

use crate::util::bin_dir;

#[derive(Deserialize, Debug)]
pub struct ConfigOption {
    pub play: Option<PlayOption>,
    pub headers: Option<HashMap<String, HashMap<String, String>>>,
}

#[derive(Debug)]
pub struct Config {
    pub play: Play,
    pub headers: HashMap<String, HashMap<String, String>>,
}

#[derive(Deserialize, Debug)]
pub struct PlayOption {
    pub bin: Option<String>,
    pub args: Option<Vec<String>>,
}

#[derive(Debug, Default)]
pub struct Play {
    pub bin: String,
    pub args: Vec<String>,
}

/// 配置文件
pub static CONFIG: Lazy<Config> = Lazy::new(|| {
    let config =
        std::fs::read_to_string(format!("{}config.toml", bin_dir(),)).unwrap_or("".to_owned());
    let config_file = basic_toml::from_str::<ConfigOption>(&config).unwrap();

    let bin = config_file
        .play
        .as_ref()
        .and_then(|play| play.bin.clone())
        .unwrap_or_default();

    let args = config_file
        .play
        .as_ref()
        .and_then(|play| play.args.clone())
        .unwrap_or_default();

    let play = Play { bin, args };

    let headers = config_file.headers.unwrap_or_default();

    Config { play, headers }
});

pub fn headers(live: &str) -> HashMap<String, String> {
    let global = CONFIG
        .headers
        .get("global")
        .unwrap_or(&HashMap::new())
        .clone();
    let mut live = CONFIG.headers.get(live).unwrap_or(&HashMap::new()).clone();
    live.extend(global);
    live
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_config() {
        // 初始化 CONFIG
        let _ = CONFIG.headers.get("bili").unwrap_or(&HashMap::new());
        println!("{:#?}", CONFIG);
    }
}


================================================
FILE: crates/gui/src-tauri/src/database/mod.rs
================================================
pub mod subscribe;

use anyhow::Result;
use sea_orm::*;

/// 初始化数据库
pub async fn init(db: &DatabaseConnection) -> Result<ExecResult, DbErr> {
    let backend = DbBackend::Sqlite;
    let schema = Schema::new(backend);
    let stmt = backend.build(&schema.create_table_from_entity(subscribe::Entity));
    db.execute(stmt).await
}


================================================
FILE: crates/gui/src-tauri/src/database/subscribe.rs
================================================
use sea_orm::entity::prelude::*;
use serde::Serialize;

/// 订阅记录
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]
#[sea_orm(table_name = "subscribe")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub live: String,
    #[sea_orm(primary_key)]
    pub rid: String,
}

#[derive(Copy, Clone, Debug, EnumIte
Download .txt
gitextract_1s2z3e_6/

├── .github/
│   ├── dependabot.yml
│   └── workflows/
│       ├── release_cli.yml
│       ├── release_gui.yml
│       └── rust-clippy.yml
├── .gitignore
├── Cargo.toml
├── LICENSE-MIT
├── LICENSE-UNLICENSE
├── README.md
├── TODO.md
├── build/
│   ├── build-host-release
│   ├── build-host-release.ps1
│   ├── build-release
│   └── build-release-zigbuild
├── config.toml
├── crates/
│   ├── cli/
│   │   ├── CHANGELOG.md
│   │   ├── Cargo.toml
│   │   ├── README.md
│   │   └── src/
│   │       ├── common.rs
│   │       ├── config.rs
│   │       ├── main.rs
│   │       └── util.rs
│   ├── core/
│   │   ├── Cargo.toml
│   │   └── src/
│   │       ├── common.rs
│   │       ├── error.rs
│   │       ├── lib.rs
│   │       ├── live/
│   │       │   ├── afreeca.rs
│   │       │   ├── bili.rs
│   │       │   ├── cc.rs
│   │       │   ├── douyin.rs
│   │       │   ├── douyu.rs
│   │       │   ├── flex.rs
│   │       │   ├── huajiao.rs
│   │       │   ├── huya.rs
│   │       │   ├── inke.rs
│   │       │   ├── kk.rs
│   │       │   ├── ks.rs
│   │       │   ├── mht.rs
│   │       │   ├── mod.rs
│   │       │   ├── now.rs
│   │       │   ├── panda.rs
│   │       │   ├── qf.rs
│   │       │   ├── twitch.rs
│   │       │   ├── wink.rs
│   │       │   └── yqs.rs
│   │       └── util.rs
│   ├── danmu/
│   │   ├── Cargo.toml
│   │   └── src/
│   │       ├── danmu/
│   │       │   ├── afreeca.rs
│   │       │   ├── bili.rs
│   │       │   ├── cc.rs
│   │       │   ├── douyin.rs
│   │       │   ├── douyu.rs
│   │       │   ├── flex.rs
│   │       │   ├── huajiao.rs
│   │       │   ├── huya.rs
│   │       │   ├── inke.rs
│   │       │   ├── kk.rs
│   │       │   ├── ks.rs
│   │       │   ├── mht.rs
│   │       │   ├── mod.rs
│   │       │   ├── now.rs
│   │       │   ├── panda.rs
│   │       │   ├── qf.rs
│   │       │   ├── wink.rs
│   │       │   └── yqs.rs
│   │       ├── error.rs
│   │       └── lib.rs
│   ├── gui/
│   │   ├── .eslintrc.json
│   │   ├── .gitignore
│   │   ├── .vscode/
│   │   │   └── extensions.json
│   │   ├── CHANGELOG.md
│   │   ├── README.md
│   │   ├── index.html
│   │   ├── package.json
│   │   ├── src/
│   │   │   ├── App.css
│   │   │   ├── App.tsx
│   │   │   ├── components/
│   │   │   │   ├── Control.tsx
│   │   │   │   ├── GoodItem.tsx
│   │   │   │   ├── Live.tsx
│   │   │   │   ├── Panel.tsx
│   │   │   │   ├── SideBar.tsx
│   │   │   │   ├── SideItem.tsx
│   │   │   │   └── TopBar.tsx
│   │   │   ├── css/
│   │   │   │   ├── Chart.css
│   │   │   │   ├── Control.css
│   │   │   │   ├── Good.css
│   │   │   │   ├── GoodItem.css
│   │   │   │   ├── Home.css
│   │   │   │   ├── Live.css
│   │   │   │   ├── Panel.css
│   │   │   │   ├── Setting.css
│   │   │   │   ├── SideBar.css
│   │   │   │   ├── SideItem.css
│   │   │   │   └── TopBar.css
│   │   │   ├── icon/
│   │   │   │   └── icon.tsx
│   │   │   ├── index.tsx
│   │   │   ├── model/
│   │   │   │   ├── Live.tsx
│   │   │   │   ├── Record.tsx
│   │   │   │   └── Resp.tsx
│   │   │   ├── pages/
│   │   │   │   ├── Chart.tsx
│   │   │   │   ├── Good.tsx
│   │   │   │   ├── Home.tsx
│   │   │   │   └── Setting.tsx
│   │   │   └── styles.css
│   │   ├── src-tauri/
│   │   │   ├── .gitignore
│   │   │   ├── Cargo.toml
│   │   │   ├── build.rs
│   │   │   ├── icons/
│   │   │   │   └── icon.icns
│   │   │   ├── src/
│   │   │   │   ├── command/
│   │   │   │   │   ├── live.rs
│   │   │   │   │   ├── mod.rs
│   │   │   │   │   ├── refresh.rs
│   │   │   │   │   └── subscribe.rs
│   │   │   │   ├── common.rs
│   │   │   │   ├── config.rs
│   │   │   │   ├── database/
│   │   │   │   │   ├── mod.rs
│   │   │   │   │   └── subscribe.rs
│   │   │   │   ├── main.rs
│   │   │   │   ├── manager/
│   │   │   │   │   ├── mod.rs
│   │   │   │   │   └── refresh.rs
│   │   │   │   ├── model.rs
│   │   │   │   ├── resp.rs
│   │   │   │   ├── service/
│   │   │   │   │   ├── mod.rs
│   │   │   │   │   └── subscribe.rs
│   │   │   │   ├── setup.rs
│   │   │   │   └── util.rs
│   │   │   └── tauri.conf.json
│   │   ├── tsconfig.json
│   │   └── vite.config.ts
│   ├── marcos/
│   │   ├── Cargo.toml
│   │   └── src/
│   │       └── lib.rs
│   └── status/
│       ├── Cargo.toml
│       └── src/
│           ├── common.rs
│           ├── error.rs
│           ├── lib.rs
│           └── status/
│               ├── bili.rs
│               ├── cc.rs
│               ├── douyin.rs
│               └── mod.rs
├── doc/
│   ├── 配置说明.md
│   └── 额外安装.md
├── justfile
└── script/
    └── gui_version.lua
Download .txt
SYMBOL INDEX (208 symbols across 59 files)

FILE: crates/cli/src/common.rs
  function test (line 11) | async fn test() {

FILE: crates/cli/src/config.rs
  type ConfigOption (line 9) | pub struct ConfigOption {
  type Config (line 15) | pub struct Config {
  type FileNameConfig (line 21) | pub struct FileNameConfig {
  type FileNameOption (line 27) | pub struct FileNameOption {
  function headers (line 51) | pub fn headers(live: &str) -> HashMap<String, String> {
  function test_config (line 67) | fn test_config() {

FILE: crates/cli/src/main.rs
  type Cli (line 20) | struct Cli {
  type Commands (line 48) | enum Commands {
  function cli (line 54) | pub async fn cli() -> Result<()> {
  function main (line 143) | async fn main() -> Result<()> {

FILE: crates/cli/src/util.rs
  constant SEPARATOR (line 2) | const SEPARATOR: &str = "\\";
  constant SEPARATOR (line 5) | const SEPARATOR: &str = "/";
  function bin_dir (line 7) | pub fn bin_dir() -> String {

FILE: crates/core/src/common.rs
  constant USER_AGENT (line 5) | pub const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...

FILE: crates/core/src/error.rs
  type Result (line 3) | pub type Result<T> = std::result::Result<T, SeamError>;
  type SeamError (line 7) | pub enum SeamError {

FILE: crates/core/src/live/afreeca.rs
  constant URL (line 14) | const URL: &str = "https://play.afreecatv.com/";
  constant PLAY_URL (line 15) | const PLAY_URL: &str = "https://live.afreecatv.com/afreeca/player_live_a...
  constant CDN (line 16) | const CDN: &str = "https://live-global-cdn-v02.afreecatv.com/live-stmc-3...
  type Client (line 21) | pub struct Client;
  method get (line 25) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...

FILE: crates/core/src/live/bili.rs
  constant INIT_URL (line 15) | const INIT_URL: &str = "https://api.live.bilibili.com/room/v1/Room/room_...
  constant INFO_URL (line 16) | const INFO_URL: &str =
  constant PLAY_URL (line 18) | const PLAY_URL: &str = "https://api.live.bilibili.com/xlive/web-room/v2/...
  type Client (line 23) | pub struct Client;
  method get (line 27) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...
  function get_bili_stream_info (line 137) | pub async fn get_bili_stream_info(

FILE: crates/core/src/live/cc.rs
  constant URL (line 12) | const URL: &str = "https://cc.163.com/";
  type Client (line 17) | pub struct Client;
  method get (line 21) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...

FILE: crates/core/src/live/douyin.rs
  constant URL (line 15) | const URL: &str = "https://live.douyin.com/";
  constant ENTER_URL (line 16) | const ENTER_URL: &str =
  type Client (line 22) | pub struct Client;
  method get (line 27) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...

FILE: crates/core/src/live/douyu.rs
  constant URL (line 18) | const URL: &str = "https://www.douyu.com/";
  constant PLAY_URL (line 19) | const PLAY_URL: &str = "https://www.douyu.com/lapi/live/getH5Play/";
  constant BETARD_URL (line 20) | const BETARD_URL: &str = "https://www.douyu.com/betard/";
  constant DID (line 21) | const DID: &str = "10000000000000000000000000001501";
  type Client (line 26) | pub struct Client;
  method get (line 31) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...
  type DouyuInfo (line 190) | struct DouyuInfo {
  function get_info (line 197) | async fn get_info(rid: &str, headers: HeaderMap) -> Result<DouyuInfo> {

FILE: crates/core/src/live/flex.rs
  constant URL (line 13) | const URL: &str = "https://api.flextv.co.kr/api/channels/rid/stream?opti...
  type Client (line 18) | pub struct Client;
  method get (line 22) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...

FILE: crates/core/src/live/huajiao.rs
  constant URL (line 14) | const URL: &str = "https://www.huajiao.com/l/";
  type Client (line 19) | pub struct Client;
  method get (line 23) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...

FILE: crates/core/src/live/huya.rs
  constant URL (line 19) | const URL: &str = "https://m.huya.com/";
  type Client (line 24) | pub struct Client;
  method get (line 28) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...
  function get_uuid (line 138) | fn get_uuid(now: u128, rand: u32) -> u128 {
  function get_anonymous_uid (line 142) | async fn get_anonymous_uid() -> Result<u128> {
  function process_anticode (line 165) | fn process_anticode(

FILE: crates/core/src/live/inke.rs
  constant URL (line 13) | const URL: &str = "https://webapi.busi.inke.cn/web/live_share_pc?uid=";
  type Client (line 18) | pub struct Client;
  method get (line 22) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...

FILE: crates/core/src/live/kk.rs
  constant URL (line 14) | const URL: &str = "https://www.kktv5.com/show/";
  type Client (line 19) | pub struct Client;
  method get (line 24) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...

FILE: crates/core/src/live/ks.rs
  constant URL (line 14) | const URL: &str = "https://live.kuaishou.com/u/";
  type Client (line 19) | pub struct Client;
  method get (line 24) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...

FILE: crates/core/src/live/mht.rs
  constant URL (line 14) | const URL: &str = "https://www.2cq.com/proxy/room/room/info";
  type Client (line 19) | pub struct Client;
  method get (line 24) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...

FILE: crates/core/src/live/mod.rs
  type Live (line 37) | pub trait Live: Send + Sync {
    method get (line 41) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>...
  function test_get (line 52) | async fn test_get() {
  type Node (line 79) | pub struct Node {
    method json (line 89) | pub fn json(&self) -> String {
  type Url (line 95) | pub struct Url {
    method is_m3u8 (line 103) | pub fn is_m3u8(&self) -> Result<String> {
  type Format (line 112) | pub enum Format {
  method serialize (line 120) | fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::E...

FILE: crates/core/src/live/now.rs
  constant ROOM_URL (line 13) | const ROOM_URL: &str =
  constant URL (line 15) | const URL: &str = "https://now.qq.com/pcweb/story.html?roomid=";
  type Client (line 19) | pub struct Client;
  method get (line 23) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...
  function get_title (line 60) | async fn get_title(rid: &str, headers: Option<HashMap<String, String>>) ...

FILE: crates/core/src/live/panda.rs
  constant URL (line 3) | const URL: &str = "https://api.pandalive.co.kr/v1/live/play/";
  type Client (line 18) | pub struct Client;
  method get (line 22) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...

FILE: crates/core/src/live/qf.rs
  constant URL (line 14) | const URL: &str = "https://qf.56.com/";
  type Client (line 19) | pub struct Client;
  method get (line 23) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...

FILE: crates/core/src/live/twitch.rs
  type Client (line 15) | pub struct Client;
    method get_access_token (line 64) | async fn get_access_token(
    method get_live_streams (line 92) | async fn get_live_streams(
    method get_channel_metadata (line 132) | async fn get_channel_metadata(&self, rid: &str, headers: HeaderMap) ->...
  method get (line 19) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...
  function test_twitch (line 182) | async fn test_twitch() {

FILE: crates/core/src/live/wink.rs
  constant URL (line 3) | const URL: &str = "https://api.winktv.co.kr/v1/live/play";
  type Client (line 18) | pub struct Client;
  method get (line 22) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...

FILE: crates/core/src/live/yqs.rs
  constant URL (line 14) | const URL: &str = "https://www.173.com/";
  constant ROOM_URL (line 15) | const ROOM_URL: &str = "https://www.173.com/room/getVieoUrl";
  type Client (line 20) | pub struct Client;
  method get (line 24) | async fn get(&self, rid: &str, headers: Option<HashMap<String, String>>)...
  function get_title (line 64) | async fn get_title(rid: &str, headers: Option<HashMap<String, String>>) ...

FILE: crates/core/src/util.rs
  function eval (line 13) | pub fn eval(js: &str) -> String {
  function match_format (line 21) | pub fn match_format(url: &str) -> Format {
  function parse_url (line 33) | pub fn parse_url(url: String) -> Url {
  function get_datetime (line 43) | pub fn get_datetime() -> String {
  function hash2header (line 47) | pub fn hash2header(map: Option<HashMap<String, String>>) -> HeaderMap {

FILE: crates/danmu/src/danmu/bili.rs
  constant WSS_URL (line 12) | const WSS_URL: &str = "wss://broadcastlv.chat.bilibili.com/sub";
  constant HEART_BEAT (line 13) | const HEART_BEAT: &str = "\x00\x00\x00\x1f\x00\x10\x00\x01\x00\x00\x00\x...
  constant HEART_BEAT_INTERVAL (line 14) | const HEART_BEAT_INTERVAL: u64 = 60;
  function init_msg_generator (line 16) | fn init_msg_generator(rid: &str) -> Vec<Vec<u8>> {
  function decode_and_record_danmu (line 38) | fn decode_and_record_danmu(data: &[u8]) -> Result<Vec<DanmuBody>> {
  type Danmu (line 120) | pub struct Danmu;
  method start (line 124) | async fn start(rid: &str, recorder: Vec<&dyn DanmuRecorder>) -> Result<(...
  function test_danmu_terminal (line 153) | async fn test_danmu_terminal() {

FILE: crates/danmu/src/error.rs
  type Result (line 3) | pub type Result<T> = std::result::Result<T, SeamDanmuError>;
  type SeamDanmuError (line 6) | pub enum SeamDanmuError {

FILE: crates/danmu/src/lib.rs
  type DanmuTrait (line 29) | pub trait DanmuTrait {
    method start (line 38) | async fn start(rid: &str, recorder: Vec<&dyn DanmuRecorder>) -> Result...
  type DanmuRecorder (line 50) | pub trait DanmuRecorder: Send + Sync {
    method try_new (line 51) | fn try_new(path: Option<PathBuf>) -> Result<Self>
    method path (line 55) | fn path(&self) -> Option<&PathBuf>;
    method init (line 57) | fn init(&self) -> Result<()> {
    method formatter (line 65) | fn formatter(&self, danmu: &DanmuBody) -> String {
    method record (line 74) | fn record(&self, danmu: &DanmuBody) -> Result<()> {
    method try_new (line 91) | fn try_new(path: Option<PathBuf>) -> Result<Self> {
    method path (line 99) | fn path(&self) -> Option<&PathBuf> {
    method init (line 106) | fn init(&self) -> Result<()> {
    method formatter (line 114) | fn formatter(&self, danmu: &DanmuBody) -> String {
    method try_new (line 122) | fn try_new(_path: Option<PathBuf>) -> Result<Self> {
    method path (line 126) | fn path(&self) -> Option<&PathBuf> {
    method init (line 130) | fn init(&self) -> Result<()> {
    method record (line 135) | fn record(&self, danmu: &DanmuBody) -> Result<()> {
  type Csv (line 86) | pub struct Csv {
  type Terminal (line 119) | pub struct Terminal;
  type DanmuBody (line 143) | pub struct DanmuBody {
    method new (line 149) | pub fn new(user: String, content: String) -> Self {
  function websocket_danmu_work_flow (line 187) | pub async fn websocket_danmu_work_flow<B>(
  function closed_room_checker (line 222) | async fn closed_room_checker<B>(is_closed_room: impl Fn() -> B)
  function heart_beat (line 239) | async fn heart_beat(
  function fetch_danmu (line 264) | async fn fetch_danmu(

FILE: crates/gui/src-tauri/build.rs
  function main (line 1) | fn main() {

FILE: crates/gui/src-tauri/src/command/live.rs
  function url (line 6) | pub async fn url(live: String, rid: String) -> Resp<Node> {
  function play (line 22) | pub async fn play(url: String) -> Resp<bool> {

FILE: crates/gui/src-tauri/src/command/refresh.rs
  function refresh_all (line 6) | pub async fn refresh_all(app: AppHandle) -> Resp<()> {
  function refresh_one (line 11) | pub async fn refresh_one(app: AppHandle, live: String, rid: String) -> R...

FILE: crates/gui/src-tauri/src/command/subscribe.rs
  function subscribe_all (line 4) | pub async fn subscribe_all() -> Resp<Vec<database::subscribe::Model>> {
  function subscribe_add (line 9) | pub async fn subscribe_add(live: String, rid: String) -> Resp<bool> {
  function subscribe_remove (line 14) | pub async fn subscribe_remove(live: String, rid: String) -> Resp<bool> {

FILE: crates/gui/src-tauri/src/common.rs
  type Context (line 27) | pub struct Context {
  function load (line 32) | pub async fn load() -> Context {
  function test (line 64) | async fn test() {

FILE: crates/gui/src-tauri/src/config.rs
  type ConfigOption (line 9) | pub struct ConfigOption {
  type Config (line 15) | pub struct Config {
  type PlayOption (line 21) | pub struct PlayOption {
  type Play (line 27) | pub struct Play {
  function headers (line 57) | pub fn headers(live: &str) -> HashMap<String, String> {
  function test_config (line 73) | fn test_config() {

FILE: crates/gui/src-tauri/src/database/mod.rs
  function init (line 7) | pub async fn init(db: &DatabaseConnection) -> Result<ExecResult, DbErr> {

FILE: crates/gui/src-tauri/src/database/subscribe.rs
  type Model (line 7) | pub struct Model {
  type Relation (line 15) | pub enum Relation {}

FILE: crates/gui/src-tauri/src/main.rs
  function main (line 22) | async fn main() {

FILE: crates/gui/src-tauri/src/manager/refresh.rs
  type ReFreshMessage (line 12) | pub struct ReFreshMessage {
  function one (line 18) | pub async fn one(app: &AppHandle, live: String, rid: String) -> Result<(...
  function all (line 31) | pub async fn all(app: &AppHandle) -> Result<()> {

FILE: crates/gui/src-tauri/src/resp.rs
  type Resp (line 5) | pub struct Resp<T> {
  function from (line 15) | fn from(item: Result<T>) -> Self {
  function success (line 27) | pub fn success(data: T) -> Self {
  function fail (line 35) | pub fn fail(code: i64, e: &str) -> Self {

FILE: crates/gui/src-tauri/src/service/subscribe.rs
  function all (line 9) | pub async fn all() -> Result<Vec<subscribe::Model>> {
  function add (line 16) | pub async fn add(live: String, rid: String) -> Result<bool> {
  function remove (line 29) | pub async fn remove(live: String, rid: String) -> Result<bool> {

FILE: crates/gui/src-tauri/src/setup.rs
  function handler (line 7) | pub fn handler(app: &mut App) -> Result<(), Box<dyn Error>> {

FILE: crates/gui/src-tauri/src/util.rs
  constant SEPARATOR (line 7) | const SEPARATOR: &str = "\\";
  constant SEPARATOR (line 10) | const SEPARATOR: &str = "/";
  function bin_dir (line 12) | pub fn bin_dir() -> String {
  function play (line 23) | pub fn play(url: &str) -> Result<bool> {
  function test_play (line 42) | async fn test_play() {

FILE: crates/gui/src/components/Control.tsx
  type BarProps (line 43) | interface BarProps {

FILE: crates/gui/src/components/GoodItem.tsx
  type Url (line 5) | interface Url {
  type GoodItemProps (line 10) | interface GoodItemProps {

FILE: crates/gui/src/components/Live.tsx
  type Url (line 5) | interface Url {
  type LiveProps (line 10) | interface LiveProps {

FILE: crates/gui/src/components/Panel.tsx
  type LiveProps (line 7) | interface LiveProps {

FILE: crates/gui/src/icon/icon.tsx
  type IconProps (line 1) | interface IconProps {

FILE: crates/gui/src/model/Live.tsx
  type LiveItem (line 1) | interface LiveItem {

FILE: crates/gui/src/model/Record.tsx
  type SubscribeRecord (line 1) | interface SubscribeRecord {

FILE: crates/gui/src/model/Resp.tsx
  type Resp (line 1) | interface Resp<T> {

FILE: crates/gui/src/pages/Chart.tsx
  type Record (line 10) | interface Record {
  type Subscribe (line 17) | interface Subscribe {

FILE: crates/marcos/src/lib.rs
  function gen_all (line 17) | pub fn gen_all(_: TokenStream) -> TokenStream {
  function gen_test (line 63) | pub fn gen_test(input: TokenStream) -> TokenStream {

FILE: crates/status/src/common.rs
  constant USER_AGENT (line 5) | pub const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...

FILE: crates/status/src/error.rs
  type Result (line 3) | pub type Result<T> = std::result::Result<T, SeamStatusError>;
  type SeamStatusError (line 6) | pub enum SeamStatusError {

FILE: crates/status/src/lib.rs
  type StatusTrait (line 14) | pub trait StatusTrait {
    method status (line 17) | async fn status(rid: &str) -> Result<bool>;

FILE: crates/status/src/status/bili.rs
  type Status (line 4) | pub struct Status;
  constant URL (line 6) | const URL: &str = "https://api.live.bilibili.com/room/v1/Room/room_init";
  method status (line 10) | async fn status(rid: &str) -> Result<bool> {
  function test_status (line 28) | async fn test_status() {

FILE: crates/status/src/status/cc.rs
  type Status (line 4) | pub struct Status;
  constant URL (line 6) | const URL: &str = "https://cc.163.com/";
  method status (line 10) | async fn status(rid: &str) -> Result<bool> {
  function test_status (line 28) | async fn test_status() {

FILE: crates/status/src/status/douyin.rs
  type Status (line 11) | pub struct Status;
  constant URL (line 13) | const URL: &str = "https://live.douyin.com/";
  method status (line 17) | async fn status(rid: &str) -> Result<bool> {
  function test_status (line 49) | async fn test_status() {
Condensed preview — 142 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (231K chars).
[
  {
    "path": ".github/dependabot.yml",
    "chars": 193,
    "preview": "version: 2\nupdates:\n  - package-ecosystem: github-actions\n    directory: /\n    schedule:\n      interval: daily\n  - packa"
  },
  {
    "path": ".github/workflows/release_cli.yml",
    "chars": 5074,
    "preview": "name: Build CLI Releases\n\npermissions:\n  contents: write\n\non:\n  push:\n    tags:\n      - v_cli.*\n\nenv:\n  CARGO_TERM_COLOR"
  },
  {
    "path": ".github/workflows/release_gui.yml",
    "chars": 2042,
    "preview": "name: Build GUI Release\n\npermissions:\n  contents: write\n\non:\n  push:\n    tags:\n      - v_gui.*\n\njobs:\n  create_release:\n"
  },
  {
    "path": ".github/workflows/rust-clippy.yml",
    "chars": 1744,
    "preview": "# This workflow uses actions that are not certified by GitHub.\n# They are provided by a third-party and are governed by\n"
  },
  {
    "path": ".gitignore",
    "chars": 19,
    "preview": "/target\n/.idea\n/bak"
  },
  {
    "path": "Cargo.toml",
    "chars": 266,
    "preview": "[workspace]\nmembers = [\n    \"crates/cli\",\n    \"crates/gui/src-tauri\",\n    \"crates/core\",\n    \"crates/danmu\",\n    \"crates"
  },
  {
    "path": "LICENSE-MIT",
    "chars": 1063,
    "preview": "MIT License\n\nCopyright (c) 2023 Borber\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof "
  },
  {
    "path": "LICENSE-UNLICENSE",
    "chars": 1210,
    "preview": "This is free and unencumbered software released into the public domain.\n\nAnyone is free to copy, modify, publish, use, c"
  },
  {
    "path": "README.md",
    "chars": 6219,
    "preview": "<p align=\"center\">\n    <img src=\"./assets/icon.png\" style=\"width: 150px;\" alt=\"Seam\" />\n</p>\n\n<h2 align=\"center\">\n  Seam"
  },
  {
    "path": "TODO.md",
    "chars": 37,
    "preview": "-   给 Client 添加函数返回其函数调用信息, 命令, 平台名称\n"
  },
  {
    "path": "build/build-host-release",
    "chars": 2836,
    "preview": "#!/bin/bash\n\nBUILD_TARGET=\"\"\nBUILD_FEATURES=()\nwhile getopts \"t:f:\" opt; do\n    case $opt in\n    t)\n        BUILD_TARGET"
  },
  {
    "path": "build/build-host-release.ps1",
    "chars": 1852,
    "preview": "#!pwsh\n<#\n    OpenSSL is already installed on windows-latest virtual environment.\n    If you need OpenSSL, consider inst"
  },
  {
    "path": "build/build-release",
    "chars": 2855,
    "preview": "#!/bin/bash\n# Path: build\\release\n\nCUR_DIR=$(cd $(dirname $0) && pwd)\nVERSION=$(grep -E '^version' ${CUR_DIR}/../crates/"
  },
  {
    "path": "build/build-release-zigbuild",
    "chars": 2837,
    "preview": "#!/bin/bash\n\nCUR_DIR=$(cd $(dirname $0) && pwd)\nVERSION=$(grep -E '^version' ${CUR_DIR}/../crates/cli/Cargo.toml | awk '"
  },
  {
    "path": "config.toml",
    "chars": 752,
    "preview": "# 播放器路径或命令\n# 请自行安装播放器, 请确认它可以通过命令行+链接打开视频文件\n[play]\n# potplayer 样例\n# bin = \"C:\\\\Program Files (x86)\\\\Pure Codec\\\\x64\\\\Pot"
  },
  {
    "path": "crates/cli/CHANGELOG.md",
    "chars": 3044,
    "preview": "# Changelog\n\n## [0.1.39]\n\n修复 bili 无法获取原画 [#277](https://github.com/Borber/seam/issues/277)\n\n## [0.1.38]\n\n修复 斗鱼部分房间获取不到 ["
  },
  {
    "path": "crates/cli/Cargo.toml",
    "chars": 554,
    "preview": "[package]\nname = \"seam\"\nauthors = [\"Borber\"]\nversion = \"0.1.39\"\nedition = \"2021\"\n\n# See more keys and their definitions "
  },
  {
    "path": "crates/cli/README.md",
    "chars": 404,
    "preview": "```\n _______ _______ _______ _______\n|     __|    ___|   _   |   |   |\n|__     |    ___|       |       |\n|_______|______"
  },
  {
    "path": "crates/cli/src/common.rs",
    "chars": 489,
    "preview": "use std::{collections::HashMap, sync::Arc};\n\nuse once_cell::sync::Lazy;\nuse seam_core::live::{self, Live};\n\npub static G"
  },
  {
    "path": "crates/cli/src/config.rs",
    "chars": 1854,
    "preview": "use std::collections::HashMap;\n\nuse once_cell::sync::Lazy;\nuse serde::Deserialize;\n\nuse crate::util::bin_dir;\n\n#[derive("
  },
  {
    "path": "crates/cli/src/main.rs",
    "chars": 3402,
    "preview": "mod common;\nmod config;\nmod util;\n\nuse crate::common::GLOBAL_CLIENT;\nuse anyhow::{anyhow, Result};\nuse clap::Parser;\nuse"
  },
  {
    "path": "crates/cli/src/util.rs",
    "chars": 306,
    "preview": "#[cfg(windows)]\nconst SEPARATOR: &str = \"\\\\\";\n\n#[cfg(not(windows))]\nconst SEPARATOR: &str = \"/\";\n\npub fn bin_dir() -> St"
  },
  {
    "path": "crates/core/Cargo.toml",
    "chars": 631,
    "preview": "[package]\nname = \"seam_core\"\nversion = \"0.1.20\"\nedition = \"2021\"\n\n[dependencies]\nmacros = { package = \"seam_marcos\", pat"
  },
  {
    "path": "crates/core/src/common.rs",
    "chars": 289,
    "preview": "use once_cell::sync::Lazy;\nuse reqwest::Client;\n\n// TODO 这玩意也应该额外传入\npub const USER_AGENT: &str = \"Mozilla/5.0 (Windows N"
  },
  {
    "path": "crates/core/src/error.rs",
    "chars": 1039,
    "preview": "use thiserror::Error;\n\npub type Result<T> = std::result::Result<T, SeamError>;\n\n// 需要添加\n#[derive(Error, Debug)]\npub enum"
  },
  {
    "path": "crates/core/src/lib.rs",
    "chars": 59,
    "preview": "pub mod common;\npub mod error;\npub mod live;\npub mod util;\n"
  },
  {
    "path": "crates/core/src/live/afreeca.rs",
    "chars": 2439,
    "preview": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse regex::Regex;\n\nuse crate::{\n    common::CLIENT,\n    er"
  },
  {
    "path": "crates/core/src/live/bili.rs",
    "chars": 4891,
    "preview": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse reqwest::header::HeaderValue;\nuse serde_json::Value;\n\n"
  },
  {
    "path": "crates/core/src/live/cc.rs",
    "chars": 2663,
    "preview": "use std::collections::HashMap;\n\nuse super::{Live, Node};\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError}"
  },
  {
    "path": "crates/core/src/live/douyin.rs",
    "chars": 2790,
    "preview": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse reqwest::header::HeaderValue;\nuse serde_json::Value;\n\n"
  },
  {
    "path": "crates/core/src/live/douyu.rs",
    "chars": 6859,
    "preview": "use std::collections::{HashMap, HashSet};\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{e"
  },
  {
    "path": "crates/core/src/live/flex.rs",
    "chars": 1509,
    "preview": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, Seam"
  },
  {
    "path": "crates/core/src/live/huajiao.rs",
    "chars": 2011,
    "preview": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse regex::Regex;\n\nuse crate::{\n    common::CLIENT,\n    er"
  },
  {
    "path": "crates/core/src/live/huya.rs",
    "chars": 6684,
    "preview": "use std::collections::HashMap;\nuse std::time::{SystemTime, UNIX_EPOCH};\n\nuse async_trait::async_trait;\nuse base64::{engi"
  },
  {
    "path": "crates/core/src/live/inke.rs",
    "chars": 1803,
    "preview": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, Seam"
  },
  {
    "path": "crates/core/src/live/kk.rs",
    "chars": 3375,
    "preview": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse regex::Regex;\n\nuse crate::{\n    common::CLIENT,\n    er"
  },
  {
    "path": "crates/core/src/live/ks.rs",
    "chars": 2640,
    "preview": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse regex::Regex;\n\nuse crate::{\n    common::CLIENT,\n    er"
  },
  {
    "path": "crates/core/src/live/mht.rs",
    "chars": 1976,
    "preview": "use std::collections::HashMap;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    util::{hash2header,"
  },
  {
    "path": "crates/core/src/live/mod.rs",
    "chars": 2462,
    "preview": "//! 直播相关模块。\n//!\n//! 本模块提供了标准化的直播获取方式和直播状态检测的async trait 以及\n//! 标准化的直播源信息和直播状态enum\n\nuse async_trait::async_trait;\nuse mac"
  },
  {
    "path": "crates/core/src/live/now.rs",
    "chars": 2368,
    "preview": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, Seam"
  },
  {
    "path": "crates/core/src/live/panda.rs",
    "chars": 1939,
    "preview": "use std::collections::HashMap;\n\nconst URL: &str = \"https://api.pandalive.co.kr/v1/live/play/\";\n\nuse async_trait::async_t"
  },
  {
    "path": "crates/core/src/live/qf.rs",
    "chars": 1677,
    "preview": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse regex::Regex;\n\nuse crate::{\n    common::CLIENT,\n    er"
  },
  {
    "path": "crates/core/src/live/twitch.rs",
    "chars": 6512,
    "preview": "use std::collections::HashMap;\n\nuse async_trait::async_trait;\nuse reqwest::header::HeaderMap;\nuse serde_json::Value;\n\nus"
  },
  {
    "path": "crates/core/src/live/wink.rs",
    "chars": 1927,
    "preview": "use std::collections::HashMap;\n\nconst URL: &str = \"https://api.winktv.co.kr/v1/live/play\";\n\nuse async_trait::async_trait"
  },
  {
    "path": "crates/core/src/live/yqs.rs",
    "chars": 2358,
    "preview": "use async_trait::async_trait;\nuse regex::Regex;\n\nuse crate::{\n    common::CLIENT,\n    error::{Result, SeamError},\n    ut"
  },
  {
    "path": "crates/core/src/util.rs",
    "chars": 1643,
    "preview": "use boa_engine::Context;\nuse boa_engine::Source;\nuse reqwest::header::HeaderMap;\nuse reqwest::header::HeaderName;\nuse re"
  },
  {
    "path": "crates/danmu/Cargo.toml",
    "chars": 566,
    "preview": "[package]\nname = \"seam_danmu\"\nversion = \"0.1.1\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.r"
  },
  {
    "path": "crates/danmu/src/danmu/afreeca.rs",
    "chars": 92,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Afreeca);\n"
  },
  {
    "path": "crates/danmu/src/danmu/bili.rs",
    "chars": 4718,
    "preview": "use async_trait::async_trait;\nuse miniz_oxide::inflate::decompress_to_vec_zlib;\nuse rand::Rng;\nuse seam_status::status::"
  },
  {
    "path": "crates/danmu/src/danmu/cc.rs",
    "chars": 87,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Cc);\n"
  },
  {
    "path": "crates/danmu/src/danmu/douyin.rs",
    "chars": 90,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Douyu);\n"
  },
  {
    "path": "crates/danmu/src/danmu/douyu.rs",
    "chars": 91,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Douyin);\n"
  },
  {
    "path": "crates/danmu/src/danmu/flex.rs",
    "chars": 89,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Flex);\n"
  },
  {
    "path": "crates/danmu/src/danmu/huajiao.rs",
    "chars": 92,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Huajiao);\n"
  },
  {
    "path": "crates/danmu/src/danmu/huya.rs",
    "chars": 89,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Huya);\n"
  },
  {
    "path": "crates/danmu/src/danmu/inke.rs",
    "chars": 89,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Inke);\n"
  },
  {
    "path": "crates/danmu/src/danmu/kk.rs",
    "chars": 87,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Kk);\n"
  },
  {
    "path": "crates/danmu/src/danmu/ks.rs",
    "chars": 87,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Ks);\n"
  },
  {
    "path": "crates/danmu/src/danmu/mht.rs",
    "chars": 88,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Mht);\n"
  },
  {
    "path": "crates/danmu/src/danmu/mod.rs",
    "chars": 237,
    "preview": "pub mod afreeca;\npub mod bili;\npub mod cc;\npub mod douyin;\npub mod douyu;\npub mod flex;\npub mod huajiao;\npub mod huya;\np"
  },
  {
    "path": "crates/danmu/src/danmu/now.rs",
    "chars": 88,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Now);\n"
  },
  {
    "path": "crates/danmu/src/danmu/panda.rs",
    "chars": 90,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Panda);\n"
  },
  {
    "path": "crates/danmu/src/danmu/qf.rs",
    "chars": 87,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Qf);\n"
  },
  {
    "path": "crates/danmu/src/danmu/wink.rs",
    "chars": 89,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Wink);\n"
  },
  {
    "path": "crates/danmu/src/danmu/yqs.rs",
    "chars": 88,
    "preview": "use crate::default_danmu_client;\nuse crate::error::Result;\n\ndefault_danmu_client!(Yqs);\n"
  },
  {
    "path": "crates/danmu/src/error.rs",
    "chars": 402,
    "preview": "use thiserror::Error;\n\npub type Result<T> = std::result::Result<T, SeamDanmuError>;\n\n#[derive(Error, Debug)]\npub enum Se"
  },
  {
    "path": "crates/danmu/src/lib.rs",
    "chars": 8018,
    "preview": "//! 弹幕相关模块。\n//!\n//! 本模块提供了标准化的弹幕记录的async trait 以及\n//! 标准化的弹幕记录方式enum。\n//!\n//! 本模块提供了基于websocket的标准弹幕工作流。\n//! 如无定制需求,可以直接"
  },
  {
    "path": "crates/gui/.eslintrc.json",
    "chars": 628,
    "preview": "{\n    \"env\": {\n        \"browser\": true,\n        \"es2021\": true\n    },\n    \"parser\": \"@typescript-eslint/parser\",\n    \"pl"
  },
  {
    "path": "crates/gui/.gitignore",
    "chars": 22,
    "preview": "node_modules\ndist\ndata"
  },
  {
    "path": "crates/gui/.vscode/extensions.json",
    "chars": 80,
    "preview": "{\n  \"recommendations\": [\"tauri-apps.tauri-vscode\", \"rust-lang.rust-analyzer\"]\n}\n"
  },
  {
    "path": "crates/gui/CHANGELOG.md",
    "chars": 554,
    "preview": "# Changelog\n\n## [0.1.8]\n\n**请重新获取最新的抖音 cookie**\n\n### 修复\n\n-   修复 douyin 直播源获取\n\n### 更新\n\n-   douyu 支持 直播间封面, 主播名,主播头像获取\n\n## "
  },
  {
    "path": "crates/gui/README.md",
    "chars": 528,
    "preview": "<p align=\"center\">\n    <img src=\"../../assets/icon.png\" style=\"width: 150px;\" alt=\"Seam\" />\n</p>\n\n<h2 align=\"center\">\n  "
  },
  {
    "path": "crates/gui/index.html",
    "chars": 505,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"utf-8\" />\n        <meta name=\"viewport\" content=\"widt"
  },
  {
    "path": "crates/gui/package.json",
    "chars": 961,
    "preview": "{\n    \"name\": \"seam\",\n    \"version\": \"0.1.4\",\n    \"description\": \"\",\n    \"scripts\": {\n        \"start\": \"vite\",\n        \""
  },
  {
    "path": "crates/gui/src/App.css",
    "chars": 807,
    "preview": ".not-draggable {\n  user-select: none;\n}\n\n.container {\n  width: 100%;\n  height: calc(100% - 40px);\n  display: flex;\n  fle"
  },
  {
    "path": "crates/gui/src/App.tsx",
    "chars": 1854,
    "preview": "import \"./App.css\"\nimport \"./css/TopBar.css\"\n\nimport { useRoutes } from \"@solidjs/router\"\nimport { lazy, onMount } from "
  },
  {
    "path": "crates/gui/src/components/Control.tsx",
    "chars": 2274,
    "preview": "import \"../css/Control.css\"\n\nimport { appWindow } from \"@tauri-apps/api/window\"\nimport { Show } from \"solid-js\"\n\n// TODO"
  },
  {
    "path": "crates/gui/src/components/GoodItem.tsx",
    "chars": 1126,
    "preview": "import \"../css/GoodItem.css\"\n\nimport { AddIcon, CopyIcon, PlayIcon } from \"../icon/icon\"\n\ninterface Url {\n    format: st"
  },
  {
    "path": "crates/gui/src/components/Live.tsx",
    "chars": 944,
    "preview": "import \"../css/Live.css\"\n\nimport { CopyIcon, PlayIcon } from \"../icon/icon\"\n\ninterface Url {\n    format: string;\n    url"
  },
  {
    "path": "crates/gui/src/components/Panel.tsx",
    "chars": 1061,
    "preview": "import \"../css/Panel.css\"\n\nimport { Accessor, For, Setter } from \"solid-js\"\n\nimport allLives from \"../model/Live\"\n\ninter"
  },
  {
    "path": "crates/gui/src/components/SideBar.tsx",
    "chars": 940,
    "preview": "import \"../css/SideBar.css\"\n\nimport { useLocation } from \"@solidjs/router\"\nimport { createMemo } from \"solid-js\"\n\nimport"
  },
  {
    "path": "crates/gui/src/components/SideItem.tsx",
    "chars": 627,
    "preview": "import \"../css/SideItem.css\"\n\nimport { A } from \"@solidjs/router\"\nimport { JSX } from \"solid-js/jsx-runtime\"\n\nconst Side"
  },
  {
    "path": "crates/gui/src/components/TopBar.tsx",
    "chars": 2457,
    "preview": "import { invoke } from \"@tauri-apps/api\"\nimport { createSignal } from \"solid-js\"\nimport { Spinner, SpinnerType } from \"s"
  },
  {
    "path": "crates/gui/src/css/Chart.css",
    "chars": 2086,
    "preview": ".chart {\n    width: 60%;\n    height: 100%;\n    margin: 0 auto;\n    color: #fff;\n    padding: 20px;\n    display: flex;\n  "
  },
  {
    "path": "crates/gui/src/css/Control.css",
    "chars": 481,
    "preview": ".control {\n    position: absolute;\n    right: 0;\n    display: flex;\n    text-align: center;\n    justify-content: center;"
  },
  {
    "path": "crates/gui/src/css/Good.css",
    "chars": 66,
    "preview": ".good {\n    color: #fff;\n    display: flex;\n    flex-wrap: wrap;\n}"
  },
  {
    "path": "crates/gui/src/css/GoodItem.css",
    "chars": 772,
    "preview": ".good-item {\n    width: 460px;\n    height: 300px;\n    position: relative;\n    background-color: aliceblue;\n}\n\n.good-img "
  },
  {
    "path": "crates/gui/src/css/Home.css",
    "chars": 66,
    "preview": ".home {\n    color: #fff;\n    display: flex;\n    flex-wrap: wrap;\n}"
  },
  {
    "path": "crates/gui/src/css/Live.css",
    "chars": 601,
    "preview": ".live {\n    width: 230px;\n    height: 150px;\n    position: relative;\n}\n\n.live-img {\n    width: 100%;\n    height: 100%;\n "
  },
  {
    "path": "crates/gui/src/css/Panel.css",
    "chars": 867,
    "preview": ".panel {\n    width: 400px;\n    position: absolute;\n    z-index: 10;\n    top: 40px;\n    left: 300px;\n    background-color"
  },
  {
    "path": "crates/gui/src/css/Setting.css",
    "chars": 1254,
    "preview": ".setting {\n    width: 60%;\n    height: 100%;\n    margin: 0 auto;\n    color: #fff;\n    padding: 20px;\n    display: flex;\n"
  },
  {
    "path": "crates/gui/src/css/SideBar.css",
    "chars": 79,
    "preview": ".side-bar {\n    width: 80px;\n    height: 100%;\n    background-color: #1e1e1e;\n}"
  },
  {
    "path": "crates/gui/src/css/SideItem.css",
    "chars": 355,
    "preview": ".side-link {\n    cursor: default;\n}\n\n.side-item {\n    width: 80px;\n    height: 75px;\n    display: flex;\n    justify-cont"
  },
  {
    "path": "crates/gui/src/css/TopBar.css",
    "chars": 627,
    "preview": ".top-bar {\n    display: flex;\n    justify-content: center;\n    align-items: center;\n}\n\n.refresh {\n    height: 20px;\n    "
  },
  {
    "path": "crates/gui/src/icon/icon.tsx",
    "chars": 27394,
    "preview": "interface IconProps {\n    size: number\n}\n\nconst HomeIcon = (props: IconProps) => {\n    return (\n        <svg\n           "
  },
  {
    "path": "crates/gui/src/index.tsx",
    "chars": 285,
    "preview": "/* @refresh reload */\nimport \"./styles.css\"\n\nimport { Router } from \"@solidjs/router\"\nimport { render } from \"solid-js/w"
  },
  {
    "path": "crates/gui/src/model/Live.tsx",
    "chars": 1393,
    "preview": "export interface LiveItem {\n    name: string;\n    cmd: string;\n}\n\nconst allLives = (): LiveItem[] => {\n    const lives: "
  },
  {
    "path": "crates/gui/src/model/Record.tsx",
    "chars": 72,
    "preview": "export interface SubscribeRecord {\n    live: string;\n    rid: string;\n}\n"
  },
  {
    "path": "crates/gui/src/model/Resp.tsx",
    "chars": 77,
    "preview": "export interface Resp<T> {\n    code: number;\n    msg: string;\n    data: T;\n}\n"
  },
  {
    "path": "crates/gui/src/pages/Chart.tsx",
    "chars": 4192,
    "preview": "import \"../css/Chart.css\"\n\nimport { invoke } from \"@tauri-apps/api\"\nimport { createMemo, createSignal, For, onMount } fr"
  },
  {
    "path": "crates/gui/src/pages/Good.tsx",
    "chars": 537,
    "preview": "import \"../css/Good.css\"\n\nimport GoodItem from \"../components/GoodItem\"\n\nconst Good = () => {\n    const goodDemo = {\n   "
  },
  {
    "path": "crates/gui/src/pages/Home.tsx",
    "chars": 1034,
    "preview": "import \"../css/Home.css\"\n\nimport Live from \"../components/Live\"\n\nconst Home = () => {\n    const liveDemo = {\n        liv"
  },
  {
    "path": "crates/gui/src/pages/Setting.tsx",
    "chars": 1522,
    "preview": "import \"../css/Setting.css\"\n\nimport { open } from \"@tauri-apps/api/dialog\"\nimport toast from \"solid-toast\"\n\n// TODO head"
  },
  {
    "path": "crates/gui/src/styles.css",
    "chars": 1627,
    "preview": "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,"
  },
  {
    "path": "crates/gui/src-tauri/.gitignore",
    "chars": 74,
    "preview": "# Generated by Cargo\n# will have compiled files and executables\n/target/\n\n"
  },
  {
    "path": "crates/gui/src-tauri/Cargo.toml",
    "chars": 1006,
    "preview": "[package]\nname = \"seam_gui\"\nversion = \"0.1.8\"\ndescription = \"seam\"\nauthors = [\"Borber\"]\nlicense = \"\"\nrepository = \"\"\nedi"
  },
  {
    "path": "crates/gui/src-tauri/build.rs",
    "chars": 39,
    "preview": "fn main() {\n    tauri_build::build()\n}\n"
  },
  {
    "path": "crates/gui/src-tauri/src/command/live.rs",
    "chars": 709,
    "preview": "use seam_core::{error::SeamError, live::Node};\n\nuse crate::{clients, config::headers, resp::Resp, util};\n\n#[tauri::comma"
  },
  {
    "path": "crates/gui/src-tauri/src/command/mod.rs",
    "chars": 50,
    "preview": "pub mod live;\npub mod refresh;\npub mod subscribe;\n"
  },
  {
    "path": "crates/gui/src-tauri/src/command/refresh.rs",
    "chars": 337,
    "preview": "use tauri::AppHandle;\n\nuse crate::{manager, resp::Resp};\n\n#[tauri::command]\npub async fn refresh_all(app: AppHandle) -> "
  },
  {
    "path": "crates/gui/src-tauri/src/command/subscribe.rs",
    "chars": 472,
    "preview": "use crate::{database, resp::Resp, service};\n\n#[tauri::command]\npub async fn subscribe_all() -> Resp<Vec<database::subscr"
  },
  {
    "path": "crates/gui/src-tauri/src/common.rs",
    "chars": 1689,
    "preview": "use std::{collections::HashMap, path::Path, sync::Arc};\n\nuse sea_orm::Database;\nuse sea_orm::DatabaseConnection;\nuse sea"
  },
  {
    "path": "crates/gui/src-tauri/src/config.rs",
    "chars": 1758,
    "preview": "use std::collections::HashMap;\n\nuse once_cell::sync::Lazy;\nuse serde::Deserialize;\n\nuse crate::util::bin_dir;\n\n#[derive("
  },
  {
    "path": "crates/gui/src-tauri/src/database/mod.rs",
    "chars": 330,
    "preview": "pub mod subscribe;\n\nuse anyhow::Result;\nuse sea_orm::*;\n\n/// 初始化数据库\npub async fn init(db: &DatabaseConnection) -> Result"
  },
  {
    "path": "crates/gui/src-tauri/src/database/subscribe.rs",
    "chars": 414,
    "preview": "use sea_orm::entity::prelude::*;\nuse serde::Serialize;\n\n/// 订阅记录\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel"
  },
  {
    "path": "crates/gui/src-tauri/src/main.rs",
    "chars": 828,
    "preview": "#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nuse command::{\n    live::{play, url},\n    refresh::{"
  },
  {
    "path": "crates/gui/src-tauri/src/manager/mod.rs",
    "chars": 17,
    "preview": "pub mod refresh;\n"
  },
  {
    "path": "crates/gui/src-tauri/src/manager/refresh.rs",
    "chars": 1650,
    "preview": "use crate::{clients, config::headers, service};\n\nuse std::collections::HashMap;\n\nuse anyhow::Result;\nuse seam_core::live"
  },
  {
    "path": "crates/gui/src-tauri/src/model.rs",
    "chars": 1,
    "preview": "\n"
  },
  {
    "path": "crates/gui/src-tauri/src/resp.rs",
    "chars": 781,
    "preview": "use anyhow::Result;\nuse serde::Serialize;\n\n#[derive(Debug, Serialize, Clone)]\npub struct Resp<T> {\n    pub code: i64,\n  "
  },
  {
    "path": "crates/gui/src-tauri/src/service/mod.rs",
    "chars": 19,
    "preview": "pub mod subscribe;\n"
  },
  {
    "path": "crates/gui/src-tauri/src/service/subscribe.rs",
    "chars": 766,
    "preview": "use anyhow::Result;\nuse sea_orm::*;\n\nuse crate::{database::subscribe, pool};\n\n/// 获取所有订阅\n///\n/// Get all subscribe\npub a"
  },
  {
    "path": "crates/gui/src-tauri/src/setup.rs",
    "chars": 386,
    "preview": "use std::error::Error;\n\nuse tauri::{App, Manager};\nuse window_shadows::set_shadow;\n\n/// Tauri 启动\npub fn handler(app: &mu"
  },
  {
    "path": "crates/gui/src-tauri/src/util.rs",
    "chars": 1442,
    "preview": "use anyhow::Result;\nuse std::process::Command;\n\nuse crate::config::CONFIG;\n\n#[cfg(windows)]\nconst SEPARATOR: &str = \"\\\\\""
  },
  {
    "path": "crates/gui/src-tauri/tauri.conf.json",
    "chars": 1640,
    "preview": "{\n    \"build\": {\n        \"beforeDevCommand\": \"yarn dev\",\n        \"beforeBuildCommand\": \"yarn build\",\n        \"devPath\": "
  },
  {
    "path": "crates/gui/tsconfig.json",
    "chars": 338,
    "preview": "{\n  \"compilerOptions\": {\n    \"strict\": true,\n    \"target\": \"ESNext\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"No"
  },
  {
    "path": "crates/gui/vite.config.ts",
    "chars": 1344,
    "preview": "import { defineConfig, type UserConfigExport } from \"vite\";\nimport solidPlugin from \"vite-plugin-solid\";\n\n// https://vit"
  },
  {
    "path": "crates/marcos/Cargo.toml",
    "chars": 292,
    "preview": "[package]\nname = \"seam_marcos\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc."
  },
  {
    "path": "crates/marcos/src/lib.rs",
    "chars": 2209,
    "preview": "use std::path::Path;\n\nuse proc_macro::TokenStream;\nuse quote::quote;\n\n/// 返回 `fn all() -> HashMap<String, Box<dyn Live>>"
  },
  {
    "path": "crates/status/Cargo.toml",
    "chars": 470,
    "preview": "[package]\nname = \"seam_status\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc."
  },
  {
    "path": "crates/status/src/common.rs",
    "chars": 290,
    "preview": "use once_cell::sync::Lazy;\nuse reqwest::Client;\n\n#[allow(dead_code)]\npub const USER_AGENT: &str = \"Mozilla/5.0 (Windows "
  },
  {
    "path": "crates/status/src/error.rs",
    "chars": 779,
    "preview": "use thiserror::Error;\n\npub type Result<T> = std::result::Result<T, SeamStatusError>;\n\n#[derive(Error, Debug)]\npub enum S"
  },
  {
    "path": "crates/status/src/lib.rs",
    "chars": 271,
    "preview": "//! 直播状态检测相关模块。\n//!\n//! 本模块提供了标准化的直播状态检测的 async trait\n\nuse async_trait::async_trait;\n\nuse error::Result;\n\nmod common;\npu"
  },
  {
    "path": "crates/status/src/status/bili.rs",
    "chars": 789,
    "preview": "use crate::{common::CLIENT, error::Result, StatusTrait};\nuse async_trait::async_trait;\n\npub struct Status;\n\nconst URL: &"
  },
  {
    "path": "crates/status/src/status/cc.rs",
    "chars": 713,
    "preview": "use crate::{common::CLIENT, error::Result, StatusTrait};\nuse async_trait::async_trait;\n\npub struct Status;\n\nconst URL: &"
  },
  {
    "path": "crates/status/src/status/douyin.rs",
    "chars": 1578,
    "preview": "use crate::{\n    common::{CLIENT, USER_AGENT},\n    error::Result,\n    StatusTrait,\n};\nuse async_trait::async_trait;\nuse "
  },
  {
    "path": "crates/status/src/status/mod.rs",
    "chars": 42,
    "preview": "pub mod bili;\npub mod cc;\npub mod douyin;\n"
  },
  {
    "path": "doc/配置说明.md",
    "chars": 158,
    "preview": "## 获取 cookie\n\n1. 打开直播网页, 登录\n2. 按 `F12` , 或 `Shift + Ctrl + i` 或 菜单打开`开发者工具`\n3. 顶部标签切换到`网络`或`network`\n4. 刷新网页\n5. 找到和你地址栏一"
  },
  {
    "path": "doc/额外安装.md",
    "chars": 15,
    "preview": "## 播放\n\n### mpv\n"
  },
  {
    "path": "justfile",
    "chars": 349,
    "preview": "[private]\ndefault:\n    @just --list\n\n# 编译 CLI\ncb:\n    cargo build --package seam -r\n\n# 编译 GUI\ngb:\n    cd ./crates/gui; \\"
  },
  {
    "path": "script/gui_version.lua",
    "chars": 1242,
    "preview": "Root = io.popen(\"git rev-parse --show-toplevel\"):read(\"*l\")\nFile = Root .. \"/crates/gui/src-tauri/tauri.conf.json\"\nConte"
  }
]

// ... and 1 more files (download for full content)

About this extraction

This page contains the full source code of the Borber/SBtream GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 142 files (198.2 KB), approximately 66.8k tokens, and a symbol index with 208 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!