├── .env ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── scripts │ ├── publish.sh │ ├── publish_test.sh │ └── test.sh └── workflows │ ├── publish.yaml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── fav_bili ├── Cargo.toml ├── README.md ├── build.rs └── src │ ├── action │ ├── activate.rs │ ├── auth.rs │ ├── deactivate.rs │ ├── fetch.rs │ ├── like.rs │ ├── list.rs │ ├── mod.rs │ └── pull.rs │ ├── api.rs │ ├── command.rs │ ├── cookies.rs │ ├── db │ ├── account.rs │ ├── media.rs │ ├── media_set.rs │ ├── media_up.rs │ ├── mod.rs │ ├── set.rs │ ├── set_account.rs │ ├── up.rs │ └── up_account.rs │ ├── entity │ ├── entity_inner │ │ ├── account.rs │ │ ├── media.rs │ │ ├── media_set.rs │ │ ├── media_up.rs │ │ ├── mod.rs │ │ ├── prelude.rs │ │ ├── set.rs │ │ ├── set_account.rs │ │ ├── up.rs │ │ └── up_account.rs │ └── mod.rs │ ├── lib.rs │ ├── main.rs │ ├── migration │ ├── m20250527_000001_create_table.rs │ └── mod.rs │ ├── payload │ ├── auth.rs │ ├── buvid3.rs │ ├── dash.rs │ ├── like.rs │ ├── media.rs │ ├── mod.rs │ ├── set.rs │ ├── ticket.rs │ ├── up.rs │ └── wbi.rs │ ├── response │ ├── auth.rs │ ├── buvid3.rs │ ├── dash.rs │ ├── like.rs │ ├── media.rs │ ├── mod.rs │ ├── set.rs │ ├── ticket.rs │ ├── up.rs │ └── wbi.rs │ ├── state.rs │ ├── table.rs │ ├── version.rs │ └── wbi.rs ├── images ├── logo.png └── screenshot.png ├── migration ├── Cargo.toml ├── README.md └── src │ ├── lib.rs │ └── main.rs └── sea-orm.sh /.env: -------------------------------------------------------------------------------- 1 | # This is only for develop. 2 | # sea-orm needs this env var to generate entity. 3 | DATABASE_URL=sqlite://data.db?mode=rwc 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[Bug]" 5 | labels: 'unread' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior, or a Minimal Reproducible Code Snippet. 14 | 15 | **Expected behavior** 16 | A clear and concise description of what you expected to happen. 17 | 18 | **Screenshots** 19 | If applicable, add screenshots to help explain your problem. 20 | 21 | **Information (please complete the following information):** 22 | OS: [e.g. macOS] 23 | 24 | **Additional context** 25 | Add any other context about the problem here. 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[New Feature]" 5 | labels: 'unread' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | dev-dependencies: 9 | applies-to: version-updates 10 | patterns: 11 | - "*" 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "monthly" 17 | -------------------------------------------------------------------------------- /.github/scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | set -e 3 | 4 | export TERM=xterm-256color 5 | 6 | # Statements waiting to be executed 7 | statements=( 8 | "cargo publish -p $1" 9 | ) 10 | 11 | # loop echo and executing statements 12 | for statement in "${statements[@]}"; do 13 | echo "$(tput setaf 3)$statement$(tput sgr0)" 14 | eval $statement 15 | echo 16 | done 17 | -------------------------------------------------------------------------------- /.github/scripts/publish_test.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | set -e 3 | 4 | export TERM=xterm-256color 5 | 6 | # Statements waiting to be executed 7 | statements=( 8 | "cargo clippy --all-features --all-targets -p $1 -- -D warnings" 9 | "cargo test -p $1" 10 | "cargo doc --no-deps -p $1" 11 | "cargo publish -p $1 --dry-run" 12 | ) 13 | 14 | # loop echo and executing statements 15 | for statement in "${statements[@]}"; do 16 | echo "$(tput setaf 3)$statement$(tput sgr0)" 17 | eval $statement 18 | echo 19 | done 20 | -------------------------------------------------------------------------------- /.github/scripts/test.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | set -e 3 | 4 | export TERM=xterm-256color 5 | 6 | # Statements waiting to be executed 7 | statements=( 8 | "cargo fetch --locked" 9 | "cargo clippy --all-features --all-targets -- -D warnings" 10 | ) 11 | 12 | # loop echo and executing statements 13 | for statement in "${statements[@]}"; do 14 | echo "$(tput setaf 3)$statement$(tput sgr0)" 15 | eval $statement 16 | echo 17 | done 18 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | fav: 7 | description: "publish fav_bili" 8 | type: boolean 9 | default: false 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | 14 | jobs: 15 | publish: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: install cargo components 21 | run: rustup component add clippy 22 | 23 | - name: login to crates.io 24 | run: cargo login ${{ secrets.CRATESIO }} 25 | 26 | - name: publish fav_bili 27 | if: ${{ inputs.fav_bili }} 28 | run: .github/scripts/publish_test.sh fav_bili && .github/scripts/publish.sh fav_bili 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - v[0-9]+.* 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | 14 | jobs: 15 | create-release: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: taiki-e/create-gh-release-action@v1 20 | with: 21 | # (optional) Path to changelog. 22 | changelog: CHANGELOG.md 23 | # (required) GitHub token for creating GitHub Releases. 24 | token: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | upload-assets: 27 | needs: create-release 28 | strategy: 29 | matrix: 30 | include: 31 | - os: "macos-latest" # for Arm based macs (M1 and above). 32 | target: "aarch64-apple-darwin" 33 | - os: "macos-latest" # for Intel based macs. 34 | target: "x86_64-apple-darwin" 35 | - os: "ubuntu-22.04" 36 | target: x86_64-unknown-linux-gnu 37 | - os: "windows-latest" 38 | target: x86_64-pc-windows-msvc 39 | 40 | runs-on: ${{ matrix.os }} 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: taiki-e/upload-rust-binary-action@v1 44 | with: 45 | # (required) Comma-separated list of binary names (non-extension portion of filename) to build and upload. 46 | # Note that glob pattern is not supported yet. 47 | bin: fav_bili 48 | # (optional) Target triple, default is host triple. 49 | # This is optional but it is recommended that this always be set to 50 | # clarify which target you are building for if macOS is included in 51 | # the matrix because GitHub Actions changed the default architecture 52 | # of macos-latest since macos-14. 53 | target: ${{ matrix.target }} 54 | # (optional) Archive name (non-extension portion of filename) to be uploaded. 55 | # [default value: $bin-$target] 56 | # [possible values: the following variables and any string] 57 | # variables: 58 | # - $bin - Binary name (non-extension portion of filename). 59 | # - $target - Target triple. 60 | # - $tag - Tag of this release. 61 | # When multiple binary names are specified, default archive name or $bin variable cannot be used. 62 | archive: $bin-$tag-$target 63 | # (optional) On which platform to distribute the `.tar.gz` file. 64 | # [default value: unix] 65 | # [possible values: all, unix, windows, none] 66 | tar: unix 67 | # (optional) On which platform to distribute the `.zip` file. 68 | # [default value: windows] 69 | # [possible values: all, unix, windows, none] 70 | zip: windows 71 | # (required) GitHub token for uploading assets to GitHub Releases. 72 | token: ${{ secrets.GITHUB_TOKEN }} 73 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | branches: [dev] 6 | types: [opened, synchronize] 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Extract version 18 | id: extract-version 19 | run: echo "VERSION=$(grep -oP '^version = "\K[^"]+' Cargo.toml | awk '{$1=$1;print}')" >> $GITHUB_OUTPUT 20 | 21 | - name: Cache restore 22 | uses: actions/cache/restore@v4 23 | id: cache-cargo-restore 24 | with: 25 | path: | 26 | ~/.cargo/bin/ 27 | ~/.cargo/registry/index/ 28 | ~/.cargo/registry/cache/ 29 | ~/.cargo/git/db/ 30 | target/ 31 | key: ${{ runner.os }}-cargo-${{ steps.extract-version.outputs.VERSION }} 32 | 33 | - name: install cargo components 34 | if: steps.cache-cargo-restore.outputs.cache-hit != 'true' 35 | run: rustup component add clippy 36 | 37 | - name: Run build 38 | if: steps.cache-cargo-restore.outputs.cache-hit != 'true' 39 | run: cargo build --all-features --all-targets 40 | 41 | - name: Cache save 42 | if: steps.cache-cargo-restore.outputs.cache-hit != 'true' 43 | uses: actions/cache/save@v4 44 | id: cache-cargo-save 45 | with: 46 | path: | 47 | ~/.cargo/bin/ 48 | ~/.cargo/registry/index/ 49 | ~/.cargo/registry/cache/ 50 | ~/.cargo/git/db/ 51 | target/ 52 | key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }} 53 | 54 | - name: run tests 55 | run: .github/scripts/test.sh 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .DS_Store 3 | .fav 4 | temp.* 5 | *.db 6 | data.* 7 | *.mp4 8 | *.mp3 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | This project adheres to [Semantic Versioning](https://semver.org). 6 | 7 | 10 | 11 | ## [Unreleased] 12 | ## [1.0.8] - 2025-06-02 13 | 14 | - When fetching medias' details, only fetch those with active set or up. 15 | 16 | ## [1.0.7] - 2025-06-02 17 | 18 | - fix some bugs 19 | 20 | ## [1.0.6] - 2025-06-02 21 | 22 | - `fav like` will check `bili_ticket` cookies or generate one. 23 | 24 | ## [1.0.5] - 2025-06-01 25 | 26 | - `fav auth check` to check cookies usablity. 27 | 28 | ## [1.0.4] - 2025-06-01 29 | 30 | - fix missing alias `c` for `collecion` for `deactivate` sub command 31 | 32 | ## [1.0.3] - 2025-06-01 33 | 34 | - fix a few bugs in fetching 35 | 36 | ## [1.0.2] - 2025-06-01 37 | 38 | - Simplify code with paste macro 39 | 40 | ## [1.0.1] - 2025-06-01 {1.0.0} 41 | 42 | ⚠️ fav v0.* is archived [branch](https://github.com/kingwingfly/fav/tree/fav_v0) 43 | 44 | `fav v0.*`, based on `fav_core` `fav_utils` (which heavily depends on **protobuf** and many traits), 45 | is considered over-designed. 46 | 47 | As my being more familar with Rust, decision has been made to re-factor again this CRUD-oriented application. 48 | 49 | 🆕 update of fav v1.* 50 | 51 | - **sqlite & sea-orm**: to support more media attributes management 52 | - **more powerful**: support pull medias in fav collections and upper space 53 | - **dep:api_req**: my published api request helper crate 54 | - **migrating tool**: [WIP] migrator from `fav v0.*` to `fav v1.*` 55 | 56 | ## [1.0.0-alpha.*] - 2025-05-31 57 | 58 | See [1.0.0 CHANGELOG](#{1.0.0}) 59 | 60 | ## [0.2.39] - 2024-12-23 61 | 62 | - tokio runtime: instead of using threads as the number of cpus, now use the single-threaded runtime. 63 | 64 | ## [0.2.38] - 2024-12-23 65 | 66 | - rename `fav daemon` to `fav cron`. And `fav daemon` is still available as an alias. 67 | - bump deps 68 | 69 | ## [0.2.37] - 2024-11-26 70 | 71 | - bump deps 72 | 73 | ## [0.2.36] - 2024-11-12 74 | 75 | - fix: `fav daemon` uses data cached in memory instead of reading again, 76 | leading to repeated pullings with `fav pull` called manually during daemon. 77 | 78 | ## [0.2.35] - 2024-11-10 79 | 80 | - enhance: `fav -d /path` to set working directory. 81 | - enhance: `fav auth reuse` can receive path both containing or not containing `.fav`. 82 | - enhance: add a systemd service example in README. 83 | 84 | ## [0.2.34] - 2024-11-06 85 | 86 | - bump dependencies 87 | 88 | ## [0.2.33] - 2024-09-05 89 | 90 | - fav status will show name of resource creator. 91 | - enhance: Owner trait for resource. 92 | 93 | ## [0.2.32] - 2024-08-21 94 | 95 | - fix: `fav pull ` will now forcely pull the resource. 96 | 97 | ## [0.2.31] - 2024-08-18 98 | 99 | - improve: `pull` will check ffmpeg before running. 100 | 101 | ## [0.2.30] - 2024-06-18 102 | 103 | - fix: wrong hint of `fav status -h` 104 | - fix: status won't be saved after each epoch of fav daemon 105 | 106 | ## [0.2.29] - 2024-06-18 107 | 108 | - achives(合集) support. Use `fav status -a` to show the achives. 109 | 110 | ## [0.2.28] - 2024-06-07 111 | 112 | - improve: annotation of `fav_derive` 113 | - fix daemon: double SIGINT handlers when pulling 114 | 115 | ## [0.2.27] - 2024-06-07 116 | 117 | - improve follow [this](https://users.rust-lang.org/t/i-just-wrote-the-hardest-code-in-my-life-any-improvements/112596) 118 | 119 | ## [0.2.26] - 2024-06-07 120 | 121 | - yanked 122 | 123 | ## [0.2.25] - 2024-06-07 124 | 125 | - yanked 126 | 127 | ## [0.2.24] - 2024-06-06 128 | 129 | - fix: batch pull SIGINT hint missed 130 | 131 | ## [0.2.23] - 2024-06-06 132 | 133 | - core: refactored, more reliable 134 | - fix: SIGINT handle 135 | 136 | ## [0.2.22] - 2024-06-06 137 | 138 | - tracing: use stdout as tracing output while stderr as processbar 139 | 140 | ## [0.2.21] - 2024-06-05 141 | 142 | - improve progress bar 143 | - skip and contine if error happens while pulling chunks 144 | - terminate if network disconnected 145 | - improve error message if resource inaccessible 146 | - improve error message during daemon 147 | - core: fix a doc test which will create temp dir 148 | 149 | ## [0.2.20] - 2024-06-05 150 | 151 | - better documente for `fav_core` 152 | - fix bugs in batch ops 153 | - fix ctrl-c not handled: handle `SIGINT` in `fav_core`'s Ext methods 154 | - auth: auto fetch after login 155 | - (un)track: show hint after (un)track 156 | - pull: id not found hint improve 157 | - cli version: remove git timestamp 158 | - fix: `batch_pull` and `pull` handle SIGINT together, leading multi info log. 159 | - refactor all commands 160 | - fix: cache not clear if failed or cancelled 161 | 162 | ## [0.2.19] - 2024-06-05 163 | 164 | - yanked 165 | 166 | ## [0.2.18] - 2024-06-04 167 | 168 | - optimize: lto set to `fat` to reduce binary size. 169 | - init: add confirming if '.fav' already exists. 170 | 171 | ## [0.2.17] - 2024-05-17 172 | 173 | - improve: show more infos(timestamp) in `fav -V`. 174 | - fix: meaningless version message when building without a git tree. 175 | - optimize: speed up `fav fetch` 176 | 177 | ## [0.2.16] - 2024-05-10 178 | 179 | - improve: show expired status in `fav status bvid`. 180 | - improve: show more infos(git commit hash and rustc info) in `fav -V`. 181 | 182 | ## [0.2.15] - 2024-05-06 183 | 184 | - utils: remove duplicated status change. 185 | - fix: `fav fetch` will mark all resources as `expired` if no network. This command fetches sets before resources, so the bug only happens when network disconnected after fetching sets successfully, it's rare. 186 | 187 | ## [0.2.14] - 2024-04-25 188 | 189 | - core: FavCoreError will show a message. 190 | - pull: Sometimes, users may collect paid videos, but have no permission to watch full videos. This lead to a `SerdePointerError`. Fix: a message ask users to untrack the video has been added. 191 | - development: `fav_utils_old` will no longer be included into workspace. 192 | 193 | ## [0.2.13] - 2024-04-24 194 | 195 | - bump dependencies 196 | - core: improve log 197 | 198 | ## [0.2.12] - 2024-04-10 199 | 200 | - bump dependencies. 201 | - daemon: show error. 202 | - daemon: performance improvement. 203 | 204 | ## [0.2.11] - 2024-03-18 205 | 206 | - bump dependencies. 207 | - improvement: quit while running `daemon` in a dir which is not initialized. 208 | 209 | ## [0.2.10] - 2024-03-13 210 | 211 | - bump dependencies 212 | 213 | ## [0.2.9] - 2024-03-05 214 | 215 | - fix: modify src/ while building. This does not influence users. 216 | 217 | ## [0.2.8] - 2024-03-04 218 | 219 | - Auth reuse: use hard link instead of copying file 220 | - Stop publish utils and cli to crates.io, because `Cargo` only allow modify `OutDir` during building now. 221 | - Sync the version of cli and bin. 222 | 223 | ## [0.2.7] - 2024-03-03 224 | 225 | - improve error hint of IoErr 226 | 227 | ## [0.2.6] - 2024-02-29 228 | 229 | - Untrack: When untrack a set, it will clear its content now, or `fav status` would keep showing its resources after untracking. 230 | 231 | ## [0.2.5] - 2024-02-29 232 | 233 | - Improvement: the help info of `pull`. 234 | - Auth: Add `reuse` to `fav auth reuse` to reuse the old cookies. 235 | 236 | ## [0.2.4] - 2024-02-24 237 | 238 | - Fix: media count not refresh after fetching. 239 | 240 | ## [0.2.3] - 2024-02-24 241 | 242 | - TryFix: panic when `base_url` not exist. 243 | 244 | ## [0.2.2] - 2024-02-20 245 | 246 | - Fix: overwriting the same file when pulling resources with the same name. 247 | - Improvement: the help info of `pull`. 248 | 249 | ## [0.2.1] - 2024-02-20 250 | 251 | - Handle Expired. 252 | - Pull: If `pull bvid`, `fav` will force to pull it, as long as it's tracked and not expired. 253 | 254 | ## [0.2.0] - 2024-02-20 255 | 256 | - Broken upgrade: the new `fav` is not compatible with the old `fav`. You need to delete `.fav` dir and re-`init` your `fav` after upgrading to `0.2.0`. 257 | - Refactor: `fav` is completely rewritten in rusty style, and is now more generic and more maintainable. 258 | - Simplify: Only `fetch` `pull` `status` `track` `init` `auth` `daemon` `completion` commands are supported now. The `modify` command is removed, since it's too tedious to modify status through a CLI tool. 259 | - Status: Now `status` only show id, title and few status.What's more, use --sets instead of --list, --res instead of --video 260 | - Track: Now `track` does not support resource not in remote favorite sets. (In other words, there's no data only in local, but not in remote.) 261 | - Pull: Now `pull` will call `fetch` first, and resources not tracked and fetched will never able to be pulled. 262 | - Init: Only support bilibili now, so no args needed after `init`. 263 | - Daemon: Now iterval less that 15min will only show a warning, and won't exit. 264 | 265 | ## [0.1.13] - 2024-02-08 266 | 267 | - Fix: `fav completion` generate the wrong script. 268 | 269 | ## [0.1.12] - 2024-02-07 270 | 271 | - Fix: args parsing error when using `fav modify` `fav init` command. 272 | 273 | ## [0.1.11] - 2024-02-07 274 | 275 | - Fix: `Ctrl-C` only cancels current batch(10) of jobs, instead of exiting the whole program. 276 | - See discussions in #5 for more information about the next developping trends. 277 | 278 | ## [0.1.10] - 2024-02-06 279 | 280 | - add `fav completion` command to support auto completion for `bash`, `elvish`, `fish`, `powershell`, `zsh` shell; Run `fav completion -h` for more information. (e.g. run `fav completion fish > ~/.config/fish/completions/fav.fish` to register the auto completion script for `fish`; You can google `where to put completions for xxshell` to find the right place to put the completion script for your shell.) 281 | 282 | ## [0.1.9] - 2024-02-06 283 | 284 | - auto complete support for `zsh` and `fish`; Run `fav complete -h` for more information. (e.g. run `fav complete --shell fish --register ~/.config/fish/completions` to register the auto completion script for `fish`) 285 | - I'll also upload some other auto completion scripts for `bash` and `powershell` and so on. 286 | 287 | ## [0.1.8] - 2024-02-05 288 | 289 | - increased version to 0.1.8 290 | - narrow unsafe closure 291 | - upgrade git action 292 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 2 | [workspace] 3 | members = ["fav_bili", "migration"] 4 | default-members = ["fav_bili"] 5 | resolver = "2" 6 | 7 | [workspace.package] 8 | authors = ["Louis <836250617@qq.com>"] 9 | license = "MIT" 10 | edition = "2024" 11 | repository = "https://github.com/kingwingfly/fav" 12 | documentation = "https://github.com/kingwingfly/fav" 13 | 14 | [workspace.dependencies] 15 | 16 | [profile.release] 17 | lto = "fat" 18 | opt-level = 3 19 | codegen-units = 1 20 | strip = "debuginfo" 21 | panic = "abort" 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 王翼翔 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | [![Contributors][contributors-shield]][contributors-url] 6 | [![Forks][forks-shield]][forks-url] 7 | [![Stargazers][stars-shield]][stars-url] 8 | [![Issues][issues-shield]][issues-url] 9 | [![MIT License][license-shield]][license-url] 10 | 11 | 12 |
13 |
14 | 15 | Logo 16 | 17 | 18 |

fav

19 | 20 |

21 | Back up your favorite bilibili resources with CLI. 22 |
23 | Explore the docs » 24 |
25 |
26 | View Demo 27 | · 28 | Report Bug 29 | · 30 | Request Feature 31 |

32 |
33 | 34 | 35 |
36 | Table of Contents 37 |
    38 |
  1. 39 | About The Project 40 | 43 |
  2. 44 |
  3. 45 | Getting Started 46 | 50 |
  4. 51 |
  5. Usage
  6. 52 |
  7. Contributing
  8. 53 |
  9. License
  10. 54 |
  11. Contact
  12. 55 |
  13. Acknowledgments
  14. 56 |
57 |
58 | 59 | 60 | 61 | ## About The Project 62 | 63 | [![Product Name Screen Shot][product-screenshot]](https://github.com/kingwingfly/fav) 64 | 65 | Back up your favorite bilibili online resources with CLI. 66 | 67 | ⚠️: There's a broken change between v0 and v1, details in [CHANGELOG.md](CHANGELOG.md) 68 | 69 |

(back to top)

70 | 71 | ### Built With 72 | 73 | - [![Rust][Rust]][Rust-url] 74 | 75 |

(back to top)

76 | 77 | 78 | 79 | ## Getting Started 80 | 81 | You can download the release [here](https://github.com/kingwingfly/fav/releases). 82 | 83 | For Arch Linux users, you can `yay -S fav-git` maybe, someone has maken it a package. 84 | 85 | Or you can compile by yourself: 86 | 87 | 1. Clone the repo 88 | ```sh 89 | git clone https://github.com/kingwingfly/fav.git 90 | ``` 91 | 2. Compilation 92 | ```sh 93 | cargo build --release 94 | ``` 95 | 96 |

(back to top)

97 | 98 | 99 | 100 | ## Usage 101 | 102 | Need `ffmpeg` usable, and added to the PATH. 103 | 104 | ```sh 105 | Back up your favorite bilibili online resources with CLI. 106 | 107 | Usage: fav [OPTIONS] [COMMAND] 108 | 109 | Commands: 110 | auth Auth account 111 | list List accounts/sets/ups/medias [alias: ls, l] 112 | activate Activate obj [alias: active, a] 113 | deactivate Deactivate obj [alias: d] 114 | fetch Fetch metadata of following ups, fav sets, medias, ups [alias: f] 115 | pull Pull fetched medias [alias: p] 116 | like Like medias 117 | completion Generate completion script 118 | help Print this message or the help of the given subcommand(s) 119 | 120 | Options: 121 | -v, --verbose Show debug messages 122 | -h, --help Print help 123 | -V, --version Print version 124 | ``` 125 | 126 | ### Steps 127 | 128 | 1. Login first 129 | 2. Fetch the favorite sets(lists) 130 | 3. Activate the list or up you want. You can see them through `fav ls` 131 | 4. Fetch active resources 132 | 5. Pull the resources 133 | 134 | ### Example 135 | 136 | ```sh 137 | # auto completion is supported; e.g. fish 138 | fav completion fish > ~/.config/fish/completions/fav.fish 139 | # For Windows users 140 | echo "fav completion powershell | Out-String | Invoke-Expression" >> $PROFILE 141 | # scan code to login 142 | fav auth login # you can also login with `fav usecookies` 143 | # fetch following ups and fav sets 144 | fav fetch 145 | # show sets 146 | fav ls set 147 | # activate set or up 148 | fav activate 149 | # pull videos 150 | fav fetch 151 | fav pull 152 | # deactivate set or up 153 | fav deactivate 154 | # after fetching, you can find your favorite upper 155 | # limbo/sqlite3 .fav/fav.db 156 | SELECT u.up_id, u.name, COUNT(u.up_id) count FROM up u LEFT JOIN media_up mu ON u.up_id=mu.up_id JOIN media m ON mu.id=m.id GROUP BY u.up_id, u.name ORDER BY count; 157 | # you can also like medias, should usecookies when login 158 | fav like 159 | # or like all medias faved 160 | fav ls v | sed '1d;$d' | awk '{print $2;}' | xargs fav like 161 | # check cookies usability 162 | fav auth check -a 163 | ``` 164 | 165 | Service example: 166 | ```ini 167 | # /etc/systemd/system/fav.service 168 | [Unit] 169 | Description=Fav Service 170 | After=network-online.target 171 | 172 | [Service] 173 | Type=oneshot 174 | User=your_user 175 | WorkingDirectory=/path/to/fav_set 176 | ExecStart=/bin/sh -c "/usr/local/bin/fav fetch && /usr/local/bin/fav pull" 177 | 178 | # /etc/systemd/system/fav.timer 179 | [Unit] 180 | Description=Run fav service every 3 hours 181 | 182 | [Timer] 183 | OnCalendar=*-*-* 0/3:00:00 184 | # or OnUnitActiveSec=3h 185 | AccuracySec=1m 186 | Persistent=true 187 | 188 | [Install] 189 | WantedBy=timers.target 190 | ``` 191 | 192 | ```sh 193 | sudo systemctl daemon-reload 194 | sudo systemctl enable fav.timer 195 | sudo systemctl start fav.timer 196 | ``` 197 | 198 | You can also achieve the goal with `systemd timer` by yourself, but it's a little hard to learn. 199 | 200 | _For more examples, please refer to the [Documentation](https://github.com/kingwingfly/fav)_ 201 | 202 |

(back to top)

203 | 204 | 205 | 206 | ## Develop 207 | 208 | `sea-orm-cli` is used to handle database ops. 209 | 210 | ```sh 211 | cargo binstall sea-orm-cli # or `cargo install sea-orm-cli` 212 | # generate ORM code 213 | ./sea-orm.sh 214 | ``` 215 | 216 | 217 | 218 | ## Contributing 219 | 220 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. Moreover, it is recommended to open an issue before coding to avoid repeated and useless work. 221 | 222 | If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". 223 | Don't forget to give the project a star! Thanks again! 224 | 225 |

(back to top)

226 | 227 | 228 | 229 | ## License 230 | 231 | Distributed under the MIT License. See `LICENSE.txt` for more information. 232 | 233 |

(back to top)

234 | 235 | 236 | 237 | ## Contact 238 | 239 | Louis - 836250617@qq.com 240 | 241 | Project Link: [https://github.com/kingwingfly/fav](https://github.com/kingwingfly/fav) 242 | 243 |

(back to top)

244 | 245 | 246 | 247 | ## Acknowledgments 248 | 249 | - [bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) 250 | 251 |

(back to top)

252 | 253 | 254 | 255 | 256 | [contributors-shield]: https://img.shields.io/github/contributors/kingwingfly/fav.svg?style=for-the-badge 257 | [contributors-url]: https://github.com/kingwingfly/fav/graphs/contributors 258 | [forks-shield]: https://img.shields.io/github/forks/kingwingfly/fav.svg?style=for-the-badge 259 | [forks-url]: https://github.com/kingwingfly/fav/network/members 260 | [stars-shield]: https://img.shields.io/github/stars/kingwingfly/fav.svg?style=for-the-badge 261 | [stars-url]: https://github.com/kingwingfly/fav/stargazers 262 | [issues-shield]: https://img.shields.io/github/issues/kingwingfly/fav.svg?style=for-the-badge 263 | [issues-url]: https://github.com/kingwingfly/fav/issues 264 | [license-shield]: https://img.shields.io/github/license/kingwingfly/fav.svg?style=for-the-badge 265 | [license-url]: https://github.com/kingwingfly/fav/blob/master/LICENSE.txt 266 | [product-screenshot]: images/screenshot.png 267 | [Rust]: https://img.shields.io/badge/Rust-000000?style=for-the-badge&logo=Rust&logoColor=orange 268 | [Rust-url]: https://www.rust-lang.org 269 | -------------------------------------------------------------------------------- /fav_bili/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fav_bili" 3 | version = "1.0.8" 4 | authors.workspace = true 5 | license.workspace = true 6 | edition.workspace = true 7 | repository.workspace = true 8 | documentation.workspace = true 9 | description = "Back up your favorite bilibili online resources with CLI." 10 | keywords = ["bilibili", "bili"] 11 | 12 | [dependencies] 13 | tokio = { version = "1", default-features = false, features = [ 14 | "macros", 15 | "rt-multi-thread", 16 | "signal", 17 | "process", 18 | ] } 19 | tokio-util = { version = "0.7" } 20 | futures = { version = "0.3" } 21 | tracing = { version = "0.1" } 22 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 23 | anyhow = { version = "1.0" } 24 | const_format = { version = "0.2", features = ["fmt"] } 25 | dashmap = { version = "6.1" } 26 | clap = { version = "4.5", features = ["cargo"] } 27 | clap_complete = { version = "4.5.51" } 28 | api_req = { version = "0.4", features = ["cookies"] } 29 | reqwest = { version = "0.12" } 30 | url = { version = "2.5", features = ["serde"] } 31 | cookie = { version = "0.18.1" } 32 | serde = { version = "1", features = ["derive"] } 33 | serde_json = { version = "1" } 34 | serde_urlencoded = { version = "0.7" } 35 | qrcode = { version = "0.14", default-features = false } 36 | tabled = { version = "0.19.0" } 37 | indicatif = { version = "0.17.11" } 38 | unicode-width = { version = "0.2.0" } 39 | md5 = { version = "0.7" } 40 | hex = { version = "0.4" } 41 | ring = { version = "0.17" } 42 | sea-orm = { version = "1.1.0", features = [ 43 | "sqlx-sqlite", 44 | "runtime-tokio", 45 | "macros", 46 | ] } 47 | sea-orm-migration = { version = "1.1.0", features = [ 48 | "runtime-tokio", 49 | "sqlx-sqlite", 50 | ] } 51 | tempfile = { version = "3.20" } 52 | sanitize-filename = { version = "0.6" } 53 | paste = { version = "1.0" } 54 | 55 | [build-dependencies] 56 | vergen = { version = "9.0", features = ["rustc"] } 57 | vergen-gitcl = { version = "1.0", features = [] } 58 | -------------------------------------------------------------------------------- /fav_bili/README.md: -------------------------------------------------------------------------------- 1 | A CLI tool to download your favorite Bilibili medias from up and favorite collections. 2 | 3 | Need `ffmpeg` usable, and added to the PATH. 4 | 5 | ```sh 6 | Back up your favorite bilibili online resources with CLI. 7 | 8 | Usage: fav [OPTIONS] [COMMAND] 9 | 10 | Commands: 11 | auth Auth account 12 | list List accounts/sets/ups/medias [alias: ls, l] 13 | activate Activate obj [alias: active, a] 14 | deactivate Deactivate obj [alias: d] 15 | fetch Fetch metadata of following ups, fav sets, medias, ups [alias: f] 16 | pull Pull fetched medias [alias: p] 17 | like Like medias 18 | completion Generate completion script 19 | help Print this message or the help of the given subcommand(s) 20 | 21 | Options: 22 | -v, --verbose Show debug messages 23 | -h, --help Print help 24 | -V, --version Print version 25 | ``` 26 | 27 | ### Steps 28 | 29 | 1. Login first 30 | 2. Fetch the favorite sets(lists) 31 | 3. Activate the list or up you want. You can see them through `fav ls` 32 | 4. Fetch active resources 33 | 5. Pull the resources 34 | 35 | ### Example 36 | 37 | ```sh 38 | # auto completion is supported; e.g. fish 39 | fav completion fish > ~/.config/fish/completions/fav.fish 40 | # For Windows users 41 | echo "fav completion powershell | Out-String | Invoke-Expression" >> $PROFILE 42 | # scan code to login 43 | fav auth login 44 | # you can also login with `fav usecookies` 45 | # fetch following ups and fav sets 46 | fav fetch 47 | # show sets 48 | fav ls set 49 | # activate set or up 50 | fav activate 51 | # pull videos 52 | fav fetch 53 | fav pull 54 | # deactivate set or up 55 | fav deactivate 56 | # after fetching, you can find your favorite upper 57 | # limbo/sqlite3 .fav/fav.db 58 | SELECT u.up_id, u.name, COUNT(u.up_id) count FROM up u LEFT JOIN media_up mu ON u.up_id=mu.up_id JOIN media m ON mu.id=m.id GROUP BY u.up_id, u.name ORDER BY count; 59 | # you can also like medias, should usecookies when login 60 | fav like 61 | # or like all medias faved 62 | fav ls v | sed '1d;$d' | awk '{print $2;}' | xargs fav like 63 | # check cookies usability 64 | fav auth check -a 65 | ``` 66 | 67 | Service example: 68 | ```ini 69 | # /etc/systemd/system/fav.service 70 | [Unit] 71 | Description=Fav Service 72 | After=network-online.target 73 | 74 | [Service] 75 | Type=oneshot 76 | User=your_user 77 | WorkingDirectory=/path/to/fav_set 78 | ExecStart=/bin/sh -c "/usr/local/bin/fav fetch && /usr/local/bin/fav pull" 79 | 80 | # /etc/systemd/system/fav.timer 81 | [Unit] 82 | Description=Run fav service every 3 hours 83 | 84 | [Timer] 85 | OnCalendar=*-*-* 0/3:00:00 86 | # or OnUnitActiveSec=3h 87 | AccuracySec=1m 88 | Persistent=true 89 | 90 | [Install] 91 | WantedBy=timers.target 92 | ``` 93 | 94 | ```sh 95 | sudo systemctl daemon-reload 96 | sudo systemctl enable fav.timer 97 | sudo systemctl start fav.timer 98 | ``` 99 | -------------------------------------------------------------------------------- /fav_bili/build.rs: -------------------------------------------------------------------------------- 1 | use vergen::RustcBuilder; 2 | use vergen_gitcl::{Emitter, GitclBuilder}; 3 | 4 | fn main() { 5 | Emitter::default() 6 | .add_instructions( 7 | &GitclBuilder::default() 8 | .describe(true, true, Some("v*")) 9 | .build() 10 | .unwrap(), 11 | ) 12 | .unwrap() 13 | .add_instructions(&RustcBuilder::default().semver(true).build().unwrap()) 14 | .unwrap() 15 | .emit() 16 | .unwrap(); 17 | } 18 | -------------------------------------------------------------------------------- /fav_bili/src/action/activate.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use paste::paste; 3 | use tracing::info; 4 | 5 | use crate::db::db; 6 | 7 | macro_rules! activate { 8 | ($($obj: ident),+) => { 9 | $(paste! { 10 | pub async fn [](account_id: i64) -> Result<()> { 11 | let db = db().await; 12 | db.[](account_id).await?; 13 | info!(concat!("Activated ", stringify!($obj), "<{}>"), account_id); 14 | Ok(()) 15 | } 16 | 17 | pub async fn []() -> Result<()> { 18 | let db = db().await; 19 | db.[]().await?; 20 | info!(concat!("Activated all ", stringify!($obj), "s")); 21 | Ok(()) 22 | } 23 | })+ 24 | }; 25 | } 26 | 27 | activate!(account, set, up); 28 | -------------------------------------------------------------------------------- /fav_bili/src/action/auth.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use anyhow::{Context as _, Result, anyhow}; 4 | use api_req::{ApiCaller as _, error::ApiErr}; 5 | use futures::StreamExt as _; 6 | use qrcode::{QrCode, render::unicode}; 7 | use tokio::time::sleep; 8 | use tracing::{error, info}; 9 | 10 | use crate::{ 11 | api::{AuthApi, BiliApi}, 12 | cookies::{add_cookie_jar, current_cookies, parse_cookies}, 13 | db::db, 14 | entity::account, 15 | payload::{LogoutPayload, QrPayload, QrPollPayload, WbiPayload}, 16 | response::{LogoutResp, QrData, QrPollData, QrPollResp, QrResp, WbiData, WbiResp}, 17 | state::AccountState, 18 | }; 19 | 20 | pub async fn login() -> Result<()> { 21 | let db = db().await; 22 | let QrResp { 23 | data: QrData { url, qrcode_key }, 24 | } = AuthApi::request(QrPayload).await?; 25 | let code = QrCode::new(url.as_ref())?; 26 | let image = code 27 | .render::() 28 | .dark_color(unicode::Dense1x2::Light) 29 | .light_color(unicode::Dense1x2::Dark) 30 | .build(); 31 | println!("{}", image); 32 | loop { 33 | sleep(Duration::from_secs(3)).await; 34 | let QrPollResp { 35 | data: QrPollData { code, message }, 36 | } = AuthApi::request(QrPollPayload { 37 | qrcode_key: qrcode_key.clone(), 38 | }) 39 | .await?; 40 | match code { 41 | 0 => { 42 | info!("Login successfully."); 43 | break; 44 | } 45 | 86101 | 86090 => {} 46 | _ => { 47 | error!("{}", message); 48 | return Ok(()); 49 | } 50 | } 51 | } 52 | let cookies = current_cookies()?; 53 | let WbiResp { 54 | data: WbiData { mid, uname, .. }, 55 | } = BiliApi::request(WbiPayload).await?; 56 | db.upsert_account(account::Model { 57 | account_id: mid, 58 | name: uname.to_owned(), 59 | cookies, 60 | state: AccountState::Active.to_string(), 61 | }) 62 | .await?; 63 | println!("Hello😊, {}.", uname); 64 | Ok(()) 65 | } 66 | 67 | pub async fn usecookies(cookies: String) -> Result<()> { 68 | let db = db().await; 69 | add_cookie_jar(parse_cookies(&cookies)); 70 | let cookies = current_cookies()?; 71 | let WbiResp { 72 | data: WbiData { mid, uname, .. }, 73 | } = BiliApi::request(WbiPayload).await?; 74 | db.upsert_account(account::Model { 75 | account_id: mid, 76 | name: uname.to_owned(), 77 | cookies, 78 | state: AccountState::Active.to_string(), 79 | }) 80 | .await?; 81 | println!("Hello😊, {}.", uname); 82 | Ok(()) 83 | } 84 | 85 | pub async fn logout(account_id: i64) -> Result<()> { 86 | let db = db().await; 87 | let account = db.get_account(account_id).await?; 88 | logout_account(account_id, account.cookies).await?; 89 | info!("Logout successfully."); 90 | db.delete_account(account_id).await?; 91 | println!("Goodbye👋, {}", account.name); 92 | Ok(()) 93 | } 94 | 95 | pub async fn logout_all() -> Result<()> { 96 | let db = db().await; 97 | let accounts = db.all_accounts().await?; 98 | let mut tasks = futures::stream::iter(accounts) 99 | .map(|account| async move { 100 | logout_account(account.account_id, account.cookies).await?; 101 | info!("Logout successfully."); 102 | db.delete_account(account.account_id).await?; 103 | println!("Goodbye👋, {}", account.name); 104 | Ok::<_, anyhow::Error>(()) 105 | }) 106 | .buffer_unordered(8); 107 | while let Some(res) = tasks.next().await { 108 | if let Err(e) = res { 109 | error!("{}", e); 110 | } 111 | } 112 | Ok(()) 113 | } 114 | 115 | async fn logout_account(account_id: i64, cookies: String) -> Result<()> { 116 | let cookies = parse_cookies(&cookies).collect::>(); 117 | let bili_jct = cookies 118 | .iter() 119 | .find(|c| c.name() == "bili_jct") 120 | .map(|c| c.value().to_owned()) 121 | .context(format!( 122 | "No bili_jct in cookies of account_id<{}>.", 123 | account_id 124 | ))?; 125 | add_cookie_jar(cookies.into_iter()); 126 | let LogoutResp { code, message } = 127 | AuthApi::request(LogoutPayload { biliCSRF: bili_jct }).await?; 128 | match code { 129 | 0 => Ok(()), 130 | _ => Err(anyhow!("Failed to logout: {}", message.unwrap_or_default())), 131 | } 132 | } 133 | 134 | pub async fn check(account_id: i64) -> Result<()> { 135 | let db = db().await; 136 | let account = db.get_account(account_id).await?; 137 | check_account(account).await?; 138 | Ok(()) 139 | } 140 | 141 | pub async fn check_all() -> Result<()> { 142 | let db = db().await; 143 | let accounts = db.all_accounts().await?; 144 | for account in accounts { 145 | check_account(account).await?; 146 | } 147 | Ok(()) 148 | } 149 | 150 | async fn check_account(account: account::Model) -> Result<()> { 151 | add_cookie_jar(parse_cookies(&account.cookies)); 152 | match BiliApi::request(WbiPayload).await { 153 | Ok(WbiResp { 154 | data: WbiData { mid, .. }, 155 | }) => { 156 | if mid == account.account_id { 157 | info!("Check passed. Hello😊, {}.", account.name); 158 | } else { 159 | error!( 160 | "Bilibili returned unmatched user id account<{}>", 161 | account.name 162 | ) 163 | } 164 | } 165 | Err(ApiErr::UnDeserializeable(_)) => error!( 166 | "Bilibili returned unexpected json, cookies expired: account<{}>", 167 | account.name 168 | ), 169 | Err(e) => return Err(e.into()), 170 | } 171 | Ok(()) 172 | } 173 | -------------------------------------------------------------------------------- /fav_bili/src/action/deactivate.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use paste::paste; 3 | use tracing::info; 4 | 5 | use crate::db::db; 6 | 7 | macro_rules! deactivate { 8 | ($($obj: ident),+) => { 9 | $(paste! { 10 | pub async fn [](account_id: i64) -> Result<()> { 11 | let db = db().await; 12 | db.[](account_id).await?; 13 | info!(concat!("Deactivated ", stringify!($obj), "<{}>"), account_id); 14 | Ok(()) 15 | } 16 | 17 | pub async fn []() -> Result<()> { 18 | let db = db().await; 19 | db.[]().await?; 20 | info!(concat!("Deactivated all ", stringify!($obj), "s")); 21 | Ok(()) 22 | } 23 | })+ 24 | }; 25 | } 26 | 27 | deactivate!(account, set, up); 28 | -------------------------------------------------------------------------------- /fav_bili/src/action/fetch.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | sync::Arc, 4 | }; 5 | 6 | use anyhow::{Context as _, Result, anyhow}; 7 | use api_req::ApiCaller; 8 | use dashmap::DashSet; 9 | use futures::StreamExt as _; 10 | use sea_orm::ColumnTrait as _; 11 | use tracing::{debug, error, info, warn}; 12 | 13 | use crate::{ 14 | api::BiliApi, 15 | cookies::{add_cookie_jar, parse_cookies}, 16 | db::db, 17 | entity::{account, media, media_set, media_up, set, set_account, up, up_account}, 18 | payload::{ 19 | FollowingNumPayload, FollowingUpPayload, InSetPayload, InUpPayload, ListSetPayload, 20 | MediaInfoPayload, PublishNumPayload, 21 | }, 22 | response::{ 23 | FollowingNumData, FollowingNumResp, FollowingUpData, FollowingUpResp, InSetData, InSetResp, 24 | InUpData, InUpList, InUpResp, ListSetData, ListSetResp, MediaInfoData, MediaInfoResp, 25 | PublishNumData, PublishNumResp, 26 | }, 27 | state::{AccountState, MediaState, SetState, UpState}, 28 | }; 29 | 30 | pub async fn fetch(prune: bool) -> Result<()> { 31 | let db = db().await; 32 | let accounts = db 33 | .get_accounts_filtered(account::Column::State.eq(AccountState::Active)) 34 | .await?; 35 | for account in accounts.iter() { 36 | add_cookie_jar(parse_cookies(&account.cookies)); 37 | let account_id = account.account_id; 38 | info!("Fetching sets with account<{}>", account.name); 39 | let ListSetResp { 40 | data: ListSetData { list }, 41 | } = BiliApi::request(ListSetPayload { up_mid: account_id }).await?; 42 | if !list.is_empty() { 43 | db.upsert_sets(list.iter().map(|set| { 44 | debug!("Updating set<{}>", set.title); 45 | set::Model { 46 | set_id: set.id, 47 | name: set.title.to_owned(), 48 | count: set.media_count, 49 | state: SetState::Inactive.to_string(), // conflic skip 50 | } 51 | })) 52 | .await?; 53 | db.upsert_set_accounts(list.iter().map(|set| { 54 | debug!("Linking account<{}> and set<{}>", account.name, set.title,); 55 | set_account::Model { 56 | set_id: set.id, 57 | account_id, 58 | } 59 | })) 60 | .await?; 61 | } 62 | let mut old_set_ids: HashSet = 63 | HashSet::from_iter(db.get_set_ids_of_account(account_id).await?); 64 | for set in list { 65 | old_set_ids.remove(&set.id); 66 | } 67 | for set_id in old_set_ids { 68 | db.delete_set_account(set_account::Model { set_id, account_id }) 69 | .await?; 70 | warn!("Unlinked account<{}> and set<{}>", account.name, set_id,); 71 | } 72 | info!("Fetching following ups with account<{}>", account.name); 73 | let FollowingNumResp { 74 | data: FollowingNumData { following }, 75 | } = BiliApi::request(FollowingNumPayload { vmid: account_id }) 76 | .await 77 | .context("Failed to fetch following ups number")?; 78 | if following == 0 { 79 | continue; 80 | } 81 | let page = (following - 1) / 50 + 1; 82 | let mut tasks = futures::stream::iter(1..=page) 83 | .map(|pn| async move { 84 | let FollowingUpResp { 85 | data: FollowingUpData { list }, 86 | } = BiliApi::request(FollowingUpPayload { 87 | vmid: account_id, 88 | pn, 89 | ps: 50, 90 | }) 91 | .await 92 | .context(format!("Failed to fetch following ups' page {}", pn))?; 93 | Ok::<_, anyhow::Error>(list) 94 | }) 95 | .buffer_unordered(8); 96 | let mut ups = vec![]; 97 | while let Some(res) = tasks.next().await { 98 | match res { 99 | Ok(list) => ups.extend(list), 100 | Err(e) => error!("{}", e), 101 | } 102 | } 103 | let mut old_following_ids: HashSet = 104 | HashSet::from_iter(db.get_up_ids_of_account(account_id).await?); 105 | if !ups.is_empty() { 106 | db.upsert_ups(ups.iter().map(|up| { 107 | debug!("Updating following up<{}>", up.name); 108 | up::Model { 109 | up_id: up.mid, 110 | name: up.name.to_owned(), 111 | state: UpState::Inactive.to_string(), 112 | } 113 | })) 114 | .await?; 115 | db.upsert_up_accounts(ups.iter().map(|up| { 116 | debug!("Linking account<{}> and up<{}>", account.name, up.name); 117 | up_account::Model { 118 | up_id: up.mid, 119 | account_id, 120 | } 121 | })) 122 | .await?; 123 | for up in ups { 124 | old_following_ids.remove(&up.mid); 125 | } 126 | for up_id in old_following_ids { 127 | db.delete_up_account(up_account::Model { up_id, account_id }) 128 | .await?; 129 | warn!("Unlinked account<{}> and up<{}>", account.name, up_id,); 130 | } 131 | } 132 | } 133 | let fetched_sets = DashSet::::new(); 134 | for account in accounts.iter() { 135 | info!("Fetching set medias with account<{}>", account.name); 136 | add_cookie_jar(parse_cookies(&account.cookies)); 137 | let account_id = account.account_id; 138 | let set_ids_of_account = db.get_set_ids_of_account(account_id).await?; 139 | for set_id in set_ids_of_account { 140 | if fetched_sets.contains(&set_id) { 141 | continue; 142 | } 143 | let set = db.get_set(set_id).await?; 144 | if set.state != SetState::Active.to_string() || set.count == 0 { 145 | continue; 146 | } 147 | info!("Fetching medias in set<{}>", set.name); 148 | let page = (set.count - 1) / 20 + 1; 149 | let mut tasks = futures::stream::iter(1..=page) 150 | .map(|pn| async move { 151 | let InSetResp { 152 | data: InSetData { medias }, 153 | } = BiliApi::request(InSetPayload { 154 | media_id: set.set_id, 155 | pn, 156 | ps: 20, 157 | }) 158 | .await 159 | .context(format!("Failed to fetch sets' page {}", pn))?; 160 | Ok::<_, anyhow::Error>(medias) 161 | }) 162 | .buffer_unordered(8); 163 | let mut medias = vec![]; 164 | while let Some(res) = tasks.next().await { 165 | match res { 166 | Ok(list) => medias.extend(list), 167 | Err(e) => error!("{}", e), 168 | } 169 | } 170 | if !medias.is_empty() { 171 | db.upsert_medias(medias.iter().map(|m| { 172 | debug!("Updating media<{}>", m.title); 173 | media::Model { 174 | id: m.id, 175 | bv_id: m.bv_id.to_owned(), 176 | title: m.title.to_owned(), 177 | r#type: m.r#type.to_string(), 178 | state: MediaState::Pending.to_string(), 179 | } 180 | })) 181 | .await?; 182 | db.upsert_media_sets(medias.into_iter().map(|m| { 183 | debug!("Linking media<{}> and set<{}>", m.title, set.name); 184 | media_set::Model { id: m.id, set_id } 185 | })) 186 | .await?; 187 | } 188 | fetched_sets.insert(set_id); 189 | } 190 | } 191 | let fetched_ups = DashSet::::new(); 192 | for account in accounts.iter() { 193 | info!( 194 | "Fetching published contents of ups with account<{}>", 195 | account.name 196 | ); 197 | add_cookie_jar(parse_cookies(&account.cookies)); 198 | let account_id = account.account_id; 199 | let up_ids_of_account = db.get_up_ids_of_account(account_id).await?; 200 | for up_id in up_ids_of_account { 201 | if fetched_ups.contains(&up_id) { 202 | continue; 203 | } 204 | let up = db.get_up(up_id).await?; 205 | if up.state != SetState::Active.to_string() { 206 | continue; 207 | } 208 | let PublishNumResp { 209 | data: PublishNumData { video }, 210 | } = BiliApi::request(PublishNumPayload { mid: up_id }).await?; 211 | if video == 0 { 212 | continue; 213 | } 214 | info!("Fetching published videos of up<{}>", up.name); 215 | let page = (video - 1) / 30 + 1; 216 | let mut tasks = futures::stream::iter(1..=page) 217 | .map(|pn| async move { 218 | let InUpResp { 219 | data: 220 | InUpData { 221 | list: InUpList { vlist }, 222 | }, 223 | } = BiliApi::request(InUpPayload::new(up_id, pn, 30).await?) 224 | .await 225 | .context(format!("Failed to fetch up space page {}", pn))?; 226 | Ok::<_, anyhow::Error>(vlist) 227 | }) 228 | .buffer_unordered(8); 229 | let mut medias = vec![]; 230 | while let Some(res) = tasks.next().await { 231 | match res { 232 | Ok(list) => medias.extend(list), 233 | Err(e) => error!("{}", e), 234 | } 235 | } 236 | if !medias.is_empty() { 237 | db.upsert_medias(medias.iter().map(|m| { 238 | debug!("Updating media<{}>", m.title); 239 | media::Model { 240 | id: m.id, 241 | bv_id: m.bv_id.to_owned(), 242 | title: m.title.to_owned(), 243 | r#type: m.r#type.to_string(), 244 | state: MediaState::Pending.to_string(), 245 | } 246 | })) 247 | .await?; 248 | db.upsert_media_ups(medias.into_iter().map(|m| { 249 | debug!("Linking media<{}> and up<{}>", m.title, up.name); 250 | media_up::Model { id: m.id, up_id } 251 | })) 252 | .await?; 253 | } 254 | fetched_ups.insert(up_id); 255 | } 256 | } 257 | let fetched_medias = Arc::new(DashSet::::new()); 258 | for account in accounts.iter() { 259 | info!("Fetching media metadatas with account<{}>", account.name); 260 | add_cookie_jar(parse_cookies(&account.cookies)); 261 | let medias = db.all_active_medias().await?; 262 | let mut tasks = futures::stream::iter( 263 | medias 264 | .into_iter() 265 | .filter(|media| !fetched_medias.contains(&media.id)), 266 | ) 267 | .map(|media| async move { 268 | match BiliApi::request(MediaInfoPayload { aid: media.id }).await? { 269 | MediaInfoResp { 270 | data: Some(MediaInfoData { owner, staff, .. }), 271 | code: 0, 272 | .. 273 | } => Ok((owner, staff, media)), 274 | MediaInfoResp { 275 | message: option_msg, 276 | .. 277 | } => Err(anyhow!( 278 | "Info unreachable media<{} {}>: {}", 279 | media.title, 280 | media.id, 281 | option_msg.unwrap_or_default() 282 | )), 283 | } 284 | }) 285 | .buffer_unordered(128); 286 | let mut media_ups = vec![]; 287 | let mut ups = HashMap::new(); 288 | while let Some(res) = tasks.next().await { 289 | match res { 290 | Ok((owner, staff, media)) => { 291 | ups.insert(owner.mid, owner.clone()); 292 | media_ups.push((media.clone(), owner)); 293 | if let Some(staff) = staff { 294 | staff.into_iter().for_each(|staff| { 295 | ups.insert(staff.mid, staff.clone()); 296 | media_ups.push((media.clone(), staff)); 297 | }); 298 | } 299 | } 300 | Err(e) => error!("{}", e), 301 | } 302 | } 303 | if !ups.is_empty() { 304 | db.upsert_ups(ups.into_values().map(|up| { 305 | debug!("Updating up<{}>", up.name); 306 | up::Model { 307 | up_id: up.mid, 308 | name: up.name, 309 | state: UpState::Inactive.to_string(), 310 | } 311 | })) 312 | .await?; 313 | } 314 | if !media_ups.is_empty() { 315 | db.upsert_media_ups(media_ups.iter().map(|(media, up)| { 316 | debug!("Linking media<{}> and up<{}>", media.title, up.name); 317 | media_up::Model { 318 | id: media.id, 319 | up_id: up.mid, 320 | } 321 | })) 322 | .await?; 323 | } 324 | for (media, _) in media_ups.into_iter() { 325 | fetched_medias.insert(media.id); 326 | } 327 | } 328 | if prune { 329 | info!("Pruning unfaved sets"); 330 | db.prune_sets().await?; 331 | info!("Pruning unfollowed ups"); 332 | db.prune_ups().await?; 333 | info!("Pruning unfollowed medias"); 334 | db.prune_medias().await?; 335 | } 336 | info!("Finished fetching"); 337 | Ok(()) 338 | } 339 | -------------------------------------------------------------------------------- /fav_bili/src/action/like.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::{Context as _, Result, anyhow}; 4 | use api_req::ApiCaller; 5 | use cookie::Cookie; 6 | use futures::StreamExt; 7 | use sea_orm::ColumnTrait as _; 8 | use tracing::{error, info, warn}; 9 | 10 | use crate::{ 11 | api::BiliApi, 12 | cookies::{add_cookie_jar, current_cookies, parse_cookies}, 13 | db::db, 14 | entity::account, 15 | payload::{Buvid3Payload, LikePayload, TicketPayload}, 16 | response::{Buvid3Data, Buvid3Resp, LikeResp, TicketData, TicketResp}, 17 | state::AccountState, 18 | }; 19 | 20 | pub async fn like(avids: Vec) -> Result<()> { 21 | let db = db().await; 22 | let accounts = db 23 | .get_accounts_filtered(account::Column::State.eq(AccountState::Active)) 24 | .await?; 25 | for account in accounts { 26 | let mut cookies = parse_cookies(&account.cookies) 27 | .map(|c| (c.name().to_owned(), c)) 28 | .collect::>(); 29 | let bili_jct = cookies 30 | .get("bili_jct") 31 | .map(|c| c.value().to_owned()) 32 | .context(format!( 33 | "No bili_jct in cookies of account<{}>.", 34 | account.name 35 | ))?; 36 | match ( 37 | cookies 38 | .get("bili_ticket_expires") 39 | .and_then(|c| c.value().parse::().ok()), 40 | cookies.contains_key("bili_ticket"), 41 | ) { 42 | (Some(bili_ticket_expires), true) 43 | if bili_ticket_expires 44 | > std::time::SystemTime::now() 45 | .duration_since(std::time::UNIX_EPOCH) 46 | .unwrap() 47 | .as_secs() => {} 48 | (None, true) => { 49 | warn!( 50 | "bili_ticket_expires not exist or invalid, did not check bili_ticket account<{}>", 51 | account.name 52 | ) 53 | } 54 | _ => { 55 | warn!( 56 | "bili_ticket has expired or not exists account<{}>", 57 | account.name 58 | ); 59 | info!("generating bili_ticket account<{}>", account.name); 60 | let TicketResp { 61 | data: 62 | TicketData { 63 | ticket, 64 | created_at, 65 | ttl, 66 | }, 67 | } = BiliApi::request(TicketPayload::new(bili_jct.to_owned())).await?; 68 | cookies.extend( 69 | [ 70 | Cookie::new("bili_ticket", ticket), 71 | Cookie::new("bili_ticket_expires", (created_at + ttl).to_string()), 72 | ] 73 | .into_iter() 74 | .map(|c| (c.value().to_owned(), c)), 75 | ); 76 | } 77 | } 78 | if !cookies.contains_key("buvid3") { 79 | info!("generating buvid3 account<{}>", account.name); 80 | let Buvid3Resp { 81 | data: Buvid3Data { buvid }, 82 | } = BiliApi::request(Buvid3Payload).await?; 83 | cookies.insert("buvid3".to_string(), Cookie::new("buvid3", buvid)); 84 | } 85 | add_cookie_jar(cookies.into_values()); 86 | info!("Saving cookies account<{}>", account.name); 87 | let cookies = current_cookies()?; 88 | db.upsert_account(account::Model { 89 | account_id: account.account_id, 90 | name: account.name, 91 | cookies, 92 | state: account.state, 93 | }) 94 | .await?; 95 | let mut tasks = futures::stream::iter(avids.iter()) 96 | .map(|&aid| { 97 | let bili_jct = bili_jct.to_owned(); 98 | async move { 99 | let LikeResp { code, message } = BiliApi::request(LikePayload { 100 | aid, 101 | like: 1, 102 | csrf: bili_jct, 103 | }) 104 | .await?; 105 | match code { 106 | 0 => { 107 | info!("Liked {}", aid); 108 | Ok::<_, anyhow::Error>(()) 109 | } 110 | _ => Err(anyhow!("{} {}", message, aid)), 111 | } 112 | } 113 | }) 114 | .buffer_unordered(8); 115 | while let Some(res) = tasks.next().await { 116 | if let Err(e) = res { 117 | error!("{}", e); 118 | } 119 | } 120 | } 121 | Ok(()) 122 | } 123 | -------------------------------------------------------------------------------- /fav_bili/src/action/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | use crate::{db::db, entity::ToTableRecord, table::table}; 4 | 5 | pub async fn list_accounts() -> Result<()> { 6 | let db = db().await; 7 | let accounts = db.all_accounts().await?; 8 | let table = table( 9 | ["account_id", "name", "state"], 10 | accounts.into_iter().map(ToTableRecord::to_record), 11 | ); 12 | println!("{}\nrows: {}", table, table.count_rows() - 1); 13 | Ok(()) 14 | } 15 | 16 | pub async fn list_sets() -> Result<()> { 17 | let db = db().await; 18 | let sets = db.all_sets().await?; 19 | let table = table( 20 | ["set_id", "name", "count", "state"], 21 | sets.into_iter().map(ToTableRecord::to_record), 22 | ); 23 | println!("{}\nrows: {}", table, table.count_rows() - 1); 24 | Ok(()) 25 | } 26 | 27 | pub async fn list_medias() -> Result<()> { 28 | let db = db().await; 29 | let medias = db.all_medias().await?; 30 | let table = table( 31 | ["id", "bvid", "title", "type", "state"], 32 | medias.into_iter().map(ToTableRecord::to_record), 33 | ); 34 | println!("{}\nrows: {}", table, table.count_rows() - 1); 35 | Ok(()) 36 | } 37 | 38 | pub async fn list_ups() -> Result<()> { 39 | let db = db().await; 40 | let ups = db.all_ups().await?; 41 | let table = table( 42 | ["id", "name", "state"], 43 | ups.into_iter().map(ToTableRecord::to_record), 44 | ); 45 | println!("{}\nrows: {}", table, table.count_rows() - 1); 46 | Ok(()) 47 | } 48 | -------------------------------------------------------------------------------- /fav_bili/src/action/mod.rs: -------------------------------------------------------------------------------- 1 | mod activate; 2 | mod auth; 3 | mod deactivate; 4 | mod fetch; 5 | mod like; 6 | mod list; 7 | mod pull; 8 | 9 | pub use activate::*; 10 | pub use auth::*; 11 | pub use deactivate::*; 12 | pub use fetch::*; 13 | pub use like::*; 14 | pub use list::*; 15 | pub use pull::*; 16 | -------------------------------------------------------------------------------- /fav_bili/src/action/pull.rs: -------------------------------------------------------------------------------- 1 | use std::{io::Write, sync::Arc}; 2 | 3 | use anyhow::{Result, anyhow}; 4 | use api_req::ApiCaller as _; 5 | use dashmap::DashSet; 6 | use futures::StreamExt as _; 7 | use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; 8 | use reqwest::header::{CONTENT_LENGTH, HeaderValue}; 9 | use sea_orm::ColumnTrait as _; 10 | use tempfile::NamedTempFile; 11 | use tokio_util::sync::CancellationToken; 12 | use tracing::{error, info, warn}; 13 | 14 | use crate::{ 15 | api::BiliApi, 16 | cookies::{add_cookie_jar, parse_cookies}, 17 | db::{Db, db}, 18 | entity::{account, media}, 19 | payload::{DashPayload, MediaInfoPayload}, 20 | response::{Dash, DashData, DashResp, MediaInfoData, MediaInfoResp, Page}, 21 | state::{AccountState, MediaState}, 22 | table::head, 23 | }; 24 | 25 | pub async fn pull() -> Result<()> { 26 | let db = db().await; 27 | let accounts = db 28 | .get_accounts_filtered(account::Column::State.eq(AccountState::Active)) 29 | .await?; 30 | let pulled_medias = Arc::new(DashSet::::new()); 31 | let medias = db.all_active_pending_medias().await?; 32 | let bars = MultiProgress::with_draw_target(ProgressDrawTarget::stderr()); 33 | for account in accounts { 34 | info!("Pulling medias with account<{}>", account.name); 35 | add_cookie_jar(parse_cookies(&account.cookies)); 36 | let token = CancellationToken::new(); 37 | let mut tasks = futures::stream::iter( 38 | medias 39 | .iter() 40 | .filter(|media| !pulled_medias.contains(&media.id)), 41 | ) 42 | .map(|media| { 43 | let token = token.clone(); 44 | let db = db.clone(); 45 | let bars = bars.clone(); 46 | let pulled_medias = pulled_medias.clone(); 47 | async move { 48 | tokio::select! { 49 | res = download(media, db, bars), if !token.is_cancelled() => match res { 50 | Ok(_) => { pulled_medias.insert(media.id); } 51 | Err(e) => error!("{}", e), 52 | }, 53 | _ = token.cancelled() => {}, 54 | } 55 | } 56 | }) 57 | .buffer_unordered(8); 58 | loop { 59 | tokio::select! { 60 | res = tasks.next() => { 61 | if res.is_none() { 62 | break; 63 | } 64 | } 65 | _ = tokio::signal::ctrl_c() => { 66 | token.cancel(); 67 | warn!("Received Ctrl-C"); 68 | break; 69 | } 70 | } 71 | } 72 | } 73 | drop(bars); 74 | info!("Finished pulling"); 75 | Ok(()) 76 | } 77 | 78 | async fn download(media: &media::Model, db: Db, bars: MultiProgress) -> Result<()> { 79 | match BiliApi::request(MediaInfoPayload { aid: media.id }).await? { 80 | MediaInfoResp { 81 | data: Some(MediaInfoData { pages, .. }), 82 | code: 0, 83 | .. 84 | } => { 85 | let only1p = pages.len() == 1; 86 | for Page { cid, page, part } in pages { 87 | let filename = if only1p { 88 | format!("{}-{}", media.id, media.title) 89 | } else { 90 | format!("{}-{}({page})-{part}", media.id, media.title) 91 | }; 92 | let DashResp { 93 | data: 94 | DashData { 95 | dash: Dash { video, audio }, 96 | }, 97 | } = BiliApi::request(DashPayload::new(media.id, cid).await?).await?; 98 | 99 | match (video.into_iter().next(), audio.into_iter().next()) { 100 | (Some(v), Some(a)) => { 101 | let mut resp_v = BiliApi::client().get(v.base_url).send().await?; 102 | let mut resp_a = BiliApi::client().get(a.base_url).send().await?; 103 | let hv2u64 = 104 | |hv: &HeaderValue| -> u64 { hv.to_str().unwrap().parse().unwrap() }; 105 | let size = hv2u64(&resp_v.headers()[CONTENT_LENGTH]) 106 | + hv2u64(&resp_a.headers()[CONTENT_LENGTH]); 107 | let pb = ProgressBar::new(size); 108 | bars.add(pb.clone()); 109 | pb.set_message(head(part, 10)); 110 | pb.set_style( 111 | ProgressStyle::with_template("{msg} {spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})") 112 | .unwrap() 113 | .progress_chars("#>-") 114 | ); 115 | let mut file_v = NamedTempFile::new()?; 116 | let mut file_a = NamedTempFile::new()?; 117 | let (mut finished_v, mut finished_a) = (false, false); 118 | loop { 119 | tokio::select! { 120 | res = resp_v.chunk(), if !finished_v => { 121 | match res { 122 | Ok(Some(chunk)) => { 123 | file_v.write_all(&chunk)?; 124 | file_v.flush()?; 125 | pb.inc(chunk.len() as u64); 126 | } 127 | Ok(None) => finished_v = true, 128 | Err(e) => return Err(anyhow!( 129 | "Failed to download video {filename}: {e}" 130 | )) 131 | } 132 | } 133 | res = resp_a.chunk(), if !finished_a => { 134 | match res { 135 | Ok(Some(chunk)) => { 136 | file_a.write_all(&chunk)?; 137 | file_a.flush()?; 138 | pb.inc(chunk.len() as u64); 139 | } 140 | Ok(None) => finished_a = true, 141 | Err(e) => return Err(anyhow!( 142 | "Failed to download audio {filename}: {e}" 143 | )) 144 | } 145 | } 146 | else => break, 147 | } 148 | } 149 | let title = format!( 150 | "{filename}.mp4", 151 | filename = sanitize_filename::sanitize(&filename) 152 | ); 153 | let status = tokio::process::Command::new("ffmpeg") 154 | .args([ 155 | "-y", 156 | "-i", 157 | file_v.path().to_str().unwrap(), 158 | "-i", 159 | file_a.path().to_str().unwrap(), 160 | "-codec", 161 | "copy", 162 | "-f", 163 | "mp4", 164 | &format!("./{}", title), 165 | ]) 166 | .stderr(std::process::Stdio::null()) 167 | .status() 168 | .await 169 | .unwrap(); 170 | if !status.success() { 171 | return Err(anyhow!("Failed to merge video and audio {filename}")); 172 | } 173 | } 174 | (Some(v), None) => { 175 | let mut resp_v = BiliApi::client().get(v.base_url).send().await?; 176 | let hv2u64 = 177 | |hv: &HeaderValue| -> u64 { hv.to_str().unwrap().parse().unwrap() }; 178 | let size = hv2u64(&resp_v.headers()[CONTENT_LENGTH]); 179 | let pb = ProgressBar::new(size); 180 | bars.add(pb.clone()); 181 | pb.set_message(head(part, 10)); 182 | pb.set_style( 183 | ProgressStyle::with_template("{msg} {spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})") 184 | .unwrap() 185 | .progress_chars("#>-") 186 | ); 187 | let mut file_v = NamedTempFile::new()?; 188 | loop { 189 | match resp_v.chunk().await { 190 | Ok(Some(chunk)) => { 191 | file_v.write_all(&chunk)?; 192 | file_v.flush()?; 193 | pb.inc(chunk.len() as u64); 194 | } 195 | Ok(None) => break, 196 | Err(e) => { 197 | return Err(anyhow!( 198 | "Failed to download video {filename}: {e}" 199 | )); 200 | } 201 | } 202 | } 203 | let title = format!( 204 | "{filename}.mp4", 205 | filename = sanitize_filename::sanitize(&filename) 206 | ); 207 | tokio::fs::rename(file_v.path(), format!("./{}", title)).await?; 208 | } 209 | (None, Some(a)) => { 210 | let mut resp_a = BiliApi::client().get(a.base_url).send().await?; 211 | let hv2u64 = 212 | |hv: &HeaderValue| -> u64 { hv.to_str().unwrap().parse().unwrap() }; 213 | let size = hv2u64(&resp_a.headers()[CONTENT_LENGTH]); 214 | let pb = ProgressBar::new(size); 215 | bars.add(pb.clone()); 216 | pb.set_message(head(part, 10)); 217 | pb.set_style( 218 | ProgressStyle::with_template("{msg} {spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})") 219 | .unwrap() 220 | .progress_chars("#>-") 221 | ); 222 | let mut file_a = NamedTempFile::new()?; 223 | loop { 224 | match resp_a.chunk().await { 225 | Ok(Some(chunk)) => { 226 | file_a.write_all(&chunk)?; 227 | file_a.flush()?; 228 | pb.inc(chunk.len() as u64); 229 | } 230 | Ok(None) => break, 231 | Err(e) => { 232 | return Err(anyhow!( 233 | "Failed to download audio {filename}: {e}" 234 | )); 235 | } 236 | } 237 | } 238 | let title = format!( 239 | "{filename}.mp3", 240 | filename = sanitize_filename::sanitize(&filename) 241 | ); 242 | tokio::fs::rename(file_a.path(), format!("./{}", title)).await?; 243 | } 244 | _ => {} 245 | } 246 | } 247 | db.set_media_state(media.id, MediaState::Completed).await?; 248 | Ok(()) 249 | } 250 | MediaInfoResp { 251 | code, 252 | message: option_msg, 253 | .. 254 | } => { 255 | db.set_media_state( 256 | media.id, 257 | match code { 258 | -403 | 62012 | 62002 => MediaState::PermissionDenied, 259 | _ => MediaState::Expired, 260 | }, 261 | ) 262 | .await?; 263 | Err(anyhow!( 264 | "Info unreachable media<{}-{}>: {}", 265 | media.id, 266 | media.title, 267 | option_msg.unwrap_or_default() 268 | )) 269 | } 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /fav_bili/src/api.rs: -------------------------------------------------------------------------------- 1 | use api_req::{ApiCaller, header}; 2 | 3 | const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15"; 4 | const REFERER: &str = "https://www.bilibili.com/"; 5 | 6 | #[derive(Debug, ApiCaller)] 7 | #[api_req( 8 | base_url = "https://passport.bilibili.com", 9 | default_headers = ( 10 | (header::USER_AGENT, USER_AGENT), 11 | (header::REFERER, REFERER), 12 | ), 13 | )] 14 | pub struct AuthApi; 15 | 16 | #[derive(Debug, ApiCaller)] 17 | #[api_req( 18 | base_url = "https://api.bilibili.com", 19 | default_headers = ( 20 | (header::USER_AGENT, USER_AGENT), 21 | (header::REFERER, REFERER), 22 | ), 23 | )] 24 | pub struct BiliApi; 25 | -------------------------------------------------------------------------------- /fav_bili/src/command.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::{Arg, ArgAction, Command, command, value_parser}; 3 | use clap_complete::Shell; 4 | use tracing_subscriber::EnvFilter; 5 | 6 | use crate::{action::*, version::VERSION}; 7 | 8 | #[derive(Debug, Default)] 9 | pub struct FavCommand(Command); 10 | 11 | impl FavCommand { 12 | pub fn new() -> Self { 13 | Self( 14 | command!() 15 | .arg_required_else_help(true) 16 | .version(VERSION) 17 | .subcommands([ 18 | Command::new("auth") 19 | .about("Auth account") 20 | .arg_required_else_help(true) 21 | .subcommands([ 22 | Command::new("login").about("Login with QR code"), 23 | Command::new("usecookies") 24 | .about("Add accounts with user-provided cookies (recommended)") 25 | .arg_required_else_help(true) 26 | .args([ 27 | Arg::new("cookies") 28 | .help( 29 | "Cookies at least including SESSDATA; For logout, plus DedeUserID, bili_jct; For liking medias, please copy directly from browser" 30 | ).action(ArgAction::Append) 31 | ]), 32 | Command::new("logout") 33 | .about("Logout accounts") 34 | .arg_required_else_help(true) 35 | .args([ 36 | Arg::new("all") 37 | .help("Logout all authorized accounts") 38 | .long("all") 39 | .short('a') 40 | .action(ArgAction::SetTrue) 41 | .conflicts_with("account_id"), 42 | Arg::new("account_id") 43 | .help("The account to logout") 44 | .value_parser(value_parser!(i64)) 45 | .action(ArgAction::Append), 46 | ]), 47 | Command::new("check") 48 | .about("Check accounts cookies usability") 49 | .arg_required_else_help(true) 50 | .args([ 51 | Arg::new("all") 52 | .help("Check all accounts cookies usability") 53 | .long("all") 54 | .short('a') 55 | .action(ArgAction::SetTrue) 56 | .conflicts_with("account_id"), 57 | Arg::new("account_id") 58 | .help("The account to check cookies usability") 59 | .value_parser(value_parser!(i64)) 60 | .action(ArgAction::Append), 61 | ]), 62 | ]), 63 | Command::new("list") 64 | .about("List accounts/sets/ups/medias [alias: ls, l]") 65 | .arg_required_else_help(true) 66 | .aliases(["ls", "l"]) 67 | .subcommands([ 68 | Command::new("account") 69 | .about("List accounts [alias: a]") 70 | .aliases(["a"]), 71 | Command::new("set") 72 | .about("List sets [alias: list, collection, s, l, c]") 73 | .aliases(["list", "collection", "s", "l", "c"]), 74 | Command::new("up") 75 | .about("List ups [alias: upper, u]") 76 | .aliases(["upper", "u"]), 77 | Command::new("media") 78 | .about("List medias [alias: video bv, m, v]") 79 | .aliases(["video", "bv", "m", "v"]), 80 | ]), 81 | Command::new("activate") 82 | .about("Activate obj [alias: active, a]") 83 | .arg_required_else_help(true) 84 | .aliases(["active", "a"]) 85 | .subcommands([ 86 | Command::new("account") 87 | .about("Activate accounts [alias: a]") 88 | .arg_required_else_help(true) 89 | .aliases(["a"]) 90 | .args([ 91 | Arg::new("all") 92 | .help("Activate all authorized accounts") 93 | .long("all") 94 | .short('a') 95 | .action(ArgAction::SetTrue) 96 | .conflicts_with("account_id"), 97 | Arg::new("account_id") 98 | .help("The account to activate") 99 | .value_parser(value_parser!(i64)) 100 | .action(ArgAction::Append), 101 | ]), 102 | Command::new("set") 103 | .about("Activate sets [alias: list, collection, s, l, c]") 104 | .arg_required_else_help(true) 105 | .aliases(["list", "collection", "s", "l", "c"]) 106 | .args([ 107 | Arg::new("all") 108 | .help("Activate all sets") 109 | .long("all") 110 | .short('a') 111 | .action(ArgAction::SetTrue) 112 | .conflicts_with("set_id"), 113 | Arg::new("set_id") 114 | .help("The set to activate") 115 | .value_parser(value_parser!(i64)) 116 | .action(ArgAction::Append), 117 | ]), 118 | Command::new("up") 119 | .about("Activate ups [alias: u]") 120 | .arg_required_else_help(true) 121 | .aliases(["u"]) 122 | .args([ 123 | Arg::new("all") 124 | .help("Activate all ups") 125 | .long("all") 126 | .short('a') 127 | .action(ArgAction::SetTrue) 128 | .conflicts_with("up_id"), 129 | Arg::new("up_id") 130 | .help("The up to activate") 131 | .value_parser(value_parser!(i64)) 132 | .action(ArgAction::Append), 133 | ]), 134 | ]), 135 | Command::new("deactivate") 136 | .about("Deactivate obj [alias: d]") 137 | .aliases(["d"]) 138 | .arg_required_else_help(true) 139 | .subcommands([ 140 | Command::new("account") 141 | .about("Deactivate accounts [alias: a]") 142 | .arg_required_else_help(true) 143 | .aliases(["a"]) 144 | .args([ 145 | Arg::new("all") 146 | .help("Dectivate all authorized accounts") 147 | .long("all") 148 | .short('a') 149 | .action(ArgAction::SetTrue) 150 | .conflicts_with("account_id"), 151 | Arg::new("account_id") 152 | .help("The account to deactivate") 153 | .value_parser(value_parser!(i64)) 154 | .action(ArgAction::Append), 155 | ]), 156 | Command::new("set") 157 | .about("Deactivate sets [alias: list, collection, s, l, c]") 158 | .arg_required_else_help(true) 159 | .aliases(["list", "collection", "s", "l", "c"]) 160 | .args([ 161 | Arg::new("all") 162 | .help("Deactivate all sets") 163 | .long("all") 164 | .short('a') 165 | .action(ArgAction::SetTrue) 166 | .conflicts_with("set_id"), 167 | Arg::new("set_id") 168 | .help("The set to deactivate") 169 | .value_parser(value_parser!(i64)) 170 | .action(ArgAction::Append), 171 | ]), 172 | Command::new("up") 173 | .about("Deactivate ups [alias: u]") 174 | .arg_required_else_help(true) 175 | .aliases(["u"]) 176 | .args([ 177 | Arg::new("all") 178 | .help("Deactivate all ups") 179 | .long("all") 180 | .short('a') 181 | .action(ArgAction::SetTrue) 182 | .conflicts_with("up_id"), 183 | Arg::new("up_id") 184 | .help("The up to deactivate") 185 | .value_parser(value_parser!(i64)) 186 | .action(ArgAction::Append), 187 | ]), 188 | ]), 189 | Command::new("fetch") 190 | .about("Fetch metadata of following ups, fav sets, medias, ups [alias: f]") 191 | .aliases(["f"]) 192 | .args([Arg::new("prune") 193 | .long("prune") 194 | .short('p') 195 | .help("Prune the objs: remove unfaved sets, unfollowed ups and medias not belonging to active set or up") 196 | .action(ArgAction::SetTrue)]), 197 | Command::new("pull") 198 | .about("Pull fetched medias [alias: p]") 199 | .aliases(["p"]), 200 | Command::new("like") 201 | .about("Like medias") 202 | .arg_required_else_help(true) 203 | .args([ 204 | Arg::new("avids") 205 | .help("The avids to like") 206 | .value_parser(value_parser!(i64)) 207 | .action(ArgAction::Append) 208 | ]), 209 | Command::new("completion") 210 | .about("Generate completion script") 211 | .arg_required_else_help(true) 212 | .args([Arg::new("shell") 213 | .help("The shell to generate completion script for") 214 | .value_parser(value_parser!(Shell))]), 215 | ]) 216 | .args([Arg::new("verbose") 217 | .help("Show debug messages") 218 | .long("verbose") 219 | .short('v') 220 | .action(ArgAction::SetTrue)]), 221 | ) 222 | } 223 | 224 | /// Parse the commands and args, return the Event to trigger. 225 | pub async fn run(mut self) -> Result<()> { 226 | let matches = self.0.get_matches_mut(); 227 | 228 | match matches.subcommand() { 229 | Some(("completion", sub_matches)) => { 230 | let bin_name = std::env::current_exe() 231 | .unwrap() 232 | .file_name() 233 | .unwrap() 234 | .to_string_lossy() 235 | .to_string(); 236 | let shell = *sub_matches.get_one::("shell").unwrap(); 237 | clap_complete::generate(shell, &mut self.0, bin_name, &mut std::io::stdout()); 238 | } 239 | sub_cmd => { 240 | match matches.get_flag("verbose") { 241 | true => { 242 | let filter = EnvFilter::from_default_env() 243 | .add_directive("fav=debug".parse().unwrap()); 244 | tracing_subscriber::fmt() 245 | .with_env_filter(filter) 246 | .with_thread_ids(true) 247 | .with_line_number(true) 248 | .init(); 249 | } 250 | false => { 251 | let filter = EnvFilter::from_default_env() 252 | .add_directive("fav=info".parse().unwrap()); 253 | tracing_subscriber::fmt() 254 | .with_target(false) 255 | .with_env_filter(filter) 256 | .without_time() 257 | .init(); 258 | } 259 | } 260 | 261 | match sub_cmd { 262 | Some(("auth", sub_matches)) => match sub_matches.subcommand() { 263 | Some(("login", _)) => login().await?, 264 | Some(("usecookies", sub_matches)) => { 265 | for cookies in sub_matches.get_many::("cookies").unwrap() 266 | // arg_required_else_help has been set to true 267 | { 268 | usecookies(cookies.to_owned()).await?; 269 | } 270 | } 271 | Some(("logout", sub_matches)) if sub_matches.get_flag("all") => { 272 | logout_all().await?; 273 | } 274 | Some(("logout", sub_matches)) => { 275 | for account_id in sub_matches.get_many::("account_id").unwrap() { 276 | logout(*account_id).await?; 277 | } 278 | } 279 | Some(("check", sub_matches)) if sub_matches.get_flag("all") => { 280 | check_all().await?; 281 | } 282 | Some(("check", sub_matches)) => { 283 | for account_id in sub_matches.get_many::("account_id").unwrap() { 284 | check(*account_id).await?; 285 | } 286 | } 287 | _ => unreachable!(), 288 | }, 289 | Some(("list", sub_matches)) => match sub_matches.subcommand() { 290 | Some(("account", _)) => list_accounts().await?, 291 | Some(("set", _)) => list_sets().await?, 292 | Some(("up", _)) => list_ups().await?, 293 | Some(("media", _)) => list_medias().await?, 294 | _ => unreachable!(), 295 | }, 296 | Some(("activate", sub_matches)) => match sub_matches.subcommand() { 297 | Some(("account", sub_matches)) => match sub_matches.get_flag("all") { 298 | true => activate_all_accounts().await?, 299 | false => { 300 | for account_id in sub_matches.get_many::("account_id").unwrap() 301 | // arg_required_else_help has been set to true 302 | { 303 | activate_account(*account_id).await?; 304 | } 305 | } 306 | }, 307 | Some(("set", sub_matches)) => match sub_matches.get_flag("all") { 308 | true => activate_all_sets().await?, 309 | false => { 310 | for set_id in sub_matches.get_many::("set_id").unwrap() 311 | // arg_required_else_help has been set to true 312 | { 313 | activate_set(*set_id).await?; 314 | } 315 | } 316 | }, 317 | Some(("up", sub_matches)) => match sub_matches.get_flag("all") { 318 | true => activate_all_ups().await?, 319 | false => { 320 | for up_id in sub_matches.get_many::("up_id").unwrap() 321 | // arg_required_else_help has been up to true 322 | { 323 | activate_up(*up_id).await?; 324 | } 325 | } 326 | }, 327 | _ => unreachable!(), 328 | }, 329 | Some(("deactivate", sub_matches)) => match sub_matches.subcommand() { 330 | Some(("account", sub_matches)) => match sub_matches.get_flag("all") { 331 | true => deactivate_all_accounts().await?, 332 | false => { 333 | for account_id in sub_matches.get_many::("account_id").unwrap() 334 | // arg_required_else_help has been set to true 335 | { 336 | deactivate_account(*account_id).await?; 337 | } 338 | } 339 | }, 340 | Some(("set", sub_matches)) => match sub_matches.get_flag("all") { 341 | true => deactivate_all_sets().await?, 342 | false => { 343 | for set_id in sub_matches.get_many::("set_id").unwrap() 344 | // arg_required_else_help has been set to true 345 | { 346 | deactivate_set(*set_id).await?; 347 | } 348 | } 349 | }, 350 | Some(("up", sub_matches)) => match sub_matches.get_flag("all") { 351 | true => deactivate_all_ups().await?, 352 | false => { 353 | for up_id in sub_matches.get_many::("up_id").unwrap() 354 | // arg_required_else_help has been up to true 355 | { 356 | deactivate_up(*up_id).await?; 357 | } 358 | } 359 | }, 360 | _ => unreachable!(), 361 | }, 362 | Some(("fetch", sub_matches)) => fetch(sub_matches.get_flag("prune")).await?, 363 | Some(("like", sub_matches)) => { 364 | like(sub_matches.get_many("avids").unwrap().copied().collect()).await? 365 | } 366 | Some(("pull", _)) => pull().await?, 367 | _ => unreachable!(), 368 | } 369 | } 370 | } 371 | Ok(()) 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /fav_bili/src/cookies.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context as _, Result}; 2 | use api_req::{COOKIE_JAR, CookieStore as _}; 3 | use cookie::Cookie; 4 | 5 | pub fn parse_cookies(cookies: &str) -> impl Iterator> { 6 | Cookie::split_parse_encoded(cookies).filter_map(|res| res.ok()) 7 | } 8 | 9 | /// Set `api_req::COOKIE_JAR` with cookies of account_id from db. 10 | pub fn add_cookie_jar<'a>(cookies: impl Iterator>) { 11 | cookies.into_iter().for_each(|mut c| { 12 | c.set_domain("bilibili.com"); 13 | COOKIE_JAR.add_cookie_str( 14 | &c.encoded().to_string(), 15 | &"https://bilibili.com".parse().unwrap(), 16 | ); 17 | }); 18 | } 19 | 20 | pub fn current_cookies() -> Result { 21 | Ok(COOKIE_JAR 22 | .cookies(&"https://bilibili.com".parse().unwrap()) 23 | .context("Auth related cookies should be set.")? 24 | .to_str()? 25 | .to_owned()) 26 | } 27 | -------------------------------------------------------------------------------- /fav_bili/src/db/account.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context as _, Result}; 2 | use sea_orm::{ 3 | ActiveValue::{Set, Unchanged}, 4 | EntityTrait as _, IntoActiveModel as _, QueryFilter as _, Value, 5 | sea_query::{OnConflict, SimpleExpr}, 6 | }; 7 | 8 | use super::Db; 9 | use crate::{entity::account, state::AccountState}; 10 | 11 | impl Db { 12 | pub async fn upsert_account(&self, account: account::Model) -> Result<()> { 13 | account::Entity::insert(account.into_active_model()) 14 | .on_conflict( 15 | OnConflict::column(account::Column::AccountId) 16 | .update_columns([account::Column::Name, account::Column::Cookies]) 17 | .to_owned(), 18 | ) 19 | .exec_without_returning(&self.db) 20 | .await?; 21 | Ok(()) 22 | } 23 | 24 | pub async fn get_account(&self, account_id: i64) -> Result { 25 | account::Entity::find_by_id(account_id) 26 | .one(&self.db) 27 | .await? 28 | .context(format!("Unknown account<{}>", account_id)) 29 | } 30 | 31 | pub async fn all_accounts(&self) -> Result> { 32 | account::Entity::find() 33 | .all(&self.db) 34 | .await 35 | .map_err(Into::into) 36 | } 37 | 38 | pub async fn get_accounts_filtered(&self, filter: SimpleExpr) -> Result> { 39 | account::Entity::find() 40 | .filter(filter) 41 | .all(&self.db) 42 | .await 43 | .map_err(Into::into) 44 | } 45 | 46 | pub async fn delete_account(&self, account_id: i64) -> Result<()> { 47 | account::Entity::delete_by_id(account_id) 48 | .exec(&self.db) 49 | .await?; 50 | Ok(()) 51 | } 52 | 53 | pub async fn activate_account(&self, account_id: i64) -> Result<()> { 54 | account::Entity::update(account::ActiveModel { 55 | account_id: Unchanged(account_id), 56 | state: Set(AccountState::Active.to_string()), 57 | ..Default::default() 58 | }) 59 | .exec(&self.db) 60 | .await?; 61 | Ok(()) 62 | } 63 | 64 | pub async fn activate_all_accounts(&self) -> Result<()> { 65 | account::Entity::update_many() 66 | .col_expr( 67 | account::Column::State, 68 | SimpleExpr::Value(Value::String(Some(Box::new( 69 | AccountState::Active.to_string(), 70 | )))), 71 | ) 72 | .exec(&self.db) 73 | .await?; 74 | Ok(()) 75 | } 76 | 77 | pub async fn deactivate_account(&self, account_id: i64) -> Result<()> { 78 | account::Entity::update(account::ActiveModel { 79 | account_id: Unchanged(account_id), 80 | state: Set(AccountState::Inactive.to_string()), 81 | ..Default::default() 82 | }) 83 | .exec(&self.db) 84 | .await?; 85 | Ok(()) 86 | } 87 | 88 | pub async fn deactivate_all_accounts(&self) -> Result<()> { 89 | account::Entity::update_many() 90 | .col_expr( 91 | account::Column::State, 92 | SimpleExpr::Value(Value::String(Some(Box::new( 93 | AccountState::Inactive.to_string(), 94 | )))), 95 | ) 96 | .exec(&self.db) 97 | .await?; 98 | Ok(()) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /fav_bili/src/db/media.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use sea_orm::{ 3 | ActiveValue::{Set, Unchanged}, 4 | ConnectionTrait, DatabaseBackend, EntityTrait as _, IntoActiveModel as _, Statement, 5 | sea_query::OnConflict, 6 | }; 7 | 8 | use super::Db; 9 | use crate::{entity::media, state::MediaState}; 10 | 11 | impl Db { 12 | pub async fn upsert_medias( 13 | &self, 14 | medias: impl IntoIterator, 15 | ) -> Result<()> { 16 | media::Entity::insert_many(medias.into_iter().map(|m| m.into_active_model())) 17 | .on_conflict( 18 | OnConflict::column(media::Column::BvId) 19 | .update_columns([media::Column::Title, media::Column::Id, media::Column::Type]) 20 | .to_owned(), 21 | ) 22 | .exec_without_returning(&self.db) 23 | .await?; 24 | Ok(()) 25 | } 26 | 27 | pub async fn set_media_state(&self, id: i64, state: MediaState) -> Result<()> { 28 | media::Entity::update(media::ActiveModel { 29 | id: Unchanged(id), 30 | state: Set(state.to_string()), 31 | ..Default::default() 32 | }) 33 | .exec(&self.db) 34 | .await?; 35 | Ok(()) 36 | } 37 | 38 | pub async fn all_medias(&self) -> Result> { 39 | media::Entity::find() 40 | .all(&self.db) 41 | .await 42 | .map_err(Into::into) 43 | } 44 | 45 | pub async fn all_active_medias(&self) -> Result> { 46 | media::Entity::find() 47 | .from_raw_sql(Statement::from_string( 48 | DatabaseBackend::Sqlite, 49 | r#" 50 | SELECT DISTINCT m.* 51 | FROM media m 52 | AND ( 53 | EXISTS ( 54 | SELECT 1 55 | FROM media_up mu 56 | JOIN up u ON mu.up_id = u.up_id 57 | WHERE mu.id = m.id AND u.state = 'Active' 58 | ) 59 | OR EXISTS ( 60 | SELECT 1 61 | FROM media_set ms 62 | JOIN "set" s ON ms.set_id = s.set_id 63 | WHERE ms.id = m.id AND s.state = 'Active' 64 | ) 65 | ); 66 | "#, 67 | )) 68 | .all(&self.db) 69 | .await 70 | .map_err(Into::into) 71 | } 72 | 73 | pub async fn all_active_pending_medias(&self) -> Result> { 74 | media::Entity::find() 75 | .from_raw_sql(Statement::from_string( 76 | DatabaseBackend::Sqlite, 77 | r#" 78 | SELECT DISTINCT m.* 79 | FROM media m 80 | WHERE 81 | m.state = 'Pending' 82 | AND ( 83 | EXISTS ( 84 | SELECT 1 85 | FROM media_up mu 86 | JOIN up u ON mu.up_id = u.up_id 87 | WHERE mu.id = m.id AND u.state = 'Active' 88 | ) 89 | OR EXISTS ( 90 | SELECT 1 91 | FROM media_set ms 92 | JOIN "set" s ON ms.set_id = s.set_id 93 | WHERE ms.id = m.id AND s.state = 'Active' 94 | ) 95 | ); 96 | "#, 97 | )) 98 | .all(&self.db) 99 | .await 100 | .map_err(Into::into) 101 | } 102 | 103 | /// Cleanup the medias whose up and set both are inactive/null 104 | pub async fn prune_medias(&self) -> Result<()> { 105 | self.db 106 | .execute(Statement::from_string( 107 | DatabaseBackend::Sqlite, 108 | r#" 109 | DELETE FROM media 110 | WHERE id IN ( 111 | SELECT m.id 112 | FROM media m 113 | WHERE NOT EXISTS ( 114 | SELECT 1 FROM media_up mu 115 | JOIN up u ON mu.up_id = u.up_id 116 | WHERE mu.id = m.id AND u.state != 'Inactive' 117 | ) 118 | AND NOT EXISTS ( 119 | SELECT 1 FROM media_set ms 120 | JOIN "set" s ON ms.set_id = s.set_id 121 | WHERE ms.id = m.id AND s.state != 'Inactive' 122 | ) 123 | ); 124 | "#, 125 | )) 126 | .await?; 127 | Ok(()) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /fav_bili/src/db/media_set.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use sea_orm::{EntityTrait as _, IntoActiveModel as _}; 3 | 4 | use super::Db; 5 | use crate::entity::media_set; 6 | 7 | impl Db { 8 | pub async fn upsert_media_sets( 9 | &self, 10 | media_sets: impl IntoIterator, 11 | ) -> Result<()> { 12 | media_set::Entity::insert_many(media_sets.into_iter().map(|m| m.into_active_model())) 13 | .on_conflict_do_nothing() 14 | .exec_without_returning(&self.db) 15 | .await?; 16 | Ok(()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /fav_bili/src/db/media_up.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use sea_orm::{EntityTrait as _, IntoActiveModel as _}; 3 | 4 | use super::Db; 5 | use crate::entity::media_up; 6 | 7 | impl Db { 8 | pub async fn upsert_media_ups( 9 | &self, 10 | media_ups: impl IntoIterator, 11 | ) -> Result<()> { 12 | media_up::Entity::insert_many(media_ups.into_iter().map(|m| m.into_active_model())) 13 | .on_conflict_do_nothing() 14 | .exec_without_returning(&self.db) 15 | .await?; 16 | Ok(()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /fav_bili/src/db/mod.rs: -------------------------------------------------------------------------------- 1 | mod account; 2 | mod media; 3 | mod media_set; 4 | mod media_up; 5 | mod set; 6 | mod set_account; 7 | mod up; 8 | mod up_account; 9 | 10 | use std::process::exit; 11 | 12 | use anyhow::{Context, Result}; 13 | use sea_orm::{Database, DatabaseConnection}; 14 | use sea_orm_migration::MigratorTrait as _; 15 | use tokio::sync::OnceCell; 16 | use tracing::error; 17 | 18 | use crate::migration::Migrator; 19 | 20 | static DB: OnceCell = OnceCell::const_new(); 21 | 22 | pub async fn db() -> &'static Db { 23 | DB.get_or_init(async || match Db::connect().await { 24 | Ok(db) => db, 25 | Err(e) => { 26 | error!("{}", e); 27 | exit(-1) 28 | } 29 | }) 30 | .await 31 | } 32 | 33 | #[derive(Debug, Clone)] 34 | pub struct Db { 35 | db: DatabaseConnection, 36 | } 37 | 38 | impl Db { 39 | pub async fn connect() -> Result { 40 | std::fs::create_dir_all(".fav").context("Failed to create .fav dir")?; 41 | let db = Database::connect("sqlite://.fav/fav.db?mode=rwc") 42 | .await 43 | .context("Failed to connect db")?; 44 | Migrator::up(&db, None) 45 | .await 46 | .context("Failed to update db tables")?; 47 | Ok(Self { db }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /fav_bili/src/db/set.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context as _, Result}; 2 | use sea_orm::{ 3 | ActiveValue::{Set, Unchanged}, 4 | ConnectionTrait as _, DatabaseBackend, EntityTrait as _, IntoActiveModel as _, Statement, 5 | Value, 6 | sea_query::{OnConflict, SimpleExpr}, 7 | }; 8 | 9 | use super::Db; 10 | use crate::{entity::set, state::SetState}; 11 | 12 | impl Db { 13 | pub async fn upsert_sets(&self, sets: impl IntoIterator) -> Result<()> { 14 | set::Entity::insert_many(sets.into_iter().map(|s| s.into_active_model())) 15 | .on_conflict( 16 | OnConflict::column(set::Column::SetId) 17 | .update_columns([set::Column::Name, set::Column::Count]) 18 | .to_owned(), 19 | ) 20 | .exec_without_returning(&self.db) 21 | .await?; 22 | Ok(()) 23 | } 24 | 25 | pub async fn get_set(&self, set_id: i64) -> Result { 26 | set::Entity::find_by_id(set_id) 27 | .one(&self.db) 28 | .await? 29 | .context(format!("Unknown set<{}>", set_id)) 30 | } 31 | 32 | pub async fn all_sets(&self) -> Result> { 33 | set::Entity::find().all(&self.db).await.map_err(Into::into) 34 | } 35 | 36 | pub async fn activate_set(&self, set_id: i64) -> Result<()> { 37 | set::Entity::update(set::ActiveModel { 38 | set_id: Unchanged(set_id), 39 | state: Set(SetState::Active.to_string()), 40 | ..Default::default() 41 | }) 42 | .exec(&self.db) 43 | .await?; 44 | Ok(()) 45 | } 46 | 47 | pub async fn activate_all_sets(&self) -> Result<()> { 48 | set::Entity::update_many() 49 | .col_expr( 50 | set::Column::State, 51 | SimpleExpr::Value(Value::String(Some(Box::new(SetState::Active.to_string())))), 52 | ) 53 | .exec(&self.db) 54 | .await?; 55 | Ok(()) 56 | } 57 | 58 | pub async fn deactivate_set(&self, set_id: i64) -> Result<()> { 59 | set::Entity::update(set::ActiveModel { 60 | set_id: Unchanged(set_id), 61 | state: Set(SetState::Inactive.to_string()), 62 | ..Default::default() 63 | }) 64 | .exec(&self.db) 65 | .await?; 66 | Ok(()) 67 | } 68 | 69 | pub async fn deactivate_all_sets(&self) -> Result<()> { 70 | set::Entity::update_many() 71 | .col_expr( 72 | set::Column::State, 73 | SimpleExpr::Value(Value::String(Some(Box::new( 74 | SetState::Inactive.to_string(), 75 | )))), 76 | ) 77 | .exec(&self.db) 78 | .await?; 79 | Ok(()) 80 | } 81 | 82 | /// Cleanup the sets belonging to no account 83 | pub async fn prune_sets(&self) -> Result<()> { 84 | self.db 85 | .execute(Statement::from_string( 86 | DatabaseBackend::Sqlite, 87 | r#" 88 | DELETE FROM "set" 89 | WHERE set_id IN ( 90 | SELECT s.set_id 91 | FROM "set" s 92 | WHERE NOT EXISTS ( 93 | SELECT 1 FROM set_account sa 94 | JOIN account a ON sa.account_id = a.account_id 95 | WHERE sa.set_id = s.set_id 96 | ) 97 | ); 98 | "#, 99 | )) 100 | .await?; 101 | Ok(()) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /fav_bili/src/db/set_account.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use sea_orm::{ColumnTrait as _, EntityTrait as _, IntoActiveModel as _, QueryFilter as _}; 3 | 4 | use super::Db; 5 | use crate::entity::set_account; 6 | 7 | impl Db { 8 | pub async fn upsert_set_accounts( 9 | &self, 10 | set_accounts: impl IntoIterator, 11 | ) -> Result<()> { 12 | set_account::Entity::insert_many(set_accounts.into_iter().map(|m| m.into_active_model())) 13 | .on_conflict_do_nothing() 14 | .exec_without_returning(&self.db) 15 | .await?; 16 | Ok(()) 17 | } 18 | 19 | pub async fn get_set_ids_of_account(&self, account_id: i64) -> Result> { 20 | set_account::Entity::find() 21 | .filter(set_account::Column::AccountId.eq(account_id)) 22 | .all(&self.db) 23 | .await 24 | .map_err(Into::into) 25 | .map(|res| res.into_iter().map(|m| m.set_id).collect()) 26 | } 27 | 28 | pub async fn delete_set_account(&self, set_account: set_account::Model) -> Result<()> { 29 | set_account::Entity::delete_by_id((set_account.set_id, set_account.account_id)) 30 | .exec(&self.db) 31 | .await?; 32 | Ok(()) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /fav_bili/src/db/up.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context as _, Result}; 2 | use sea_orm::{ 3 | ActiveValue::{Set, Unchanged}, 4 | ConnectionTrait as _, EntityTrait as _, IntoActiveModel as _, Value, 5 | sea_query::{OnConflict, SimpleExpr}, 6 | }; 7 | use sea_orm::{DatabaseBackend, Statement}; 8 | 9 | use super::Db; 10 | use crate::entity::up; 11 | use crate::state::UpState; 12 | 13 | impl Db { 14 | pub async fn upsert_ups(&self, ups: impl IntoIterator) -> Result<()> { 15 | up::Entity::insert_many(ups.into_iter().map(|s| s.into_active_model())) 16 | .on_conflict( 17 | OnConflict::column(up::Column::UpId) 18 | .update_columns([up::Column::Name]) 19 | .to_owned(), 20 | ) 21 | .exec_without_returning(&self.db) 22 | .await?; 23 | Ok(()) 24 | } 25 | 26 | pub async fn get_up(&self, up_id: i64) -> Result { 27 | up::Entity::find_by_id(up_id) 28 | .one(&self.db) 29 | .await? 30 | .context(format!("Unknown up<{}>", up_id)) 31 | } 32 | 33 | pub async fn all_ups(&self) -> Result> { 34 | up::Entity::find().all(&self.db).await.map_err(Into::into) 35 | } 36 | 37 | pub async fn activate_up(&self, up_id: i64) -> Result<()> { 38 | up::Entity::update(up::ActiveModel { 39 | up_id: Unchanged(up_id), 40 | state: Set(UpState::Active.to_string()), 41 | ..Default::default() 42 | }) 43 | .exec(&self.db) 44 | .await?; 45 | Ok(()) 46 | } 47 | 48 | pub async fn activate_all_ups(&self) -> Result<()> { 49 | up::Entity::update_many() 50 | .col_expr( 51 | up::Column::State, 52 | SimpleExpr::Value(Value::String(Some(Box::new(UpState::Active.to_string())))), 53 | ) 54 | .exec(&self.db) 55 | .await?; 56 | Ok(()) 57 | } 58 | 59 | pub async fn deactivate_up(&self, up_id: i64) -> Result<()> { 60 | up::Entity::update(up::ActiveModel { 61 | up_id: Unchanged(up_id), 62 | state: Set(UpState::Inactive.to_string()), 63 | ..Default::default() 64 | }) 65 | .exec(&self.db) 66 | .await?; 67 | Ok(()) 68 | } 69 | 70 | pub async fn deactivate_all_ups(&self) -> Result<()> { 71 | up::Entity::update_many() 72 | .col_expr( 73 | up::Column::State, 74 | SimpleExpr::Value(Value::String(Some(Box::new(UpState::Inactive.to_string())))), 75 | ) 76 | .exec(&self.db) 77 | .await?; 78 | Ok(()) 79 | } 80 | 81 | /// Cleanup the ups followd by no account 82 | pub async fn prune_ups(&self) -> Result<()> { 83 | self.db 84 | .execute(Statement::from_string( 85 | DatabaseBackend::Sqlite, 86 | r#" 87 | DELETE FROM up 88 | WHERE up_id IN ( 89 | SELECT up_id 90 | FROM up u 91 | WHERE NOT EXISTS ( 92 | SELECT 1 FROM up_account ua 93 | JOIN account a ON ua.account_id = a.account_id 94 | WHERE ua.up_id = u.up_id 95 | ) 96 | AND NOT EXISTS ( 97 | SELECT 1 FROM media_up mu 98 | JOIN media m ON mu.id = m.id 99 | WHERE mu.up_id = u.up_id 100 | ) 101 | ); 102 | "#, 103 | )) 104 | .await?; 105 | Ok(()) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /fav_bili/src/db/up_account.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use sea_orm::{ColumnTrait as _, EntityTrait as _, IntoActiveModel as _, QueryFilter as _}; 3 | 4 | use super::Db; 5 | use crate::entity::up_account; 6 | 7 | impl Db { 8 | pub async fn upsert_up_accounts( 9 | &self, 10 | up_accounts: impl IntoIterator, 11 | ) -> Result<()> { 12 | up_account::Entity::insert_many(up_accounts.into_iter().map(|m| m.into_active_model())) 13 | .on_conflict_do_nothing() 14 | .exec_without_returning(&self.db) 15 | .await?; 16 | Ok(()) 17 | } 18 | 19 | pub async fn get_up_ids_of_account(&self, account_id: i64) -> Result> { 20 | up_account::Entity::find() 21 | .filter(up_account::Column::AccountId.eq(account_id)) 22 | .all(&self.db) 23 | .await 24 | .map_err(Into::into) 25 | .map(|res| res.into_iter().map(|m| m.up_id).collect()) 26 | } 27 | 28 | pub async fn delete_up_account(&self, up_account: up_account::Model) -> Result<()> { 29 | up_account::Entity::delete_by_id((up_account.up_id, up_account.account_id)) 30 | .exec(&self.db) 31 | .await?; 32 | Ok(()) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /fav_bili/src/entity/entity_inner/account.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Copy, Clone, Default, Debug, DeriveEntity)] 6 | pub struct Entity; 7 | 8 | impl EntityName for Entity { 9 | fn table_name(&self) -> &str { 10 | "account" 11 | } 12 | } 13 | 14 | #[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] 15 | pub struct Model { 16 | pub account_id: i64, 17 | pub name: String, 18 | pub cookies: String, 19 | pub state: String, 20 | } 21 | 22 | #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] 23 | pub enum Column { 24 | AccountId, 25 | Name, 26 | Cookies, 27 | State, 28 | } 29 | 30 | #[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] 31 | pub enum PrimaryKey { 32 | AccountId, 33 | } 34 | 35 | impl PrimaryKeyTrait for PrimaryKey { 36 | type ValueType = i64; 37 | fn auto_increment() -> bool { 38 | false 39 | } 40 | } 41 | 42 | #[derive(Copy, Clone, Debug, EnumIter)] 43 | pub enum Relation { 44 | SetAccount, 45 | UpAccount, 46 | } 47 | 48 | impl ColumnTrait for Column { 49 | type EntityName = Entity; 50 | fn def(&self) -> ColumnDef { 51 | match self { 52 | Self::AccountId => ColumnType::BigInteger.def(), 53 | Self::Name => ColumnType::String(StringLen::None).def(), 54 | Self::Cookies => ColumnType::String(StringLen::None).def(), 55 | Self::State => ColumnType::custom("enum_text").def(), 56 | } 57 | } 58 | } 59 | 60 | impl RelationTrait for Relation { 61 | fn def(&self) -> RelationDef { 62 | match self { 63 | Self::SetAccount => Entity::has_many(super::set_account::Entity).into(), 64 | Self::UpAccount => Entity::has_many(super::up_account::Entity).into(), 65 | } 66 | } 67 | } 68 | 69 | impl Related for Entity { 70 | fn to() -> RelationDef { 71 | Relation::SetAccount.def() 72 | } 73 | } 74 | 75 | impl Related for Entity { 76 | fn to() -> RelationDef { 77 | Relation::UpAccount.def() 78 | } 79 | } 80 | 81 | impl Related for Entity { 82 | fn to() -> RelationDef { 83 | super::set_account::Relation::Set.def() 84 | } 85 | fn via() -> Option { 86 | Some(super::set_account::Relation::Account.def().rev()) 87 | } 88 | } 89 | 90 | impl Related for Entity { 91 | fn to() -> RelationDef { 92 | super::up_account::Relation::Up.def() 93 | } 94 | fn via() -> Option { 95 | Some(super::up_account::Relation::Account.def().rev()) 96 | } 97 | } 98 | 99 | impl ActiveModelBehavior for ActiveModel {} 100 | -------------------------------------------------------------------------------- /fav_bili/src/entity/entity_inner/media.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Copy, Clone, Default, Debug, DeriveEntity)] 6 | pub struct Entity; 7 | 8 | impl EntityName for Entity { 9 | fn table_name(&self) -> &str { 10 | "media" 11 | } 12 | } 13 | 14 | #[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] 15 | pub struct Model { 16 | pub id: i64, 17 | pub bv_id: String, 18 | pub title: String, 19 | pub r#type: String, 20 | pub state: String, 21 | } 22 | 23 | #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] 24 | pub enum Column { 25 | Id, 26 | BvId, 27 | Title, 28 | Type, 29 | State, 30 | } 31 | 32 | #[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] 33 | pub enum PrimaryKey { 34 | Id, 35 | } 36 | 37 | impl PrimaryKeyTrait for PrimaryKey { 38 | type ValueType = i64; 39 | fn auto_increment() -> bool { 40 | false 41 | } 42 | } 43 | 44 | #[derive(Copy, Clone, Debug, EnumIter)] 45 | pub enum Relation { 46 | MediaSet, 47 | MediaUp, 48 | } 49 | 50 | impl ColumnTrait for Column { 51 | type EntityName = Entity; 52 | fn def(&self) -> ColumnDef { 53 | match self { 54 | Self::Id => ColumnType::BigInteger.def(), 55 | Self::BvId => ColumnType::String(StringLen::None).def().unique(), 56 | Self::Title => ColumnType::String(StringLen::None).def(), 57 | Self::Type => ColumnType::custom("enum_text").def(), 58 | Self::State => ColumnType::custom("enum_text").def(), 59 | } 60 | } 61 | } 62 | 63 | impl RelationTrait for Relation { 64 | fn def(&self) -> RelationDef { 65 | match self { 66 | Self::MediaSet => Entity::has_many(super::media_set::Entity).into(), 67 | Self::MediaUp => Entity::has_many(super::media_up::Entity).into(), 68 | } 69 | } 70 | } 71 | 72 | impl Related for Entity { 73 | fn to() -> RelationDef { 74 | Relation::MediaSet.def() 75 | } 76 | } 77 | 78 | impl Related for Entity { 79 | fn to() -> RelationDef { 80 | Relation::MediaUp.def() 81 | } 82 | } 83 | 84 | impl Related for Entity { 85 | fn to() -> RelationDef { 86 | super::media_set::Relation::Set.def() 87 | } 88 | fn via() -> Option { 89 | Some(super::media_set::Relation::Media.def().rev()) 90 | } 91 | } 92 | 93 | impl Related for Entity { 94 | fn to() -> RelationDef { 95 | super::media_up::Relation::Up.def() 96 | } 97 | fn via() -> Option { 98 | Some(super::media_up::Relation::Media.def().rev()) 99 | } 100 | } 101 | 102 | impl ActiveModelBehavior for ActiveModel {} 103 | -------------------------------------------------------------------------------- /fav_bili/src/entity/entity_inner/media_set.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Copy, Clone, Default, Debug, DeriveEntity)] 6 | pub struct Entity; 7 | 8 | impl EntityName for Entity { 9 | fn table_name(&self) -> &str { 10 | "media_set" 11 | } 12 | } 13 | 14 | #[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] 15 | pub struct Model { 16 | pub id: i64, 17 | pub set_id: i64, 18 | } 19 | 20 | #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] 21 | pub enum Column { 22 | Id, 23 | SetId, 24 | } 25 | 26 | #[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] 27 | pub enum PrimaryKey { 28 | Id, 29 | SetId, 30 | } 31 | 32 | impl PrimaryKeyTrait for PrimaryKey { 33 | type ValueType = (i64, i64); 34 | fn auto_increment() -> bool { 35 | false 36 | } 37 | } 38 | 39 | #[derive(Copy, Clone, Debug, EnumIter)] 40 | pub enum Relation { 41 | Media, 42 | Set, 43 | } 44 | 45 | impl ColumnTrait for Column { 46 | type EntityName = Entity; 47 | fn def(&self) -> ColumnDef { 48 | match self { 49 | Self::Id => ColumnType::BigInteger.def(), 50 | Self::SetId => ColumnType::BigInteger.def(), 51 | } 52 | } 53 | } 54 | 55 | impl RelationTrait for Relation { 56 | fn def(&self) -> RelationDef { 57 | match self { 58 | Self::Media => Entity::belongs_to(super::media::Entity) 59 | .from(Column::Id) 60 | .to(super::media::Column::Id) 61 | .into(), 62 | Self::Set => Entity::belongs_to(super::set::Entity) 63 | .from(Column::SetId) 64 | .to(super::set::Column::SetId) 65 | .into(), 66 | } 67 | } 68 | } 69 | 70 | impl Related for Entity { 71 | fn to() -> RelationDef { 72 | Relation::Media.def() 73 | } 74 | } 75 | 76 | impl Related for Entity { 77 | fn to() -> RelationDef { 78 | Relation::Set.def() 79 | } 80 | } 81 | 82 | impl ActiveModelBehavior for ActiveModel {} 83 | -------------------------------------------------------------------------------- /fav_bili/src/entity/entity_inner/media_up.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Copy, Clone, Default, Debug, DeriveEntity)] 6 | pub struct Entity; 7 | 8 | impl EntityName for Entity { 9 | fn table_name(&self) -> &str { 10 | "media_up" 11 | } 12 | } 13 | 14 | #[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] 15 | pub struct Model { 16 | pub id: i64, 17 | pub up_id: i64, 18 | } 19 | 20 | #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] 21 | pub enum Column { 22 | Id, 23 | UpId, 24 | } 25 | 26 | #[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] 27 | pub enum PrimaryKey { 28 | Id, 29 | UpId, 30 | } 31 | 32 | impl PrimaryKeyTrait for PrimaryKey { 33 | type ValueType = (i64, i64); 34 | fn auto_increment() -> bool { 35 | false 36 | } 37 | } 38 | 39 | #[derive(Copy, Clone, Debug, EnumIter)] 40 | pub enum Relation { 41 | Media, 42 | Up, 43 | } 44 | 45 | impl ColumnTrait for Column { 46 | type EntityName = Entity; 47 | fn def(&self) -> ColumnDef { 48 | match self { 49 | Self::Id => ColumnType::BigInteger.def(), 50 | Self::UpId => ColumnType::BigInteger.def(), 51 | } 52 | } 53 | } 54 | 55 | impl RelationTrait for Relation { 56 | fn def(&self) -> RelationDef { 57 | match self { 58 | Self::Media => Entity::belongs_to(super::media::Entity) 59 | .from(Column::Id) 60 | .to(super::media::Column::Id) 61 | .into(), 62 | Self::Up => Entity::belongs_to(super::up::Entity) 63 | .from(Column::UpId) 64 | .to(super::up::Column::UpId) 65 | .into(), 66 | } 67 | } 68 | } 69 | 70 | impl Related for Entity { 71 | fn to() -> RelationDef { 72 | Relation::Media.def() 73 | } 74 | } 75 | 76 | impl Related for Entity { 77 | fn to() -> RelationDef { 78 | Relation::Up.def() 79 | } 80 | } 81 | 82 | impl ActiveModelBehavior for ActiveModel {} 83 | -------------------------------------------------------------------------------- /fav_bili/src/entity/entity_inner/mod.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12 2 | 3 | pub mod prelude; 4 | 5 | pub mod account; 6 | pub mod media; 7 | pub mod media_set; 8 | pub mod media_up; 9 | pub mod set; 10 | pub mod set_account; 11 | pub mod up; 12 | pub mod up_account; 13 | -------------------------------------------------------------------------------- /fav_bili/src/entity/entity_inner/prelude.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12 2 | 3 | pub use super::account::Entity as Account; 4 | pub use super::media::Entity as Media; 5 | pub use super::media_set::Entity as MediaSet; 6 | pub use super::media_up::Entity as MediaUp; 7 | pub use super::set::Entity as Set; 8 | pub use super::set_account::Entity as SetAccount; 9 | pub use super::up::Entity as Up; 10 | pub use super::up_account::Entity as UpAccount; 11 | -------------------------------------------------------------------------------- /fav_bili/src/entity/entity_inner/set.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Copy, Clone, Default, Debug, DeriveEntity)] 6 | pub struct Entity; 7 | 8 | impl EntityName for Entity { 9 | fn table_name(&self) -> &str { 10 | "set" 11 | } 12 | } 13 | 14 | #[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] 15 | pub struct Model { 16 | pub set_id: i64, 17 | pub name: String, 18 | pub count: i64, 19 | pub state: String, 20 | } 21 | 22 | #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] 23 | pub enum Column { 24 | SetId, 25 | Name, 26 | Count, 27 | State, 28 | } 29 | 30 | #[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] 31 | pub enum PrimaryKey { 32 | SetId, 33 | } 34 | 35 | impl PrimaryKeyTrait for PrimaryKey { 36 | type ValueType = i64; 37 | fn auto_increment() -> bool { 38 | false 39 | } 40 | } 41 | 42 | #[derive(Copy, Clone, Debug, EnumIter)] 43 | pub enum Relation { 44 | MediaSet, 45 | SetAccount, 46 | } 47 | 48 | impl ColumnTrait for Column { 49 | type EntityName = Entity; 50 | fn def(&self) -> ColumnDef { 51 | match self { 52 | Self::SetId => ColumnType::BigInteger.def(), 53 | Self::Name => ColumnType::String(StringLen::None).def(), 54 | Self::Count => ColumnType::BigInteger.def(), 55 | Self::State => ColumnType::custom("enum_text").def(), 56 | } 57 | } 58 | } 59 | 60 | impl RelationTrait for Relation { 61 | fn def(&self) -> RelationDef { 62 | match self { 63 | Self::MediaSet => Entity::has_many(super::media_set::Entity).into(), 64 | Self::SetAccount => Entity::has_many(super::set_account::Entity).into(), 65 | } 66 | } 67 | } 68 | 69 | impl Related for Entity { 70 | fn to() -> RelationDef { 71 | Relation::MediaSet.def() 72 | } 73 | } 74 | 75 | impl Related for Entity { 76 | fn to() -> RelationDef { 77 | Relation::SetAccount.def() 78 | } 79 | } 80 | 81 | impl Related for Entity { 82 | fn to() -> RelationDef { 83 | super::set_account::Relation::Account.def() 84 | } 85 | fn via() -> Option { 86 | Some(super::set_account::Relation::Set.def().rev()) 87 | } 88 | } 89 | 90 | impl Related for Entity { 91 | fn to() -> RelationDef { 92 | super::media_set::Relation::Media.def() 93 | } 94 | fn via() -> Option { 95 | Some(super::media_set::Relation::Set.def().rev()) 96 | } 97 | } 98 | 99 | impl ActiveModelBehavior for ActiveModel {} 100 | -------------------------------------------------------------------------------- /fav_bili/src/entity/entity_inner/set_account.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Copy, Clone, Default, Debug, DeriveEntity)] 6 | pub struct Entity; 7 | 8 | impl EntityName for Entity { 9 | fn table_name(&self) -> &str { 10 | "set_account" 11 | } 12 | } 13 | 14 | #[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] 15 | pub struct Model { 16 | pub set_id: i64, 17 | pub account_id: i64, 18 | } 19 | 20 | #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] 21 | pub enum Column { 22 | SetId, 23 | AccountId, 24 | } 25 | 26 | #[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] 27 | pub enum PrimaryKey { 28 | SetId, 29 | AccountId, 30 | } 31 | 32 | impl PrimaryKeyTrait for PrimaryKey { 33 | type ValueType = (i64, i64); 34 | fn auto_increment() -> bool { 35 | false 36 | } 37 | } 38 | 39 | #[derive(Copy, Clone, Debug, EnumIter)] 40 | pub enum Relation { 41 | Account, 42 | Set, 43 | } 44 | 45 | impl ColumnTrait for Column { 46 | type EntityName = Entity; 47 | fn def(&self) -> ColumnDef { 48 | match self { 49 | Self::SetId => ColumnType::BigInteger.def(), 50 | Self::AccountId => ColumnType::BigInteger.def(), 51 | } 52 | } 53 | } 54 | 55 | impl RelationTrait for Relation { 56 | fn def(&self) -> RelationDef { 57 | match self { 58 | Self::Account => Entity::belongs_to(super::account::Entity) 59 | .from(Column::AccountId) 60 | .to(super::account::Column::AccountId) 61 | .into(), 62 | Self::Set => Entity::belongs_to(super::set::Entity) 63 | .from(Column::SetId) 64 | .to(super::set::Column::SetId) 65 | .into(), 66 | } 67 | } 68 | } 69 | 70 | impl Related for Entity { 71 | fn to() -> RelationDef { 72 | Relation::Account.def() 73 | } 74 | } 75 | 76 | impl Related for Entity { 77 | fn to() -> RelationDef { 78 | Relation::Set.def() 79 | } 80 | } 81 | 82 | impl ActiveModelBehavior for ActiveModel {} 83 | -------------------------------------------------------------------------------- /fav_bili/src/entity/entity_inner/up.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Copy, Clone, Default, Debug, DeriveEntity)] 6 | pub struct Entity; 7 | 8 | impl EntityName for Entity { 9 | fn table_name(&self) -> &str { 10 | "up" 11 | } 12 | } 13 | 14 | #[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] 15 | pub struct Model { 16 | pub up_id: i64, 17 | pub name: String, 18 | pub state: String, 19 | } 20 | 21 | #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] 22 | pub enum Column { 23 | UpId, 24 | Name, 25 | State, 26 | } 27 | 28 | #[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] 29 | pub enum PrimaryKey { 30 | UpId, 31 | } 32 | 33 | impl PrimaryKeyTrait for PrimaryKey { 34 | type ValueType = i64; 35 | fn auto_increment() -> bool { 36 | false 37 | } 38 | } 39 | 40 | #[derive(Copy, Clone, Debug, EnumIter)] 41 | pub enum Relation { 42 | MediaUp, 43 | UpAccount, 44 | } 45 | 46 | impl ColumnTrait for Column { 47 | type EntityName = Entity; 48 | fn def(&self) -> ColumnDef { 49 | match self { 50 | Self::UpId => ColumnType::BigInteger.def(), 51 | Self::Name => ColumnType::String(StringLen::None).def(), 52 | Self::State => ColumnType::custom("enum_text").def(), 53 | } 54 | } 55 | } 56 | 57 | impl RelationTrait for Relation { 58 | fn def(&self) -> RelationDef { 59 | match self { 60 | Self::MediaUp => Entity::has_many(super::media_up::Entity).into(), 61 | Self::UpAccount => Entity::has_many(super::up_account::Entity).into(), 62 | } 63 | } 64 | } 65 | 66 | impl Related for Entity { 67 | fn to() -> RelationDef { 68 | Relation::MediaUp.def() 69 | } 70 | } 71 | 72 | impl Related for Entity { 73 | fn to() -> RelationDef { 74 | Relation::UpAccount.def() 75 | } 76 | } 77 | 78 | impl Related for Entity { 79 | fn to() -> RelationDef { 80 | super::up_account::Relation::Account.def() 81 | } 82 | fn via() -> Option { 83 | Some(super::up_account::Relation::Up.def().rev()) 84 | } 85 | } 86 | 87 | impl Related for Entity { 88 | fn to() -> RelationDef { 89 | super::media_up::Relation::Media.def() 90 | } 91 | fn via() -> Option { 92 | Some(super::media_up::Relation::Up.def().rev()) 93 | } 94 | } 95 | 96 | impl ActiveModelBehavior for ActiveModel {} 97 | -------------------------------------------------------------------------------- /fav_bili/src/entity/entity_inner/up_account.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12 2 | 3 | use sea_orm::entity::prelude::*; 4 | 5 | #[derive(Copy, Clone, Default, Debug, DeriveEntity)] 6 | pub struct Entity; 7 | 8 | impl EntityName for Entity { 9 | fn table_name(&self) -> &str { 10 | "up_account" 11 | } 12 | } 13 | 14 | #[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] 15 | pub struct Model { 16 | pub up_id: i64, 17 | pub account_id: i64, 18 | } 19 | 20 | #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] 21 | pub enum Column { 22 | UpId, 23 | AccountId, 24 | } 25 | 26 | #[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] 27 | pub enum PrimaryKey { 28 | UpId, 29 | AccountId, 30 | } 31 | 32 | impl PrimaryKeyTrait for PrimaryKey { 33 | type ValueType = (i64, i64); 34 | fn auto_increment() -> bool { 35 | false 36 | } 37 | } 38 | 39 | #[derive(Copy, Clone, Debug, EnumIter)] 40 | pub enum Relation { 41 | Account, 42 | Up, 43 | } 44 | 45 | impl ColumnTrait for Column { 46 | type EntityName = Entity; 47 | fn def(&self) -> ColumnDef { 48 | match self { 49 | Self::UpId => ColumnType::BigInteger.def(), 50 | Self::AccountId => ColumnType::BigInteger.def(), 51 | } 52 | } 53 | } 54 | 55 | impl RelationTrait for Relation { 56 | fn def(&self) -> RelationDef { 57 | match self { 58 | Self::Account => Entity::belongs_to(super::account::Entity) 59 | .from(Column::AccountId) 60 | .to(super::account::Column::AccountId) 61 | .into(), 62 | Self::Up => Entity::belongs_to(super::up::Entity) 63 | .from(Column::UpId) 64 | .to(super::up::Column::UpId) 65 | .into(), 66 | } 67 | } 68 | } 69 | 70 | impl Related for Entity { 71 | fn to() -> RelationDef { 72 | Relation::Account.def() 73 | } 74 | } 75 | 76 | impl Related for Entity { 77 | fn to() -> RelationDef { 78 | Relation::Up.def() 79 | } 80 | } 81 | 82 | impl ActiveModelBehavior for ActiveModel {} 83 | -------------------------------------------------------------------------------- /fav_bili/src/entity/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(unused)] 2 | mod entity_inner; 3 | 4 | pub use entity_inner::*; 5 | 6 | use crate::table::head; 7 | 8 | pub trait ToTableRecord { 9 | fn to_record(self) -> [String; N]; 10 | } 11 | 12 | impl ToTableRecord<3> for account::Model { 13 | fn to_record(self) -> [String; 3] { 14 | [self.account_id.to_string(), self.name, self.state] 15 | } 16 | } 17 | 18 | impl ToTableRecord<4> for set::Model { 19 | fn to_record(self) -> [String; 4] { 20 | [ 21 | self.set_id.to_string(), 22 | head(self.name, 20), 23 | self.count.to_string(), 24 | self.state, 25 | ] 26 | } 27 | } 28 | 29 | impl ToTableRecord<5> for media::Model { 30 | fn to_record(self) -> [String; 5] { 31 | [ 32 | self.id.to_string(), 33 | self.bv_id, 34 | head(self.title, 20), 35 | self.r#type.to_string(), 36 | self.state.to_string(), 37 | ] 38 | } 39 | } 40 | 41 | impl ToTableRecord<3> for up::Model { 42 | fn to_record(self) -> [String; 3] { 43 | [self.up_id.to_string(), head(self.name, 20), self.state] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /fav_bili/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod migration; 2 | -------------------------------------------------------------------------------- /fav_bili/src/main.rs: -------------------------------------------------------------------------------- 1 | mod action; 2 | mod api; 3 | mod command; 4 | mod cookies; 5 | mod db; 6 | mod entity; 7 | mod migration; 8 | mod payload; 9 | mod response; 10 | mod state; 11 | mod table; 12 | mod version; 13 | mod wbi; 14 | 15 | use command::FavCommand; 16 | 17 | use tracing::error; 18 | 19 | #[tokio::main(flavor = "current_thread")] 20 | async fn main() { 21 | if let Err(e) = FavCommand::new().run().await { 22 | error!("{}", e); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /fav_bili/src/migration/m20250527_000001_create_table.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::enum_variant_names)] 2 | 3 | use sea_orm_migration::{prelude::*, schema::*}; 4 | 5 | #[derive(DeriveMigrationName)] 6 | pub struct Migration; 7 | 8 | #[async_trait::async_trait] 9 | impl MigrationTrait for Migration { 10 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 11 | manager 12 | .create_table( 13 | Table::create() 14 | .table(Account::Table) 15 | .if_not_exists() 16 | .col(big_unsigned_uniq(Account::AccountId)) 17 | .col(string(Account::Name)) 18 | .col(string(Account::Cookies)) 19 | .col( 20 | enumeration(Account::State, "state", ["Active", "Inactive", "Expired"]) 21 | .default("Active"), 22 | ) 23 | .primary_key(Index::create().col(Account::AccountId)) 24 | .to_owned(), 25 | ) 26 | .await?; 27 | manager 28 | .create_table( 29 | Table::create() 30 | .table(Up::Table) 31 | .if_not_exists() 32 | .col(big_unsigned_uniq(Up::UpId)) 33 | .col(string(Up::Name)) 34 | .col( 35 | enumeration(Up::State, "state", ["Active", "Inactive", "Deactivated"]) 36 | .default("Inactive"), 37 | ) 38 | .primary_key(Index::create().col(Up::UpId)) 39 | .to_owned(), 40 | ) 41 | .await?; 42 | manager 43 | .create_table( 44 | Table::create() 45 | .table(Set::Table) 46 | .if_not_exists() 47 | .col(big_unsigned_uniq(Set::SetId)) 48 | .col(string(Set::Name)) 49 | .col(big_unsigned(Set::Count)) 50 | .col( 51 | enumeration(Set::State, "state", ["Active", "Inactive", "Unreachable"]) 52 | .default("Inactive"), 53 | ) 54 | .primary_key(Index::create().col(Set::SetId)) 55 | .to_owned(), 56 | ) 57 | .await?; 58 | manager 59 | .create_table( 60 | Table::create() 61 | .table(Media::Table) 62 | .if_not_exists() 63 | .col(big_unsigned_uniq(Media::Id)) 64 | .col(string_uniq(Media::BvId)) 65 | .col(string(Media::Title)) 66 | .col( 67 | enumeration(Media::Type, "type", ["Video", "Audio", "Collection"]) 68 | .default("Video"), 69 | ) 70 | .col( 71 | enumeration( 72 | Media::State, 73 | "state", 74 | [ 75 | "Pending", 76 | "Downloading", 77 | "Completed", 78 | "Failed", 79 | "Expired", 80 | "PermissionDenied", 81 | ], 82 | ) 83 | .default("Pending"), 84 | ) 85 | .primary_key(Index::create().col(Media::Id)) 86 | .to_owned(), 87 | ) 88 | .await?; 89 | manager 90 | .create_table( 91 | Table::create() 92 | .table(MediaSet::Table) 93 | .if_not_exists() 94 | .col(big_unsigned(MediaSet::Id)) 95 | .col(big_unsigned(MediaSet::SetId)) 96 | .primary_key(Index::create().col(MediaSet::Id).col(MediaSet::SetId)) 97 | .foreign_key( 98 | ForeignKey::create() 99 | .name("mediaset_media_fk") 100 | .from(MediaSet::Table, MediaSet::Id) 101 | .to(Media::Table, Media::Id) 102 | .on_delete(ForeignKeyAction::Cascade) 103 | .on_update(ForeignKeyAction::Cascade), 104 | ) 105 | .foreign_key( 106 | ForeignKey::create() 107 | .name("mediaset_set_fk") 108 | .from(MediaSet::Table, MediaSet::SetId) 109 | .to(Set::Table, Set::SetId) 110 | .on_delete(ForeignKeyAction::Cascade) 111 | .on_update(ForeignKeyAction::Cascade), 112 | ) 113 | .to_owned(), 114 | ) 115 | .await?; 116 | manager 117 | .create_table( 118 | Table::create() 119 | .table(MediaUp::Table) 120 | .if_not_exists() 121 | .col(big_unsigned(MediaUp::Id)) 122 | .col(big_unsigned(MediaUp::UpId)) 123 | .primary_key(Index::create().col(MediaUp::Id).col(MediaUp::UpId)) 124 | .foreign_key( 125 | ForeignKey::create() 126 | .name("mediaup_media_fk") 127 | .from(MediaUp::Table, MediaUp::Id) 128 | .to(Media::Table, Media::Id) 129 | .on_delete(ForeignKeyAction::Cascade) 130 | .on_update(ForeignKeyAction::Cascade), 131 | ) 132 | .foreign_key( 133 | ForeignKey::create() 134 | .name("mediaup_up_fk") 135 | .from(MediaUp::Table, MediaUp::UpId) 136 | .to(Up::Table, Up::UpId) 137 | .on_delete(ForeignKeyAction::Cascade) 138 | .on_update(ForeignKeyAction::Cascade), 139 | ) 140 | .to_owned(), 141 | ) 142 | .await?; 143 | manager 144 | .create_table( 145 | Table::create() 146 | .table(SetAccount::Table) 147 | .if_not_exists() 148 | .col(big_unsigned(SetAccount::SetId)) 149 | .col(big_unsigned(SetAccount::AccountId)) 150 | .primary_key( 151 | Index::create() 152 | .col(SetAccount::SetId) 153 | .col(SetAccount::AccountId), 154 | ) 155 | .foreign_key( 156 | ForeignKey::create() 157 | .name("setaccount_set_fk") 158 | .from(SetAccount::Table, SetAccount::SetId) 159 | .to(Set::Table, Set::SetId) 160 | .on_delete(ForeignKeyAction::Cascade) 161 | .on_update(ForeignKeyAction::Cascade), 162 | ) 163 | .foreign_key( 164 | ForeignKey::create() 165 | .name("setaccount_account_fk") 166 | .from(SetAccount::Table, SetAccount::AccountId) 167 | .to(Account::Table, Account::AccountId) 168 | .on_delete(ForeignKeyAction::Cascade) 169 | .on_update(ForeignKeyAction::Cascade), 170 | ) 171 | .to_owned(), 172 | ) 173 | .await?; 174 | manager 175 | .create_table( 176 | Table::create() 177 | .table(UpAccount::Table) 178 | .if_not_exists() 179 | .col(big_unsigned(UpAccount::UpId)) 180 | .col(big_unsigned(UpAccount::AccountId)) 181 | .primary_key( 182 | Index::create() 183 | .col(UpAccount::UpId) 184 | .col(UpAccount::AccountId), 185 | ) 186 | .foreign_key( 187 | ForeignKey::create() 188 | .name("upaccount_up_fk") 189 | .from(UpAccount::Table, UpAccount::UpId) 190 | .to(Up::Table, Up::UpId) 191 | .on_delete(ForeignKeyAction::Cascade) 192 | .on_update(ForeignKeyAction::Cascade), 193 | ) 194 | .foreign_key( 195 | ForeignKey::create() 196 | .name("upaccount_account_fk") 197 | .from(UpAccount::Table, UpAccount::AccountId) 198 | .to(Account::Table, Account::AccountId) 199 | .on_delete(ForeignKeyAction::Cascade) 200 | .on_update(ForeignKeyAction::Cascade), 201 | ) 202 | .to_owned(), 203 | ) 204 | .await?; 205 | Ok(()) 206 | } 207 | 208 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 209 | manager 210 | .drop_table(Table::drop().table(MediaUp::Table).to_owned()) 211 | .await 212 | .unwrap(); 213 | manager 214 | .drop_table(Table::drop().table(MediaSet::Table).to_owned()) 215 | .await 216 | .unwrap(); 217 | manager 218 | .drop_table(Table::drop().table(SetAccount::Table).to_owned()) 219 | .await 220 | .unwrap(); 221 | manager 222 | .drop_table(Table::drop().table(UpAccount::Table).to_owned()) 223 | .await 224 | .unwrap(); 225 | manager 226 | .drop_table(Table::drop().table(Account::Table).to_owned()) 227 | .await 228 | .unwrap(); 229 | manager 230 | .drop_table(Table::drop().table(Up::Table).to_owned()) 231 | .await 232 | .unwrap(); 233 | manager 234 | .drop_table(Table::drop().table(Set::Table).to_owned()) 235 | .await 236 | .unwrap(); 237 | manager 238 | .drop_table(Table::drop().table(Media::Table).to_owned()) 239 | .await 240 | .unwrap(); 241 | Ok(()) 242 | } 243 | } 244 | 245 | #[derive(DeriveIden)] 246 | enum Account { 247 | Table, 248 | AccountId, 249 | Name, 250 | Cookies, 251 | State, 252 | } 253 | 254 | #[derive(DeriveIden)] 255 | enum Up { 256 | Table, 257 | UpId, 258 | Name, 259 | State, 260 | } 261 | 262 | #[derive(DeriveIden)] 263 | enum Set { 264 | Table, 265 | SetId, 266 | Name, 267 | Count, 268 | State, 269 | } 270 | 271 | #[derive(DeriveIden)] 272 | enum Media { 273 | Table, 274 | Id, 275 | BvId, 276 | Title, 277 | Type, 278 | State, 279 | } 280 | 281 | #[derive(DeriveIden)] 282 | enum MediaUp { 283 | Table, 284 | Id, 285 | UpId, 286 | } 287 | 288 | #[derive(DeriveIden)] 289 | enum MediaSet { 290 | Table, 291 | Id, 292 | SetId, 293 | } 294 | 295 | #[derive(DeriveIden)] 296 | enum SetAccount { 297 | Table, 298 | SetId, 299 | AccountId, 300 | } 301 | 302 | #[derive(DeriveIden)] 303 | enum UpAccount { 304 | Table, 305 | UpId, 306 | AccountId, 307 | } 308 | -------------------------------------------------------------------------------- /fav_bili/src/migration/mod.rs: -------------------------------------------------------------------------------- 1 | mod m20250527_000001_create_table; 2 | 3 | pub use sea_orm_migration::prelude::*; 4 | 5 | pub struct Migrator; 6 | 7 | #[async_trait::async_trait] 8 | impl MigratorTrait for Migrator { 9 | fn migrations() -> Vec> { 10 | vec![Box::new(m20250527_000001_create_table::Migration)] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /fav_bili/src/payload/auth.rs: -------------------------------------------------------------------------------- 1 | use api_req::{Method, Payload}; 2 | use serde::Serialize; 3 | 4 | #[derive(Debug, Payload, Serialize)] 5 | #[api_req(path = "/x/passport-login/web/qrcode/generate")] 6 | pub struct QrPayload; 7 | 8 | #[derive(Debug, Payload, Serialize)] 9 | #[api_req(path = "/x/passport-login/web/qrcode/poll")] 10 | pub struct QrPollPayload { 11 | pub qrcode_key: String, 12 | } 13 | 14 | #[allow(non_snake_case)] 15 | #[derive(Debug, Payload, Serialize)] 16 | #[api_req(path = "/login/exit/v2", method = Method::POST, req = form)] 17 | pub struct LogoutPayload { 18 | pub biliCSRF: String, 19 | } 20 | -------------------------------------------------------------------------------- /fav_bili/src/payload/buvid3.rs: -------------------------------------------------------------------------------- 1 | use api_req::Payload; 2 | use serde::Serialize; 3 | 4 | #[derive(Debug, Serialize, Payload)] 5 | #[api_req(path = "/x/web-frontend/getbuvid")] 6 | pub struct Buvid3Payload; 7 | -------------------------------------------------------------------------------- /fav_bili/src/payload/dash.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use api_req::{ApiCaller as _, Payload}; 3 | use serde::Serialize; 4 | 5 | use crate::{ 6 | api::BiliApi, 7 | response::{WbiData, WbiResp}, 8 | wbi::{WbiEncoded, WbiEncoder}, 9 | }; 10 | 11 | use super::WbiPayload; 12 | 13 | #[derive(Debug, Payload, Serialize)] 14 | #[api_req(path = "/x/player/wbi/playurl")] 15 | pub struct DashPayload { 16 | pub avid: i64, // Do not change the field order 17 | pub cid: i64, 18 | pub fnval: u16, 19 | pub fourk: u8, 20 | pub qn: u16, 21 | pub wts: u64, 22 | #[serde(flatten)] 23 | pub wbi: Option, 24 | } 25 | 26 | impl DashPayload { 27 | pub async fn new(avid: i64, cid: i64) -> Result { 28 | let mut this = Self { 29 | avid, 30 | cid, 31 | fnval: 16 | 64 | 128 | 1024, 32 | fourk: 1, 33 | qn: 127, 34 | wts: std::time::SystemTime::now() 35 | .duration_since(std::time::UNIX_EPOCH) 36 | .unwrap() 37 | .as_secs(), 38 | wbi: None, 39 | }; 40 | let WbiResp { 41 | data: WbiData { wbi_img, .. }, 42 | } = BiliApi::request(WbiPayload).await?; 43 | this.wbi = Some(WbiEncoder::encode(wbi_img, &this)); 44 | Ok(this) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /fav_bili/src/payload/like.rs: -------------------------------------------------------------------------------- 1 | use api_req::{Method, Payload}; 2 | use serde::Serialize; 3 | 4 | #[derive(Debug, Serialize, Payload)] 5 | #[api_req( 6 | path = "/x/web-interface/archive/like", 7 | method = Method::POST, 8 | req = form, 9 | )] 10 | pub struct LikePayload { 11 | pub aid: i64, 12 | pub like: u8, 13 | pub csrf: String, 14 | } 15 | -------------------------------------------------------------------------------- /fav_bili/src/payload/media.rs: -------------------------------------------------------------------------------- 1 | use api_req::Payload; 2 | use serde::Serialize; 3 | 4 | #[derive(Debug, Payload, Serialize)] 5 | #[api_req(path = "/x/web-interface/wbi/view")] 6 | pub struct MediaInfoPayload { 7 | pub aid: i64, 8 | } 9 | -------------------------------------------------------------------------------- /fav_bili/src/payload/mod.rs: -------------------------------------------------------------------------------- 1 | mod auth; 2 | mod buvid3; 3 | mod dash; 4 | mod like; 5 | mod media; 6 | mod set; 7 | mod ticket; 8 | mod up; 9 | mod wbi; 10 | 11 | pub use auth::*; 12 | pub use buvid3::*; 13 | pub use dash::*; 14 | pub use like::*; 15 | pub use media::*; 16 | pub use set::*; 17 | pub use ticket::*; 18 | pub use up::*; 19 | pub use wbi::*; 20 | -------------------------------------------------------------------------------- /fav_bili/src/payload/set.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use api_req::{ApiCaller as _, Payload}; 3 | use serde::Serialize; 4 | 5 | use crate::{ 6 | api::BiliApi, 7 | response::{WbiData, WbiResp}, 8 | wbi::{WbiEncoded, WbiEncoder}, 9 | }; 10 | 11 | use super::WbiPayload; 12 | 13 | #[derive(Debug, Payload, Serialize)] 14 | #[api_req(path = "/x/v3/fav/folder/created/list-all")] 15 | pub struct ListSetPayload { 16 | pub up_mid: i64, 17 | } 18 | 19 | #[derive(Debug, Payload, Serialize)] 20 | #[api_req(path = "/x/v3/fav/resource/list")] 21 | pub struct InSetPayload { 22 | pub media_id: i64, 23 | pub pn: i64, 24 | pub ps: u8, 25 | } 26 | 27 | #[derive(Debug, Payload, Serialize)] 28 | #[api_req(path = "/x/space/wbi/arc/search")] 29 | pub struct InUpPayload { 30 | pub mid: i64, // Do not change the field order 31 | pub pn: i64, 32 | pub ps: u8, 33 | pub wts: u64, 34 | #[serde(flatten)] 35 | pub wbi: Option, 36 | } 37 | 38 | impl InUpPayload { 39 | pub async fn new(mid: i64, pn: i64, ps: u8) -> Result { 40 | let mut this = Self { 41 | mid, 42 | pn, 43 | ps, 44 | wts: std::time::SystemTime::now() 45 | .duration_since(std::time::UNIX_EPOCH) 46 | .unwrap() 47 | .as_secs(), 48 | wbi: None, 49 | }; 50 | let WbiResp { 51 | data: WbiData { wbi_img, .. }, 52 | } = BiliApi::request(WbiPayload).await?; 53 | this.wbi = Some(WbiEncoder::encode(wbi_img, &this)); 54 | Ok(this) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /fav_bili/src/payload/ticket.rs: -------------------------------------------------------------------------------- 1 | use api_req::{Method, Payload}; 2 | use ring::hmac::{HMAC_SHA256, Key, sign}; 3 | use serde::Serialize; 4 | 5 | const KEY: &str = "XgwSnGZ1p"; 6 | 7 | #[derive(Debug, Serialize, Payload)] 8 | #[api_req(path = 9 | "/bapis/bilibili.api.ticket.v1.Ticket/GenWebTicket", 10 | method = Method::POST, 11 | req = query, 12 | )] 13 | pub struct TicketPayload { 14 | key_id: String, 15 | hexsign: String, 16 | #[serde(rename(serialize = "context[ts]"))] 17 | context_ts: u64, 18 | csrf: String, 19 | } 20 | 21 | impl TicketPayload { 22 | pub fn new(csrf: String) -> Self { 23 | let context_ts = std::time::SystemTime::now() 24 | .duration_since(std::time::UNIX_EPOCH) 25 | .unwrap() 26 | .as_secs(); 27 | let key = Key::new(HMAC_SHA256, KEY.as_bytes()); 28 | let tag = sign(&key, format!("ts{}", context_ts).as_bytes()); 29 | let hexsign = hex::encode(tag); 30 | Self { 31 | key_id: "ec02".to_string(), 32 | hexsign, 33 | context_ts, 34 | csrf, 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /fav_bili/src/payload/up.rs: -------------------------------------------------------------------------------- 1 | use api_req::Payload; 2 | use serde::Serialize; 3 | 4 | #[derive(Debug, Serialize, Payload)] 5 | #[api_req(path = "/x/relation/followings")] 6 | pub struct FollowingUpPayload { 7 | pub vmid: i64, 8 | pub pn: i64, 9 | pub ps: u8, 10 | } 11 | 12 | #[derive(Debug, Serialize, Payload)] 13 | #[api_req(path = "/x/relation/stat")] 14 | pub struct FollowingNumPayload { 15 | pub vmid: i64, 16 | } 17 | 18 | #[derive(Debug, Serialize, Payload)] 19 | #[api_req(path = "/x/space/navnum")] 20 | pub struct PublishNumPayload { 21 | pub mid: i64, 22 | } 23 | -------------------------------------------------------------------------------- /fav_bili/src/payload/wbi.rs: -------------------------------------------------------------------------------- 1 | use api_req::Payload; 2 | use serde::Serialize; 3 | 4 | #[derive(Debug, Payload, Serialize)] 5 | #[api_req(path = "/x/web-interface/nav")] 6 | pub struct WbiPayload; 7 | -------------------------------------------------------------------------------- /fav_bili/src/response/auth.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use url::Url; 3 | 4 | #[derive(Debug, Deserialize)] 5 | pub struct QrResp { 6 | pub data: QrData, 7 | } 8 | 9 | #[derive(Debug, Deserialize)] 10 | pub struct QrData { 11 | pub qrcode_key: String, 12 | pub url: Url, 13 | } 14 | 15 | #[derive(Debug, Deserialize)] 16 | pub struct QrPollResp { 17 | pub data: QrPollData, 18 | } 19 | 20 | #[derive(Debug, Deserialize)] 21 | pub struct QrPollData { 22 | pub code: u32, 23 | pub message: String, 24 | } 25 | 26 | #[derive(Debug, Deserialize)] 27 | pub struct LogoutResp { 28 | pub code: i32, 29 | pub message: Option, 30 | } 31 | -------------------------------------------------------------------------------- /fav_bili/src/response/buvid3.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Debug, Deserialize)] 4 | pub struct Buvid3Resp { 5 | pub data: Buvid3Data, 6 | } 7 | 8 | #[derive(Debug, Deserialize)] 9 | pub struct Buvid3Data { 10 | pub buvid: String, 11 | } 12 | -------------------------------------------------------------------------------- /fav_bili/src/response/dash.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use url::Url; 3 | 4 | #[derive(Debug, Deserialize)] 5 | pub struct DashResp { 6 | pub data: DashData, 7 | } 8 | 9 | #[derive(Debug, Deserialize)] 10 | pub struct DashData { 11 | pub dash: Dash, 12 | } 13 | 14 | #[derive(Debug, Deserialize)] 15 | pub struct Dash { 16 | pub video: Vec