├── .github └── workflows │ └── build.yaml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Readme.md ├── examples ├── accounts │ └── .gitkeep ├── card │ └── card.txt ├── config.toml ├── templates │ ├── condition.toml │ ├── default.toml │ ├── mrrj.toml │ └── variables.toml └── variable_files │ ├── var.json │ └── var.txt ├── ssup ├── Cargo.toml └── src │ ├── client.rs │ ├── constants.rs │ ├── credential.rs │ ├── lib.rs │ ├── line.rs │ ├── uploader │ ├── kodo.rs │ ├── mod.rs │ ├── upos.rs │ └── utils.rs │ └── video.rs ├── sswa ├── Cargo.toml └── src │ ├── args.rs │ ├── config.rs │ ├── context.rs │ ├── ffmpeg.rs │ ├── main.rs │ └── template.rs └── tinytemplate ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── Readme.md └── src ├── compiler.rs ├── error.rs ├── instruction.rs ├── lib.rs ├── syntax.rs └── template.rs /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [ push ] 3 | 4 | jobs: 5 | build: 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | include: 10 | - target: x86_64-unknown-linux-gnu 11 | os: ubuntu-latest 12 | name: sswa-x86_64-unknown-linux-gnu.tar.gz 13 | - target: x86_64-unknown-linux-musl 14 | os: ubuntu-latest 15 | name: sswa-x86_64-unknown-linux-musl.tar.gz 16 | - target: x86_64-pc-windows-msvc 17 | os: windows-latest 18 | name: sswa-x86_64-pc-windows-msvc.zip 19 | - target: x86_64-apple-darwin 20 | os: macOS-latest 21 | name: sswa-x86_64-apple-darwin.tar.gz 22 | 23 | runs-on: ${{ matrix.os }} 24 | continue-on-error: true 25 | steps: 26 | - name: Setup | Checkout 27 | uses: actions/checkout@v2.4.0 28 | 29 | - name: Setup | Cache Cargo 30 | uses: actions/cache@v2.1.7 31 | with: 32 | path: | 33 | ~/.cargo/registry 34 | ~/.cargo/git 35 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 36 | 37 | - name: Setup | Cache Cargo Target 38 | uses: actions/cache@v2.1.7 39 | with: 40 | path: target 41 | key: ${{ matrix.target }}-cargo-target 42 | 43 | - name: Setup | Rust 44 | uses: actions-rs/toolchain@v1 45 | with: 46 | toolchain: stable 47 | override: true 48 | profile: minimal 49 | target: ${{ matrix.target }} 50 | 51 | - name: Build | Build 52 | uses: actions-rs/cargo@v1 53 | with: 54 | command: build 55 | args: --release --locked --all-features --target ${{ matrix.target }}" 56 | use-cross: ${{ matrix.os == 'ubuntu-latest' }} 57 | 58 | - name: PostBuild | Prepare artifacts [Windows] 59 | if: matrix.os == 'windows-latest' 60 | run: | 61 | cd target/${{ matrix.target }}/release 62 | strip sswa.exe 63 | 7z a ../../../${{ matrix.name }} sswa.exe 64 | cd - 65 | - name: PostBuild | Prepare artifacts [-nix] 66 | if: matrix.os != 'windows-latest' 67 | run: | 68 | cd target/${{ matrix.target }}/release 69 | strip sswa || true 70 | tar czvf ../../../${{ matrix.name }} sswa 71 | cd - 72 | - name: Deploy | Upload artifacts 73 | uses: actions/upload-artifact@v2 74 | with: 75 | name: ${{ matrix.name }} 76 | path: ${{ matrix.name }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | 4 | examples/accounts/**/* 5 | !examples/accounts/.gitkeep 6 | 7 | *.png -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "ssup", 4 | "tinytemplate", 5 | "sswa", 6 | ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # sswa 2 | 3 | > 叔叔我啊…… 4 | 5 | 本项目分为两个部分,可用作 `lib` 的 `ssup` 和可执行部分 `sswa`。 6 | 7 | 本项目很大程度上参考了 `biliup-rs`,但对投稿的方式进行了重新设计,以符合正常的投稿思维。 8 | 9 | ## 依赖 10 | 11 | - ffmpeg 12 | 13 | ## 如何开始 14 | 15 | 1. 获取配置文件目录 16 | 17 | ```bash 18 | sswa config 19 | ``` 20 | 21 | 2. 进入 `配置目录/template`,按 [示例模板](examples/templates/mrrj.toml) 创建 `your_template_name.toml` 22 | 3. 使用命令: 23 | 24 | ```bash 25 | # 登录 26 | # 这里 your_name 随便写,顺手就行,之后投稿会用到 27 | sswa login your_name 28 | # 以 your_name 用户投稿,模板为 your_template_name,视频文件为 video.mkv,分P名为 video 29 | sswa upload --user your_name --template your_template_name video.mkv 30 | ``` 31 | 32 | 4. 输入待输入的变量,上传。 33 | 34 | ## LICENSE 35 | 36 | 本项目遵循 [Apache 2.0](LICENSE) 协议,参考 biliup-rs 的部分遵循 MIT 协议。 -------------------------------------------------------------------------------- /examples/accounts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yesterday17/sswa/ffe7e6534db955f72ee7afbbfc189a618cc55c39/examples/accounts/.gitkeep -------------------------------------------------------------------------------- /examples/card/card.txt: -------------------------------------------------------------------------------- 1 | # comment starts with sharp, empty lines are skipped 2 | 3 | # each line contains [time] and [content], split by COMMA 4 | # [time] can be 'hh:mm:ss', 'mm:ss', or simply 'seconds' 5 | # [content] can contain any characters. Length limit = 16, len(half-width-character) = 0.5 6 | 00:03,开场 7 | 03:00,Mail #1 8 | 07:20,Mail #2 9 | 12:36,Mail #3 10 | 14:47,Mail #4 11 | 17:09,Audio Room 12 | 22:33,SPARKLE-结束 -------------------------------------------------------------------------------- /examples/config.toml: -------------------------------------------------------------------------------- 1 | # 投稿线路 2 | line = "kodo" 3 | # 默认使用的投稿帐号 4 | default-user = "me" 5 | # 是否自动缩放封面(依赖 ffmpeg) 6 | scale-cover = false -------------------------------------------------------------------------------- /examples/templates/condition.toml: -------------------------------------------------------------------------------- 1 | title = "Title" 2 | description = '''Description''' 3 | tid = 152 4 | cover = "./cover.jpg" 5 | dynamic-text = "{% if s7i -%} 私は妻の {{- s7i -}} を愛しています。{%- endif %}" -------------------------------------------------------------------------------- /examples/templates/default.toml: -------------------------------------------------------------------------------- 1 | # 标题 2 | title = "{{title}}" 3 | # 描述 4 | description = "{{description}}" 5 | # 转载来源,该字段存在且不为空时以转载形式投稿,否则投成自制 6 | forward-source = "{{source}}" 7 | # 投稿分区 8 | tid = 152 9 | # 封面图片路径 10 | cover = "{{cover}}" 11 | # 动态文本 12 | dynamic-text = "{{dynamic}}" 13 | # 标签 14 | tags = ["{{tags}}"] 15 | # 定时投稿 16 | display-time = "{{time}}" 17 | 18 | [variables] 19 | title = "标题" 20 | description = "描述" 21 | source = "视频来源" 22 | cover = "封面路径" 23 | dynamic = "动态" 24 | tags = "标签" 25 | time = "公开时间" -------------------------------------------------------------------------------- /examples/templates/mrrj.toml: -------------------------------------------------------------------------------- 1 | # 模板的默认投稿帐号 2 | default-user = "me" 3 | 4 | # 标题 5 | title = "【字幕】【偶像大师百万广播{{num}}回】{{title}}【MillionRADIO】" 6 | # 描述 7 | description = '''片源:RabbitC 8 | 翻译:{{translators}} 9 | 校对:{{jiaodui}} 10 | 时间轴:{{subtitle}}''' 11 | # 转载来源,该字段存在且不为空时以转载形式投稿,否则投成自制 12 | forward-source = "765.million" 13 | # 投稿分区 14 | tid = 152 15 | # 封面图片路径 16 | # 当留空或值为 auto 时,自动从第一个视频(不包含前缀视频)中随机提取 17 | cover = '''./ 18 | {%-if cover -%} 19 | {{cover}} 20 | {%- else -%} 21 | folder 22 | {%- endif %}/cover.png''' 23 | # 动态文本 24 | dynamic-text = "" 25 | # 标签 26 | tags = ["田所梓", "山崎遥", "麻仓桃", "偶像大师百万广播", "{{guests}}"] 27 | # 定时投稿 28 | display-time = "tomorrow 10:00" 29 | # 字幕(TODO) 30 | subtitle = { open = false, lang = "zh-cn" } 31 | # 前缀视频,在投稿时自动添加在选中视频**前面** 32 | video-prefix = [ 33 | # 建议写绝对路径,相对路径是相对命令执行时候的 CWD 34 | "/tmp/prefix.mp4", 35 | ] 36 | # 后缀视频,在投稿时自动添加在选中视频**后面** 37 | video-suffix = [] 38 | 39 | # 变量对应的解释文本,在需要用户手动输入时作为提示文本展示 40 | [variables] 41 | translators = "翻译" 42 | jiaodui = "校对" 43 | subtitle = "时间轴" 44 | guests = "嘉宾 Tag" 45 | 46 | [variables.num] 47 | description = "广播回数" 48 | # 变量的默认值 49 | default = "123" 50 | 51 | [variables.title] 52 | description = "标题" 53 | default = "默认标题" 54 | 55 | [variables.cover] 56 | description = "封面图片目录" 57 | # 当未提供时 变量是否必须填写 58 | can-skip = true 59 | -------------------------------------------------------------------------------- /examples/templates/variables.toml: -------------------------------------------------------------------------------- 1 | # 以去除扩展名的第一个文件的文件名为标题 2 | title = "{{$file_stem}}" 3 | description = "上传文件名:{{ss_file_name}},包含扩展名" 4 | tid = 152 5 | # 封面为 config 目录下的 cover.jpg 6 | cover = "{{$config_path}}/cover.jpg" 7 | dynamic-text = "" 8 | video-prefix = [ 9 | # 第一个视频同目录下的 prefix.mkv 10 | "{{$file_pwd}}/prefix.mkv", 11 | # 重复上传一遍第一个视频(很神秘) 12 | "{{$file_pwd}}/{{$file_name}}", 13 | ] -------------------------------------------------------------------------------- /examples/variable_files/var.json: -------------------------------------------------------------------------------- 1 | { 2 | "s7i": "種田梨沙", 3 | "desc": "value with space", 4 | "desc2": "就像正常的 JSON 那么写就行了" 5 | } -------------------------------------------------------------------------------- /examples/variable_files/var.txt: -------------------------------------------------------------------------------- 1 | s7i=種田梨沙 2 | desc="value with space" 3 | desc2=其实,就算有空格也不一定要有引号,引号就是兼容的 4 | desc3=可以用转义进行换行,比如这样就换行了\n但想不换行暂时还不行,比如这样也会换行\\n -------------------------------------------------------------------------------- /ssup/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ssup" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | # http request 8 | reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } 9 | cookie = "0.15.1" 10 | cookie_store = "0.15.0" 11 | reqwest_cookie_store = "0.2.0" 12 | reqwest-middleware = "0.1.1" 13 | reqwest-retry = "0.1.1" 14 | 15 | # async runtime 16 | tokio = { version = "1.17.0", features = ["fs", "sync"] } 17 | futures = "0.3.17" 18 | async-stream = "0.3.2" 19 | 20 | # serialize/deserialize 21 | serde = { version = "1.0", features = ["derive"] } 22 | serde_json = "1.0" 23 | serde_urlencoded = "0.7" 24 | 25 | # algorithms/structures 26 | base64 = "0.13.0" 27 | bytes = "1.1.0" 28 | lazy_static = "1.4.0" 29 | parking_lot = "0.12.0" 30 | md-5 = "0.9.1" 31 | 32 | # error handling 33 | anyhow = "1" 34 | thiserror = "1" 35 | log = "0.4.14" 36 | -------------------------------------------------------------------------------- /ssup/src/client.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::USER_AGENT; 2 | use crate::credential::{Credential, ResponseData, ResponseValue}; 3 | use crate::line::UploadLine; 4 | use crate::video::{EditVideo, EditVideoPart, Video, VideoCardItem, VideoId, VideoPart}; 5 | use anyhow::bail; 6 | use reqwest::header::{HeaderMap, HeaderValue}; 7 | use reqwest::Url; 8 | use reqwest_cookie_store::CookieStoreMutex; 9 | use serde_json::json; 10 | use std::path::Path; 11 | use std::sync::Arc; 12 | use std::time::Duration; 13 | use tokio::fs; 14 | use tokio::sync::mpsc::Sender; 15 | 16 | /// 上传使用的客户端 17 | pub struct Client { 18 | pub(crate) client: reqwest::Client, 19 | cookie_store: Arc, 20 | 21 | line: UploadLine, 22 | credential: Credential, 23 | } 24 | 25 | impl Client { 26 | pub fn new(upload_line: UploadLine, credential: Credential) -> Self { 27 | let mut headers = HeaderMap::new(); 28 | headers.insert( 29 | "Referer", 30 | HeaderValue::from_static("https://www.bilibili.com/"), 31 | ); 32 | headers.insert("Connection", HeaderValue::from_static("keep-alive")); 33 | 34 | let cookie_store = cookie_store::CookieStore::default(); 35 | let cookie_store = CookieStoreMutex::new(cookie_store); 36 | let cookie_store = Arc::new(cookie_store); 37 | 38 | let mut me = Self { 39 | client: reqwest::Client::builder() 40 | .cookie_provider(cookie_store.clone()) 41 | .user_agent(USER_AGENT.read().as_str()) 42 | .default_headers(headers) 43 | .timeout(Duration::new(60, 0)) 44 | .build() 45 | .unwrap(), 46 | cookie_store, 47 | line: upload_line, 48 | credential, 49 | }; 50 | 51 | me.load_credential(); 52 | me 53 | } 54 | 55 | pub async fn auto(credential: Credential) -> anyhow::Result { 56 | Ok(Self::new(UploadLine::auto().await?, credential)) 57 | } 58 | 59 | /// 加载 LoginInfo 进入 Client 60 | fn load_credential(&mut self) { 61 | let mut store = self.cookie_store.lock().unwrap(); 62 | let link = Url::parse("https://bilibili.com").unwrap(); 63 | for cookie in &self.credential.cookie_info.cookies { 64 | store.insert_raw(&cookie.to_cookie(), &link).unwrap(); 65 | } 66 | } 67 | 68 | /// 上传封面 69 | pub async fn upload_cover

(&self, cover: P) -> anyhow::Result 70 | where 71 | P: AsRef, 72 | { 73 | let cover = fs::read(cover).await?; 74 | 75 | let csrf = self.credential.cookie_info.get("bili_jct").unwrap(); 76 | let response: ResponseData = self 77 | .client 78 | .post("https://member.bilibili.com/x/vu/web/cover/up") 79 | .form(&json!({ 80 | "cover": format!("data:image/jpeg;base64,{}", base64::encode(cover)), 81 | "csrf": csrf, 82 | })) 83 | .send() 84 | .await? 85 | .json() 86 | .await?; 87 | match &response { 88 | ResponseData { 89 | data: ResponseValue::Value(value), 90 | .. 91 | } if value.is_null() => bail!("{response:?}"), 92 | ResponseData { 93 | data: ResponseValue::Value(value), 94 | .. 95 | } => { 96 | return Ok(value["url"] 97 | .as_str() 98 | .ok_or(anyhow::anyhow!("cover_up error"))? 99 | .into()) 100 | } 101 | _ => { 102 | unreachable!() 103 | } 104 | }; 105 | } 106 | 107 | /// 上传单个分P 108 | pub async fn upload_video_part

( 109 | &self, 110 | video: P, 111 | total_size: usize, 112 | sx: Sender, 113 | part_name: Option, 114 | ) -> anyhow::Result 115 | where 116 | P: AsRef, 117 | { 118 | let mut part = self.line.upload(self, video, total_size, sx).await?; 119 | if let Some(name) = part_name { 120 | part.title = Some(name); 121 | } 122 | Ok(part) 123 | } 124 | 125 | /// 查看现有投稿信息 126 | pub async fn get_video(&self, id: &VideoId) -> anyhow::Result { 127 | let id = match id { 128 | VideoId::AId(aid) => format!("aid={aid}"), 129 | VideoId::BVId(bvid) => format!("bvid={bvid}"), 130 | }; 131 | 132 | let ret: serde_json::Value = self 133 | .client 134 | .get(format!( 135 | "https://member.bilibili.com/x/client/archive/view?{id}" 136 | )) 137 | .send() 138 | .await? 139 | .json() 140 | .await?; 141 | 142 | if ret["code"] != 0 { 143 | bail!("{:?}", ret); 144 | } 145 | 146 | let ret = &ret["data"]; 147 | let data = &ret["archive"]; 148 | let video = EditVideo { 149 | aid: data["aid"].as_u64().unwrap(), 150 | copyright: data["copyright"].as_i64().unwrap(), 151 | source: data["source"].as_str().unwrap().into(), 152 | tid: data["tid"].as_u64().unwrap() as u16, 153 | cover: data["cover"].as_str().unwrap().into(), 154 | title: data["title"].as_str().unwrap().into(), 155 | desc_format_id: data["desc_format_id"].as_i64().unwrap(), 156 | desc: data["desc"].as_str().unwrap().into(), 157 | dynamic: data["dynamic"].as_str().unwrap().into(), 158 | tag: data["tag"].as_str().unwrap().into(), 159 | videos: ret["videos"] 160 | .as_array() 161 | .unwrap() 162 | .iter() 163 | .map(|video| EditVideoPart { 164 | title: Some(video["title"].as_str().unwrap().into()), 165 | filename: video["filename"].as_str().unwrap().into(), 166 | desc: video["desc"].as_str().unwrap().into(), 167 | cid: Some(video["cid"].as_u64().unwrap()), 168 | duration: video["duration"].as_u64().unwrap(), 169 | }) 170 | .collect(), 171 | display_time: data["dtime"].as_i64(), 172 | }; 173 | Ok(video) 174 | } 175 | 176 | /// 投稿 177 | pub async fn submit(&self, form: &Video) -> anyhow::Result<()> { 178 | let ret: serde_json::Value = self 179 | .client 180 | .post(format!( 181 | "https://member.bilibili.com/x/vu/client/add?access_key={}", 182 | self.credential.token_info.access_token 183 | )) 184 | .json(&form) 185 | .send() 186 | .await? 187 | .json() 188 | .await?; 189 | if ret["code"] == 0 { 190 | // Ok(ret) 191 | Ok(()) 192 | } else { 193 | bail!("{}", ret) 194 | } 195 | } 196 | 197 | /// 修改现有投稿 198 | pub async fn submit_edit(&self, form: &EditVideo) -> anyhow::Result<()> { 199 | let ret: serde_json::Value = self 200 | .client 201 | .post(format!( 202 | "https://member.bilibili.com/x/vu/client/edit?access_key={}", 203 | self.credential.token_info.access_token 204 | )) 205 | .json(&form) 206 | .send() 207 | .await? 208 | .json() 209 | .await?; 210 | if ret["code"] == 0 { 211 | // Ok(ret) 212 | Ok(()) 213 | } else { 214 | bail!("{}", ret) 215 | } 216 | } 217 | 218 | /// 修改投稿分段章节 219 | pub async fn edit_card( 220 | &self, 221 | aid: u64, 222 | cid: u64, 223 | cards: Vec, 224 | permanent: bool, 225 | ) -> anyhow::Result<()> { 226 | let csrf = self.credential.cookie_info.get("bili_jct").unwrap(); 227 | let cards = serde_json::to_string(&cards)?; 228 | let response: serde_json::Value = self 229 | .client 230 | .post("https://member.bilibili.com/x/web/card/submit") 231 | .form(&json!({ 232 | "aid": aid, 233 | "cid": cid, 234 | "type": 2, // TODO: why 2? 235 | "cards": cards, 236 | "permanent": permanent, 237 | "csrf": csrf, 238 | })) 239 | .send() 240 | .await? 241 | .json() 242 | .await?; 243 | 244 | if response["code"] == 0 { 245 | Ok(()) 246 | } else { 247 | bail!("{}", response) 248 | } 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /ssup/src/constants.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | use parking_lot::RwLock; 3 | 4 | lazy_static! { 5 | pub(crate) static ref USER_AGENT: RwLock = RwLock::new(String::from( 6 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/63.0.3239.108" 7 | )); 8 | pub(crate) static ref CONCURRENCY: RwLock = RwLock::new(3); 9 | } 10 | 11 | /// 设置所有请求中使用的 User-Agent 12 | pub fn set_useragent(user_agent: String) { 13 | *USER_AGENT.write() = user_agent; 14 | } 15 | 16 | /// 设置分P上传的并发数 17 | pub fn set_concurrency(concurrency: usize) { 18 | if concurrency > 0 { 19 | *CONCURRENCY.write() = concurrency; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ssup/src/credential.rs: -------------------------------------------------------------------------------- 1 | use anyhow::bail; 2 | use cookie::Cookie; 3 | use md5::{Digest, Md5}; 4 | use serde::{Deserialize, Serialize}; 5 | use serde_json::{json, Value}; 6 | use std::str::FromStr; 7 | use std::time::{Duration, SystemTime, UNIX_EPOCH}; 8 | 9 | /// 存储用户的登录信息 10 | #[derive(Serialize, Deserialize, Debug)] 11 | pub struct Credential { 12 | #[serde(default)] 13 | login_time: u64, 14 | pub(crate) cookie_info: CookieInfo, 15 | pub(crate) sso: Vec, 16 | pub(crate) token_info: TokenInfo, 17 | } 18 | 19 | impl Credential { 20 | pub async fn get_qrcode() -> anyhow::Result { 21 | let mut form = json!({ 22 | "appkey": "4409e2ce8ffd12b8", 23 | "local_id": "0", 24 | "ts": SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() 25 | }); 26 | let urlencoded = serde_urlencoded::to_string(&form)?; 27 | let sign = Credential::sign(&urlencoded, "59b43e04ad6965f34319062b478f83dd"); 28 | form["sign"] = Value::from(sign); 29 | Ok(reqwest::Client::new() 30 | .post("http://passport.bilibili.com/x/passport-tv-login/qrcode/auth_code") 31 | .form(&form) 32 | .send() 33 | .await? 34 | .json() 35 | .await?) 36 | } 37 | 38 | fn sign(param: &str, app_sec: &str) -> String { 39 | let mut hasher = Md5::new(); 40 | hasher.update(format!("{param}{app_sec}")); 41 | format!("{:x}", hasher.finalize()) 42 | } 43 | 44 | pub async fn from_qrcode(value: Value) -> anyhow::Result { 45 | let mut form = json!({ 46 | "appkey": "4409e2ce8ffd12b8", 47 | "local_id": "0", 48 | "auth_code": value["data"]["auth_code"], 49 | "ts": SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() 50 | }); 51 | let urlencoded = serde_urlencoded::to_string(&form)?; 52 | let sign = Credential::sign(&urlencoded, "59b43e04ad6965f34319062b478f83dd"); 53 | form["sign"] = Value::from(sign); 54 | loop { 55 | tokio::time::sleep(Duration::from_secs(1)).await; 56 | let res: ResponseData = reqwest::Client::new() 57 | .post("http://passport.bilibili.com/x/passport-tv-login/qrcode/poll") 58 | .form(&form) 59 | .send() 60 | .await? 61 | .json() 62 | .await?; 63 | match res { 64 | ResponseData { 65 | code: 0, 66 | data: ResponseValue::Login(mut info), 67 | .. 68 | } => { 69 | if info.login_time == 0 { 70 | info.login_time = SystemTime::now() 71 | .duration_since(UNIX_EPOCH) 72 | .unwrap() 73 | .as_secs(); 74 | } 75 | return Ok(info); 76 | } 77 | ResponseData { code: 86039, .. } => { 78 | // 二维码尚未确认; 79 | // form["ts"] = Value::from(SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()); 80 | } 81 | _ => { 82 | bail!("{:#?}", res) 83 | } 84 | } 85 | } 86 | } 87 | 88 | pub async fn get_nickname(&self) -> anyhow::Result { 89 | let response: ResponseData = reqwest::Client::new() 90 | .get("https://api.bilibili.com/x/web-interface/nav") 91 | .header("Cookie", self.cookie_info.to_string()) 92 | .send() 93 | .await? 94 | .json() 95 | .await?; 96 | if response.code != 0 { 97 | bail!("{:#?}", response) 98 | } 99 | match response.data { 100 | ResponseValue::Value(data) => Ok(data["uname"].as_str().unwrap().to_string()), 101 | _ => unreachable!(), 102 | } 103 | } 104 | 105 | pub async fn from_cookies(cookies: &CookieInfo) -> anyhow::Result { 106 | let qrcode = Self::get_qrcode().await?; 107 | let form = json!({ 108 | "auth_code": qrcode["data"]["auth_code"], 109 | "csrf": cookies.get("bili_jct").unwrap(), 110 | "scanning_type": 3, 111 | }); 112 | let response: ResponseData = reqwest::Client::new() 113 | .post("https://passport.snm0516.aisee.tv/x/passport-tv-login/h5/qrcode/confirm") 114 | .header("Cookie", cookies.to_string()) 115 | .form(&form) 116 | .send() 117 | .await? 118 | .json() 119 | .await?; 120 | if response.code != 0 { 121 | bail!("{:#?}", response) 122 | } 123 | 124 | Self::from_qrcode(qrcode).await 125 | } 126 | 127 | fn need_refresh(&self) -> bool { 128 | // Token过期前30天内重新获取 129 | (self.login_time + self.token_info.expires_in as u64) 130 | < (SystemTime::now() 131 | .duration_since(UNIX_EPOCH) 132 | .unwrap() 133 | .as_secs() 134 | + 30 * 86400) 135 | } 136 | 137 | pub async fn refresh(&mut self, force: bool) -> anyhow::Result { 138 | if force || self.need_refresh() { 139 | let refreshed = Credential::from_cookies(&self.cookie_info).await?; 140 | self.login_time = refreshed.login_time; 141 | self.cookie_info = refreshed.cookie_info; 142 | self.token_info = refreshed.token_info; 143 | self.sso = refreshed.sso; 144 | Ok(true) 145 | } else { 146 | Ok(false) 147 | } 148 | } 149 | } 150 | 151 | /// 存储 Cookie 信息 152 | #[derive(Serialize, Deserialize, Debug)] 153 | pub struct CookieInfo { 154 | pub(crate) cookies: Vec, 155 | } 156 | 157 | impl CookieInfo { 158 | pub fn new(cookies: Vec) -> Self { 159 | Self { cookies } 160 | } 161 | 162 | pub fn get(&self, key: &str) -> Option<&str> { 163 | self.cookies 164 | .iter() 165 | .find(|entry| entry.name == key) 166 | .map(|entry| entry.value.as_str()) 167 | } 168 | } 169 | 170 | impl ToString for CookieInfo { 171 | fn to_string(&self) -> String { 172 | self.cookies 173 | .iter() 174 | .map(|entry| entry.to_string()) 175 | .collect::>() 176 | .join("; ") 177 | } 178 | } 179 | 180 | /// Cookie 项 181 | #[derive(Serialize, Deserialize, Debug)] 182 | pub struct CookieEntry { 183 | name: String, 184 | value: String, 185 | } 186 | 187 | impl CookieEntry { 188 | pub(crate) fn to_cookie(&self) -> Cookie { 189 | Cookie::build(self.name.clone(), self.value.clone()) 190 | .domain("bilibili.com") 191 | .finish() 192 | } 193 | } 194 | 195 | impl FromStr for CookieEntry { 196 | type Err = anyhow::Error; 197 | 198 | fn from_str(s: &str) -> Result { 199 | let mut parts = s.splitn(2, '='); 200 | let name = parts 201 | .next() 202 | .ok_or_else(|| anyhow::anyhow!("CookieEntry::from_str: no name"))?; 203 | let value = parts 204 | .next() 205 | .ok_or_else(|| anyhow::anyhow!("CookieEntry::from_str: no value"))?; 206 | Ok(Self { 207 | name: name.to_string(), 208 | value: value.to_string(), 209 | }) 210 | } 211 | } 212 | 213 | impl ToString for CookieEntry { 214 | fn to_string(&self) -> String { 215 | format!("{}={}", self.name, self.value) 216 | } 217 | } 218 | 219 | #[derive(Deserialize, Serialize, Debug)] 220 | pub(crate) struct TokenInfo { 221 | pub(crate) access_token: String, 222 | expires_in: u32, 223 | mid: u32, 224 | refresh_token: String, 225 | } 226 | 227 | #[derive(Deserialize, Debug)] 228 | pub(crate) struct ResponseData { 229 | pub(crate) code: i32, 230 | pub(crate) data: ResponseValue, 231 | pub(crate) message: String, 232 | ttl: i32, 233 | } 234 | 235 | #[derive(Deserialize, Debug)] 236 | #[serde(untagged)] 237 | pub(crate) enum ResponseValue { 238 | Login(Credential), 239 | Value(serde_json::Value), 240 | } 241 | -------------------------------------------------------------------------------- /ssup/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod client; 2 | pub mod constants; 3 | mod credential; 4 | mod line; 5 | mod uploader; 6 | pub mod video; 7 | 8 | pub use client::Client; 9 | pub use credential::{CookieEntry, CookieInfo, Credential}; 10 | pub use line::UploadLine; 11 | pub use video::VideoId; 12 | -------------------------------------------------------------------------------- /ssup/src/line.rs: -------------------------------------------------------------------------------- 1 | use crate::client::Client; 2 | use crate::uploader::*; 3 | use crate::video::VideoPart; 4 | use anyhow::bail; 5 | use futures::TryStreamExt; 6 | use serde::de::DeserializeOwned; 7 | use serde::{Deserialize, Serialize}; 8 | use serde_json::json; 9 | use std::path::Path; 10 | use std::time::Instant; 11 | use tokio::sync::mpsc::Sender; 12 | 13 | #[derive(Serialize, Deserialize)] 14 | #[serde(rename_all = "lowercase")] 15 | pub enum Uploader { 16 | Upos, 17 | Kodo, 18 | Bos, 19 | Gcs, 20 | Cos, 21 | } 22 | 23 | impl Uploader { 24 | fn profile(&self) -> &'static str { 25 | if let Uploader::Upos = self { 26 | "ugcupos/bup" 27 | } else { 28 | "ugcupos/bupfetch" 29 | } 30 | } 31 | } 32 | 33 | /// 上传线路 34 | #[derive(Deserialize)] 35 | pub struct UploadLine { 36 | os: Uploader, 37 | probe_url: String, 38 | query: String, 39 | #[serde(skip)] 40 | cost: u128, 41 | } 42 | 43 | impl UploadLine { 44 | pub fn probe_url(&self) -> &str { 45 | &self.probe_url 46 | } 47 | 48 | pub(crate) async fn pre_upload( 49 | &self, 50 | client: &Client, 51 | file_path: P, 52 | total_size: usize, 53 | ) -> anyhow::Result 54 | where 55 | T: DeserializeOwned, 56 | P: AsRef, 57 | { 58 | let file_name = file_path 59 | .as_ref() 60 | .file_name() 61 | .ok_or("No filename") 62 | .unwrap() 63 | .to_str(); 64 | 65 | let query: serde_json::Value = json!({ 66 | "r": self.os, 67 | "profile": self.os.profile(), 68 | "ssl": 0u8, 69 | "version": "2.10.4", 70 | "build": 2100400, 71 | "name": file_name, 72 | "size": total_size, 73 | }); 74 | log::debug!("Pre uploading with query: {}", query); 75 | Ok(client 76 | .client 77 | .get(format!( 78 | "https://member.bilibili.com/preupload?{}", 79 | self.query 80 | )) 81 | .query(&query) 82 | .send() 83 | .await? 84 | .json() 85 | .await?) 86 | } 87 | 88 | pub(crate) async fn upload

( 89 | &self, 90 | client: &Client, 91 | file_path: P, 92 | total_size: usize, 93 | sx: Sender, 94 | ) -> anyhow::Result 95 | where 96 | P: AsRef, 97 | { 98 | match self.os { 99 | Uploader::Upos => { 100 | log::debug!("Uploading with upos"); 101 | let bucket = self 102 | .pre_upload(client, file_path.as_ref(), total_size) 103 | .await?; 104 | let upos = Upos::from(bucket).await?; 105 | 106 | let mut parts = Vec::new(); 107 | let stream = upos.upload_stream(file_path.as_ref()).await?; 108 | tokio::pin!(stream); 109 | 110 | while let Some((part, size)) = stream.try_next().await? { 111 | parts.push(part); 112 | sx.send(size).await?; 113 | } 114 | upos.get_ret_video_info(&parts, file_path.as_ref()).await 115 | } 116 | Uploader::Kodo => { 117 | log::debug!("Uploading with kodo"); 118 | let bucket = self 119 | .pre_upload(client, file_path.as_ref(), total_size) 120 | .await?; 121 | Kodo::from(bucket).await?.upload(file_path, sx).await 122 | } 123 | _ => unimplemented!(), 124 | } 125 | } 126 | 127 | /// 挑选条件最好的线路 128 | pub async fn auto() -> anyhow::Result { 129 | #[derive(Deserialize)] 130 | struct ProbeResponse { 131 | #[serde(rename = "OK")] 132 | ok: u8, 133 | lines: Vec, 134 | probe: serde_json::Value, 135 | } 136 | let res: ProbeResponse = reqwest::get("https://member.bilibili.com/preupload?r=probe") 137 | .await? 138 | .json() 139 | .await?; 140 | if res.ok != 1 { 141 | bail!("TODO in line.rs"); 142 | } 143 | 144 | let do_probe = if !res.probe["get"].is_null() { 145 | |url| reqwest::Client::new().get(url) 146 | } else { 147 | |url| { 148 | reqwest::Client::new() 149 | .post(url) 150 | .body(vec![0; (1024. * 0.1 * 1024.) as usize]) 151 | } 152 | }; 153 | let mut line_chosen: UploadLine = Default::default(); 154 | for mut line in res.lines { 155 | let instant = Instant::now(); 156 | if do_probe(format!("https:{}", line.probe_url)) 157 | .send() 158 | .await? 159 | .status() 160 | == 200 161 | { 162 | line.cost = instant.elapsed().as_millis(); 163 | if line_chosen.cost > line.cost { 164 | line_chosen = line 165 | } 166 | }; 167 | } 168 | Ok(line_chosen) 169 | } 170 | 171 | pub fn kodo() -> Self { 172 | Self { 173 | os: Uploader::Kodo, 174 | probe_url: "//up-na0.qbox.me/crossdomain.xml".to_string(), 175 | query: "bucket=bvcupcdnkodobm&probe_version=20211012".to_string(), 176 | cost: 0, 177 | } 178 | } 179 | 180 | pub fn bda2() -> Self { 181 | Self { 182 | os: Uploader::Upos, 183 | probe_url: "//upos-sz-upcdnbda2.bilivideo.com/OK".to_string(), 184 | query: "upcdn=bda2&probe_version=20211012".to_string(), 185 | cost: 0, 186 | } 187 | } 188 | 189 | pub fn ws() -> Self { 190 | Self { 191 | os: Uploader::Upos, 192 | probe_url: "//upos-sz-upcdnws.bilivideo.com/OK".to_string(), 193 | query: "upcdn=ws&probe_version=20211012".to_string(), 194 | cost: 0, 195 | } 196 | } 197 | 198 | pub fn qn() -> Self { 199 | Self { 200 | os: Uploader::Upos, 201 | probe_url: "//upos-sz-upcdnqn.bilivideo.com/OK".to_string(), 202 | query: "upcdn=qn&probe_version=20211012".to_string(), 203 | cost: 0, 204 | } 205 | } 206 | } 207 | 208 | impl Default for UploadLine { 209 | fn default() -> Self { 210 | let cost = u128::MAX; 211 | Self { 212 | cost, 213 | ..UploadLine::bda2() 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /ssup/src/uploader/kodo.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::{CONCURRENCY, USER_AGENT}; 2 | use crate::uploader::utils::read_chunk; 3 | use crate::video::VideoPart; 4 | use anyhow::{anyhow, bail}; 5 | use futures::{StreamExt, TryStreamExt}; 6 | use reqwest::header::{HeaderMap, HeaderName}; 7 | use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; 8 | use reqwest_retry::policies::ExponentialBackoff; 9 | use reqwest_retry::RetryTransientMiddleware; 10 | use serde::{Deserialize, Serialize}; 11 | use std::collections::HashMap; 12 | use std::path::Path; 13 | use std::str::FromStr; 14 | use std::time::Duration; 15 | use tokio::fs::File; 16 | use tokio::sync::mpsc::Sender; 17 | 18 | pub struct Kodo { 19 | client: ClientWithMiddleware, 20 | bucket: KodoBucket, 21 | url: String, 22 | } 23 | 24 | impl Kodo { 25 | pub async fn from(bucket: KodoBucket) -> anyhow::Result { 26 | let mut headers = HeaderMap::new(); 27 | headers.insert( 28 | "Authorization", 29 | format!("UpToken {}", bucket.uptoken).parse()?, 30 | ); 31 | let client = reqwest::Client::builder() 32 | .user_agent(USER_AGENT.read().as_str()) 33 | .default_headers(headers) 34 | .timeout(Duration::new(60, 0)) 35 | .build() 36 | .unwrap(); 37 | let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3); 38 | let client = ClientBuilder::new(client) 39 | .with(RetryTransientMiddleware::new_with_policy(retry_policy)) 40 | .build(); 41 | let url = format!("https:{}/mkblk", bucket.endpoint); // 视频上传路径 42 | Ok(Kodo { 43 | client, 44 | bucket, 45 | url, 46 | }) 47 | } 48 | 49 | pub async fn upload

(self, path: P, sx: Sender) -> anyhow::Result 50 | where 51 | P: AsRef, 52 | { 53 | let file = File::open(path.as_ref()).await?; 54 | let total_size = file.metadata().await?.len(); 55 | let chunk_size = 4194304; 56 | let mut parts = Vec::new(); 57 | 58 | let client = &self.client; 59 | let url = &self.url; 60 | 61 | let stream = read_chunk(file, chunk_size) 62 | .enumerate() 63 | .map(|(i, chunk)| async move { 64 | let chunk = chunk?; 65 | let len = chunk.len(); 66 | let url = format!("{url}/{len}"); 67 | let ctx: serde_json::Value = 68 | client.post(url).body(chunk).send().await?.json().await?; 69 | Ok::<_, reqwest_middleware::Error>(( 70 | Ctx { 71 | index: i, 72 | ctx: ctx["ctx"].as_str().unwrap_or_default().into(), 73 | }, 74 | len, 75 | )) 76 | }) 77 | .buffer_unordered(*CONCURRENCY.read()); 78 | tokio::pin!(stream); 79 | while let Some((part, size)) = stream.try_next().await? { 80 | parts.push(part); 81 | sx.send(size).await?; 82 | } 83 | parts.sort_by_key(|x| x.index); 84 | let key = base64::encode_config(self.bucket.key, base64::URL_SAFE); 85 | self.client 86 | .post(format!( 87 | "https:{}/mkfile/{total_size}/key/{key}", 88 | self.bucket.endpoint, 89 | )) 90 | .body( 91 | parts 92 | .iter() 93 | .map(|x| &x.ctx[..]) 94 | .collect::>() 95 | .join(","), 96 | ) 97 | .send() 98 | .await?; 99 | let mut headers = HeaderMap::new(); 100 | for (key, value) in self.bucket.fetch_headers { 101 | headers.insert(HeaderName::from_str(&key)?, value.parse()?); 102 | } 103 | let result: serde_json::Value = self 104 | .client 105 | .post(format!("https:{}", self.bucket.fetch_url)) 106 | .headers(headers) 107 | .send() 108 | .await? 109 | .json() 110 | .await?; 111 | Ok(match result.get("OK") { 112 | Some(x) if x.as_i64().ok_or(anyhow!("kodo fetch err"))? != 1 => { 113 | bail!("{result}") 114 | } 115 | _ => VideoPart { 116 | title: path 117 | .as_ref() 118 | .file_stem() 119 | .map(|x| x.to_string_lossy().into_owned()), 120 | filename: self.bucket.bili_filename, 121 | desc: "".into(), 122 | }, 123 | }) 124 | } 125 | } 126 | 127 | struct Ctx { 128 | index: usize, 129 | ctx: String, 130 | } 131 | 132 | #[derive(Serialize, Deserialize, Debug)] 133 | pub struct KodoBucket { 134 | bili_filename: String, 135 | fetch_url: String, 136 | endpoint: String, 137 | uptoken: String, 138 | key: String, 139 | fetch_headers: HashMap, 140 | } 141 | -------------------------------------------------------------------------------- /ssup/src/uploader/mod.rs: -------------------------------------------------------------------------------- 1 | mod kodo; 2 | mod upos; 3 | pub(crate) mod utils; 4 | 5 | pub(crate) use kodo::*; 6 | pub(crate) use upos::*; 7 | -------------------------------------------------------------------------------- /ssup/src/uploader/upos.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::{CONCURRENCY, USER_AGENT}; 2 | use crate::uploader::utils::read_chunk; 3 | use crate::video::VideoPart; 4 | use anyhow::{anyhow, bail}; 5 | use futures::{Stream, StreamExt, TryStreamExt}; 6 | use reqwest::header::{HeaderMap, HeaderValue}; 7 | use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; 8 | use reqwest_retry::policies::ExponentialBackoff; 9 | use reqwest_retry::RetryTransientMiddleware; 10 | use serde::{Deserialize, Serialize}; 11 | use serde_json::{json, Value}; 12 | use std::ffi::OsStr; 13 | use std::path::Path; 14 | use std::time::Duration; 15 | use tokio::sync::mpsc::Sender; 16 | 17 | pub struct Upos { 18 | client: ClientWithMiddleware, 19 | bucket: UposBucket, 20 | url: String, 21 | upload_id: String, 22 | } 23 | 24 | #[derive(Serialize, Deserialize, Debug)] 25 | pub struct UposBucket { 26 | chunk_size: usize, 27 | auth: String, 28 | endpoint: String, 29 | biz_id: usize, 30 | upos_uri: String, 31 | } 32 | 33 | #[derive(Serialize, Deserialize, Debug)] 34 | #[serde(rename_all = "camelCase")] 35 | pub struct Protocol<'a> { 36 | upload_id: &'a str, 37 | chunks: usize, 38 | total: u64, 39 | chunk: usize, 40 | size: usize, 41 | part_number: usize, 42 | start: usize, 43 | end: usize, 44 | } 45 | 46 | impl Upos { 47 | pub async fn from(bucket: UposBucket) -> anyhow::Result { 48 | let mut headers = HeaderMap::new(); 49 | headers.insert("X-Upos-Auth", HeaderValue::from_str(&bucket.auth)?); 50 | let client = reqwest::Client::builder() 51 | .user_agent(USER_AGENT.read().as_str()) 52 | .default_headers(headers) 53 | .timeout(Duration::new(300, 0)) 54 | .build() 55 | .unwrap(); 56 | let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3); 57 | let client = ClientBuilder::new(client) 58 | .with(RetryTransientMiddleware::new_with_policy(retry_policy)) 59 | .build(); 60 | let url = format!( 61 | "https:{}/{}", 62 | bucket.endpoint, 63 | bucket.upos_uri.replace("upos://", "") 64 | ); 65 | let ret: serde_json::Value = client 66 | .post(format!("{url}?uploads&output=json")) 67 | .send() 68 | .await? 69 | .json() 70 | .await?; 71 | let upload_id = ret["upload_id"].as_str().unwrap().into(); 72 | Ok(Upos { 73 | client, 74 | bucket, 75 | url, 76 | upload_id, 77 | }) 78 | } 79 | 80 | pub(crate) async fn upload_stream

( 81 | &self, 82 | file_path: P, 83 | ) -> anyhow::Result> + '_> 84 | where 85 | P: AsRef, 86 | { 87 | let file = tokio::fs::File::open(file_path.as_ref()).await?; 88 | 89 | let total_size = file.metadata().await?.len(); 90 | let chunk_size = self.bucket.chunk_size; 91 | let chunks_num = (total_size as f64 / chunk_size as f64).ceil() as usize; // 获取分块数量 92 | 93 | let client = &self.client; 94 | let url = &self.url; 95 | let upload_id = &*self.upload_id; 96 | let stream = read_chunk(file, chunk_size) 97 | .enumerate() 98 | .map(move |(i, chunk)| async move { 99 | let chunk = chunk?; 100 | let len = chunk.len(); 101 | let params = Protocol { 102 | upload_id, 103 | chunks: chunks_num, 104 | total: total_size, 105 | chunk: i, 106 | size: len, 107 | part_number: i + 1, 108 | start: i * chunk_size, 109 | end: i * chunk_size + len, 110 | }; 111 | 112 | let response = client.put(url).query(¶ms).body(chunk).send().await?; 113 | response.error_for_status()?; 114 | 115 | Ok::<_, anyhow::Error>(( 116 | json!({"partNumber": params.chunk + 1, "eTag": "etag"}), 117 | len, 118 | )) 119 | }) 120 | .buffer_unordered(*CONCURRENCY.read()); 121 | Ok(stream) 122 | } 123 | 124 | // TODO 125 | pub async fn upload

(&self, path: P, sx: Sender) -> anyhow::Result 126 | where 127 | P: AsRef, 128 | { 129 | let parts: Vec<_> = self 130 | .upload_stream(path.as_ref()) 131 | .await? 132 | .map(|union| Ok::<_, reqwest_middleware::Error>(union?.0)) 133 | .try_collect() 134 | .await?; 135 | self.get_ret_video_info(&parts, path).await 136 | } 137 | 138 | pub(crate) async fn get_ret_video_info

( 139 | &self, 140 | parts: &[Value], 141 | path: P, 142 | ) -> anyhow::Result 143 | where 144 | P: AsRef, 145 | { 146 | let value = json!({ 147 | "name": path.as_ref().file_name().and_then(OsStr::to_str), 148 | "uploadId": self.upload_id, 149 | "biz_id": self.bucket.biz_id, 150 | "output": "json", 151 | "profile": "ugcupos/bup" 152 | }); 153 | let res: serde_json::Value = self 154 | .client 155 | .post(&self.url) 156 | .query(&value) 157 | .json(&json!({ "parts": parts })) 158 | .send() 159 | .await? 160 | .json() 161 | .await?; 162 | if res["OK"] != 1 { 163 | bail!("{}", res) 164 | } 165 | Ok(VideoPart { 166 | title: path 167 | .as_ref() 168 | .file_stem() 169 | .map(|p| p.to_string_lossy().to_string()), 170 | filename: Path::new(&self.bucket.upos_uri) 171 | .file_stem() 172 | .ok_or(anyhow!("no file stem found"))? 173 | .to_string_lossy() 174 | .to_string(), 175 | desc: "".to_string(), 176 | }) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /ssup/src/uploader/utils.rs: -------------------------------------------------------------------------------- 1 | use async_stream::try_stream; 2 | use bytes::{BufMut, Bytes, BytesMut}; 3 | use futures::Stream; 4 | use tokio::fs::File; 5 | use tokio::io::AsyncReadExt; 6 | 7 | pub(crate) fn read_chunk( 8 | mut file: File, 9 | chunk_size: usize, 10 | ) -> impl Stream> { 11 | let mut buffer = vec![0u8; chunk_size]; 12 | 13 | let mut buf = BytesMut::with_capacity(chunk_size); 14 | try_stream! { 15 | loop { 16 | let n = file.read(&mut buffer).await?; 17 | let remaining = chunk_size - buf.len(); 18 | if remaining >= n { 19 | buf.put_slice(&buffer[..n]); 20 | } else { 21 | buf.put_slice(&buffer[..remaining]); 22 | yield buf.split().freeze(); 23 | buf.put_slice(&buffer[remaining..n]); 24 | } 25 | if n == 0 { 26 | yield buf.split().freeze(); 27 | return; 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ssup/src/video.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::num::ParseIntError; 3 | use std::str::FromStr; 4 | 5 | /// 视频 6 | #[derive(Serialize, Debug)] 7 | pub struct Video { 8 | /// 1 为自制,2 为转载 9 | pub copyright: i64, 10 | pub source: String, 11 | /// 分区号 12 | pub tid: i64, 13 | /// 封面链接 14 | pub cover: String, 15 | pub title: String, 16 | /// 为 0 17 | pub desc_format_id: i64, 18 | /// 描述 19 | pub desc: String, 20 | /// 动态文本 21 | pub dynamic: String, 22 | pub subtitle: Subtitle, 23 | /// 由 `,` 连接的 Tag 24 | pub tag: String, 25 | /// 分P 26 | pub videos: Vec, 27 | /// 秒为单位的定时投稿时间 28 | #[serde(rename = "dtime")] 29 | pub display_time: Option, 30 | pub open_subtitle: bool, 31 | } 32 | 33 | #[derive(Serialize, Debug)] 34 | pub struct Subtitle { 35 | pub open: i8, 36 | pub lan: String, 37 | } 38 | 39 | /// 分P 40 | #[derive(Serialize, Deserialize, Debug)] 41 | pub struct VideoPart { 42 | pub title: Option, 43 | pub filename: String, 44 | pub desc: String, 45 | } 46 | 47 | /// 视频 ID 48 | #[derive(Clone, Debug)] 49 | pub enum VideoId { 50 | AId(u64), 51 | BVId(String), 52 | } 53 | 54 | impl FromStr for VideoId { 55 | type Err = String; 56 | 57 | fn from_str(s: &str) -> Result { 58 | let s = s.trim(); 59 | 60 | if s.starts_with("av") { 61 | // av{number} 62 | Ok(VideoId::AId( 63 | s[2..].parse().map_err(|e: ParseIntError| e.to_string())?, 64 | )) 65 | } else if s.starts_with("BV") { 66 | // BV1kS4y1P7vA 67 | Ok(VideoId::BVId(s.to_string())) 68 | } else { 69 | // {number} 70 | Ok(VideoId::AId( 71 | s.parse().map_err(|e: ParseIntError| e.to_string())?, 72 | )) 73 | } 74 | } 75 | } 76 | 77 | #[derive(Serialize, Debug)] 78 | pub struct EditVideo { 79 | /// 视频 ID 80 | pub aid: u64, 81 | 82 | /// 1 为自制,2 为转载 83 | pub copyright: i64, 84 | pub source: String, 85 | /// 分区号 86 | pub tid: u16, 87 | /// 封面链接 88 | pub cover: String, 89 | pub title: String, 90 | /// 为 0 91 | pub desc_format_id: i64, 92 | /// 描述 93 | pub desc: String, 94 | /// 动态文本 95 | pub dynamic: String, 96 | /// 由 `,` 连接的 Tag 97 | pub tag: String, 98 | /// 分P 99 | pub videos: Vec, 100 | /// 秒为单位的定时投稿时间 101 | #[serde(rename = "dtime")] 102 | pub display_time: Option, 103 | } 104 | 105 | #[derive(Serialize, Deserialize, Debug)] 106 | pub struct EditVideoPart { 107 | pub title: Option, 108 | pub filename: String, 109 | pub desc: String, 110 | 111 | #[serde(skip_serializing_if = "Option::is_none")] 112 | pub cid: Option, 113 | 114 | #[serde(skip_serializing)] 115 | pub duration: u64, 116 | } 117 | 118 | impl From for EditVideoPart { 119 | fn from(v: VideoPart) -> Self { 120 | EditVideoPart { 121 | title: v.title, 122 | filename: v.filename, 123 | desc: v.desc, 124 | cid: None, 125 | duration: 0, 126 | } 127 | } 128 | } 129 | 130 | #[derive(Serialize, Deserialize, Debug)] 131 | pub struct VideoCardItem { 132 | /// 开始时间,单位为秒 133 | pub from: u64, 134 | /// 结束时间,单位为秒 135 | pub to: u64, 136 | /// 章节备注说明 137 | pub content: String, 138 | } 139 | -------------------------------------------------------------------------------- /sswa/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sswa" 3 | version = "0.2.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | ssup = { path = "../ssup" } 8 | tinytemplate = { path = "../tinytemplate" } 9 | 10 | clap = { version = "3.0.14", features = ["derive"] } 11 | clap-handler = { version = "0.1.1", features = ["async"] } 12 | directories-next = "2.0.0" 13 | tempfile = "3.3.0" 14 | 15 | tokio = { version = "1.17.0", features = ["rt", "rt-multi-thread", "macros"] } 16 | 17 | serde = { version = "1.0", features = ["derive"] } 18 | serde_json = "1.0" 19 | toml = "0.5.8" 20 | anyhow = "1" 21 | log = "0.4.14" 22 | 23 | requestty = "0.4.0" 24 | indicatif = { version = "=0.17.0-rc.10", features = ["improved_unicode"] } 25 | regex = "1" 26 | rand = "0.8.4" 27 | date_time_parser = "0.1.1" 28 | chrono = "0.4" 29 | lazy_static = "1.4.0" 30 | parking_lot = "0.12.0" 31 | -------------------------------------------------------------------------------- /sswa/src/args.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | use crate::context::CONTEXT; 3 | use crate::ffmpeg; 4 | use crate::template::VideoTemplate; 5 | use anyhow::{bail, Context}; 6 | use clap::Parser; 7 | use clap_handler::{handler, Context as ClapContext, Handler}; 8 | use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; 9 | use rand::Rng; 10 | use serde_json::Value; 11 | use ssup::constants::set_useragent; 12 | use ssup::video::{VideoCardItem, VideoPart}; 13 | use ssup::{Client, CookieEntry, CookieInfo, Credential, VideoId}; 14 | use std::collections::HashMap; 15 | use std::num::ParseIntError; 16 | use std::path::PathBuf; 17 | use std::str::FromStr; 18 | use tokio::fs; 19 | 20 | #[derive(Parser, Clone)] 21 | pub(crate) struct Args { 22 | /// 配置文件所在的目录,留空时默认通过 directories-next 获取 23 | #[clap(short, long)] 24 | config_root: Option, 25 | 26 | /// 手动指定投稿时的 User-Agent 27 | #[clap(long = "ua")] 28 | user_agent: Option, 29 | 30 | /// 投稿帐号 31 | #[clap(short = 'u', long = "user", global = true)] 32 | account: Option, 33 | 34 | /// 执行的子命令 35 | #[clap(subcommand)] 36 | command: SsCommand, 37 | } 38 | 39 | #[clap_handler::async_trait] 40 | impl Handler for Args { 41 | async fn handle_command(&mut self, ctx: &mut ClapContext) -> anyhow::Result<()> { 42 | // 初始化配置文件目录 43 | let config_root = self 44 | .config_root 45 | .as_deref() 46 | .and_then(|path| { 47 | if path.is_absolute() { 48 | Some(path.to_path_buf()) 49 | } else { 50 | path.canonicalize().ok() 51 | } 52 | }) 53 | .unwrap_or_else(|| { 54 | directories_next::ProjectDirs::from("moe.mmf", "Yesterday17", "sswa") 55 | .unwrap() 56 | .config_dir() 57 | .to_path_buf() 58 | }); 59 | // 创建配置文件目录 60 | let _ = fs::create_dir_all(&config_root).await?; 61 | let _ = fs::create_dir(config_root.join("templates")).await; 62 | let _ = fs::create_dir(config_root.join("accounts")).await; 63 | 64 | // 初始化读取配置文件 65 | let config: Config = match fs::read_to_string(config_root.join("config.toml")).await { 66 | Ok(config) => toml::from_str(&config)?, 67 | Err(_) => Config::new(), 68 | }; 69 | 70 | // 设置 User-Agent 71 | if let Some(ref user_agent) = self.user_agent { 72 | set_useragent(user_agent.to_string()); 73 | } 74 | 75 | ctx.insert(config_root); 76 | ctx.insert(config); 77 | Ok(()) 78 | } 79 | 80 | async fn handle_subcommand(&mut self, ctx: ClapContext) -> anyhow::Result<()> { 81 | self.command.execute(ctx).await 82 | } 83 | } 84 | 85 | #[derive(Parser, Handler, Clone)] 86 | pub(crate) enum SsCommand { 87 | /// 输出配置文件所在路径 88 | Config(SsConfigCommand), 89 | /// 上传视频 90 | Upload(SsUploadCommand), 91 | /// 增加分P 92 | Append(SsAppendCommand), 93 | /// 查看已投稿视频 94 | View(SsViewCommand), 95 | /// 修改视频分段章节 96 | Card(SsCardCommand), 97 | /// 帐号登录 98 | Login(SsAccountLoginCommand), 99 | /// 帐号登出 100 | Logout(SsAccountLogoutCommand), 101 | /// 列出已登录帐号 102 | Accounts(SsAccountListCommand), 103 | } 104 | 105 | #[derive(Parser, Clone)] 106 | pub(crate) struct SsConfigCommand; 107 | 108 | #[handler(SsConfigCommand)] 109 | async fn handle_config(config_root: &PathBuf) -> anyhow::Result<()> { 110 | print!("{}", config_root.display()); 111 | Ok(()) 112 | } 113 | 114 | #[derive(Parser, Clone)] 115 | pub(crate) struct SsUploadCommand { 116 | /// 投稿使用的模板 117 | #[clap(short, long)] 118 | template: String, 119 | 120 | /// 投稿模板对应的变量 121 | #[clap(short, long = "var")] 122 | variables: Vec, 123 | 124 | /// 变量文件 125 | #[clap(short = 'f', long = "variable-file")] 126 | variable_file: Option, 127 | 128 | /// 检查的等级 129 | /// 130 | /// 出现1次:跳过投稿信息确认 131 | /// 出现2次:跳过变量输入,当存在必填变量时会产生错误 132 | /// 出现3次:跳过所有变量输入且不产生错误 133 | #[clap(short = 'y', parse(from_occurrences))] 134 | skip_level: u8, 135 | 136 | /// 是否自动缩放封面到 960*600 137 | #[clap(long)] 138 | scale_cover: Option, 139 | 140 | /// 是否保留简单变量文件中值前后的引号(包括单引号和双引号) 141 | #[clap(short = 'q', long = "quotes")] 142 | keep_quote_pairs: bool, 143 | 144 | /// 是否模拟投稿 145 | /// 146 | /// 模拟投稿时,不会实际向叔叔服务器上传视频和封面 147 | #[clap(short, long)] 148 | dry_run: bool, 149 | 150 | /// 视频分p标题 151 | /// 当为空时自动选取视频文件名作为标题 152 | /// 不包含前缀和后缀 153 | #[clap(short, long)] 154 | names: Vec, 155 | 156 | /// 待投稿的视频 157 | #[clap(required = true)] 158 | videos: Vec, 159 | } 160 | 161 | /// 尝试导入用户凭据,失败时则以该名称创建新的凭据 162 | async fn credential( 163 | root: &PathBuf, 164 | account: Option<&str>, 165 | default_user: Option<&str>, 166 | ) -> anyhow::Result { 167 | let account_file = root.join("accounts").join(format!( 168 | "{}.json", 169 | account.or(default_user).expect("account not specified") 170 | )); 171 | if account_file.exists() { 172 | // 凭据存在,读取并返回 173 | let account = fs::read_to_string(&account_file).await?; 174 | let mut account: Credential = serde_json::from_str(&account)?; 175 | 176 | // 自动更新凭据 177 | let refreshed = account.refresh(false).await?; 178 | if refreshed { 179 | fs::write(&account_file, serde_json::to_string(&account)?).await?; 180 | } 181 | 182 | if let Ok(nickname) = account.get_nickname().await { 183 | eprintln!("投稿用户:{nickname}"); 184 | return Ok(account); 185 | } else { 186 | eprintln!("登录已失效!请重新登录。"); 187 | } 188 | } 189 | 190 | // 凭据不存在,新登录 191 | let qrcode = Credential::get_qrcode().await?; 192 | eprintln!( 193 | "请打开以下链接登录:\n{}", 194 | qrcode["data"]["url"].as_str().unwrap() 195 | ); 196 | let credential = Credential::from_qrcode(qrcode).await?; 197 | fs::write(account_file, serde_json::to_string(&credential)?).await?; 198 | Ok(credential) 199 | } 200 | 201 | async fn upload_videos( 202 | client: &Client, 203 | progress: &MultiProgress, 204 | videos: &[(PathBuf, &str)], 205 | dry_run: bool, 206 | ) -> anyhow::Result> { 207 | let mut parts = Vec::with_capacity(videos.len()); 208 | 209 | for (video, video_name) in videos { 210 | let metadata = tokio::fs::metadata(&video).await?; 211 | let total_size = metadata.len() as usize; 212 | 213 | let p_filename = progress.add(ProgressBar::new_spinner()); 214 | p_filename.set_message(format!("{}", video.file_name().unwrap().to_string_lossy())); 215 | let pb = progress.add(ProgressBar::new(total_size as u64)); 216 | let format = format!("{{spinner:.green}} [{{wide_bar:.cyan/blue}}] {{bytes}}/{{total_bytes}} ({{bytes_per_sec}}, {{eta}})"); 217 | pb.set_style(ProgressStyle::default_bar().template(&format)?); 218 | 219 | if dry_run { 220 | pb.inc(total_size as u64); 221 | pb.finish(); 222 | } else { 223 | pb.set_position(0); 224 | 225 | let (sx, mut rx) = tokio::sync::mpsc::channel(1); 226 | let upload = client 227 | .upload_video_part(&video, total_size, sx, None /* TODO: Add part name */); 228 | tokio::pin!(upload); 229 | let result = loop { 230 | tokio::select! { 231 | Some(size) = rx.recv() => { 232 | // 上传进度 233 | pb.inc(size as u64); 234 | } 235 | video = &mut upload => { 236 | // 上传完成 237 | p_filename.finish(); 238 | pb.finish(); 239 | break video; 240 | } 241 | } 242 | }; 243 | if let Ok(part) = result { 244 | parts.push(part); 245 | } else { 246 | // once again 247 | pb.set_position(0); 248 | 249 | let (sx, mut rx) = tokio::sync::mpsc::channel(1); 250 | let upload = client.upload_video_part( 251 | &video, 252 | total_size, 253 | sx, 254 | if video_name.is_empty() { 255 | None 256 | } else { 257 | Some(video_name.to_string()) 258 | }, 259 | ); 260 | tokio::pin!(upload); 261 | loop { 262 | tokio::select! { 263 | Some(size) = rx.recv() => { 264 | // 上传进度 265 | pb.inc(size as u64); 266 | } 267 | video = &mut upload => { 268 | // 上传完成 269 | p_filename.finish(); 270 | pb.finish(); 271 | parts.push(video?); 272 | break; 273 | } 274 | } 275 | } 276 | } 277 | } 278 | } 279 | 280 | Ok(parts) 281 | } 282 | 283 | impl SsUploadCommand { 284 | /// 尝试导入视频模板 285 | async fn template(&self, root: &PathBuf) -> anyhow::Result { 286 | fn set_variable(key: &str, value: I) 287 | where 288 | I: Into, 289 | { 290 | if key.starts_with('$') || key.starts_with("ss_") { 291 | eprintln!("跳过变量:{key}"); 292 | } else { 293 | CONTEXT.insert(key.to_string(), value); 294 | } 295 | } 296 | 297 | let template = root 298 | .join("templates") 299 | .join(format!("{}.toml", self.template)); 300 | if !template.exists() { 301 | bail!("Template not found!"); 302 | } 303 | 304 | let template = fs::read_to_string(template).await?; 305 | let template: VideoTemplate = toml::from_str(&template)?; 306 | for (variable, detail) in template.variables.iter() { 307 | if detail.can_skip && detail.default.is_none() { 308 | set_variable(variable, String::new()); 309 | } 310 | } 311 | 312 | // 低优先级:变量文件 313 | if let Some(variables) = &self.variable_file { 314 | let file = fs::read_to_string(variables).await?; 315 | if file.starts_with('{') { 316 | // parse as json file 317 | let json: HashMap = serde_json::from_str(&file)?; 318 | for (key, value) in json { 319 | set_variable(&key, value); 320 | } 321 | } else { 322 | for mut line in file.split('\n') { 323 | line = line.trim(); 324 | if !line.is_empty() && !line.starts_with('#') { 325 | let (key, value) = line.split_once('=').unwrap_or((&line, "")); 326 | let key = key.trim(); 327 | let mut value = value.trim_matches(' '); 328 | if !self.keep_quote_pairs 329 | && ((value.starts_with('"') && value.ends_with('"')) 330 | || (value.starts_with('\'') && value.ends_with('\''))) 331 | { 332 | value = &value[1..value.len() - 1]; 333 | } 334 | let value = value.replace("\\n", "\n"); 335 | set_variable(key, value); 336 | } 337 | } 338 | } 339 | } 340 | // 高优先级:命令行变量 341 | for variable in self.variables.iter() { 342 | let (key, value) = variable.split_once('=').unwrap_or((&variable, "")); 343 | set_variable(key.trim(), value.trim()); 344 | } 345 | 346 | Ok(template) 347 | } 348 | } 349 | 350 | #[handler(SsUploadCommand)] 351 | async fn handle_upload( 352 | this: &SsUploadCommand, 353 | config_root: &PathBuf, 354 | config: &Config, 355 | args: &Args, 356 | ) -> anyhow::Result<()> { 357 | let progress = indicatif::MultiProgress::new(); 358 | 359 | // 加载模板 360 | let template = this.template(&config_root).await?; 361 | 362 | // 预定义变量 363 | CONTEXT.insert_sys("config_root".to_string(), config_root.to_string_lossy()); 364 | CONTEXT.insert_sys( 365 | "file_name".to_string(), 366 | this.videos[0].file_name().unwrap().to_string_lossy(), 367 | ); 368 | CONTEXT.insert_sys( 369 | "file_stem".to_string(), 370 | this.videos[0].file_stem().unwrap().to_string_lossy(), 371 | ); 372 | CONTEXT.insert_sys( 373 | "file_pwd".to_string(), 374 | this.videos[0] 375 | .canonicalize()? 376 | .parent() 377 | .unwrap() 378 | .to_string_lossy(), 379 | ); 380 | 381 | // 模板字符串编译 382 | let tmpl = template 383 | .build(this.skip_level) 384 | .with_context(|| "build template")?; 385 | 386 | // 模板变量检查 387 | template 388 | .validate(&tmpl, this.skip_level) 389 | .with_context(|| "validate template")?; 390 | 391 | // 用户登录检查 392 | let credential = credential( 393 | config_root, 394 | args.account.as_deref(), 395 | template 396 | .default_user 397 | .as_deref() 398 | .or(config.default_user.as_deref()), 399 | ) 400 | .await?; 401 | 402 | // 线路选择 403 | let client = { 404 | let line = config.line().await?; 405 | progress.println(format!("已选择线路:{}", line.probe_url()))?; 406 | Client::new(line, credential) 407 | }; 408 | 409 | // 上传封面 410 | let cover = { 411 | let cover = if template.auto_cover() { 412 | let duration = 413 | ffmpeg::get_duration(&this.videos[0]).with_context(|| "ffmpeg::get_duration")?; 414 | let rnd = rand::thread_rng().gen_range(0..duration); 415 | Some(ffmpeg::auto_cover(&this.videos[0], rnd)?) 416 | } else if this.scale_cover.unwrap_or(config.need_scale_cover()) { 417 | Some( 418 | ffmpeg::scale_cover(&template.cover(&tmpl)?) 419 | .with_context(|| "ffmpeg::scale_cover")?, 420 | ) 421 | } else { 422 | None 423 | }; 424 | let cover_path = match &cover { 425 | Some(cover) => cover.to_path_buf(), 426 | None => template.cover(&tmpl)?.into(), 427 | }; 428 | 429 | let cover = if !this.dry_run { 430 | client 431 | .upload_cover(cover_path) 432 | .await 433 | .with_context(|| "upload cover")? 434 | } else { 435 | "".into() 436 | }; 437 | progress.println("封面已上传!")?; 438 | cover 439 | }; 440 | 441 | // 准备分P 442 | let video_files: Vec<(PathBuf, &str)> = template 443 | .video_prefix(&tmpl) 444 | .into_iter() 445 | .map(|v| (v, "")) 446 | .chain( 447 | this.videos 448 | .clone() 449 | .into_iter() 450 | .enumerate() 451 | .map(|(i, v)| (v, this.names.get(i).map(|r| r.as_str()).unwrap_or(""))), 452 | ) 453 | .chain(template.video_suffix(&tmpl).into_iter().map(|v| (v, ""))) 454 | .collect(); 455 | // 检查文件存在 456 | for (video, _) in video_files.iter() { 457 | if !video.exists() { 458 | bail!("Video not found: {}", video.display()); 459 | } 460 | } 461 | 462 | // 上传分P 463 | let parts = upload_videos(&client, &progress, &video_files, this.dry_run).await?; 464 | 465 | // 提交视频 466 | let video = template.to_video(&tmpl, parts, cover)?; 467 | if !this.dry_run { 468 | let mut retry = config.submit_retry(); 469 | loop { 470 | match client.submit(&video).await { 471 | Ok(_) => { 472 | eprintln!("投稿成功!"); 473 | break; 474 | } 475 | Err(err) => { 476 | if retry == 0 { 477 | bail!("投稿失败:{}", err); 478 | } else { 479 | println!("投稿失败:{}", err); 480 | retry -= 1; 481 | println!("正在重试,剩余 {} 次", retry); 482 | std::thread::sleep(std::time::Duration::from_secs(3)); 483 | } 484 | } 485 | } 486 | } 487 | } 488 | Ok(()) 489 | } 490 | 491 | #[derive(Parser, Clone)] 492 | pub(crate) struct SsAppendCommand { 493 | /// 待增加分P的视频 ID 494 | #[clap(short = 'v', long)] 495 | video_id: VideoId, 496 | 497 | /// 视频分p标题 498 | /// 当为空时自动选取视频文件名作为标题 499 | /// 不包含前缀和后缀 500 | #[clap(short, long)] 501 | names: Vec, 502 | 503 | /// 添加的视频 504 | #[clap(required = true)] 505 | videos: Vec, 506 | } 507 | 508 | #[handler(SsAppendCommand)] 509 | async fn handle_append( 510 | this: &SsAppendCommand, 511 | config_root: &PathBuf, 512 | config: &Config, 513 | args: &Args, 514 | ) -> anyhow::Result<()> { 515 | // 1. 获取待修改视频 516 | let credential = credential( 517 | config_root, 518 | args.account.as_deref(), 519 | config.default_user.as_deref(), 520 | ) 521 | .await?; 522 | let line = config.line().await?; 523 | let client = Client::new(line, credential); 524 | let mut video = client.get_video(&this.video_id).await?; 525 | 526 | // 2. 检查文件存在 527 | for video in this.videos.iter() { 528 | if !video.exists() { 529 | bail!("Video not found: {}", video.display()); 530 | } 531 | } 532 | 533 | // 3. 准备进度条 534 | let progress = indicatif::MultiProgress::new(); 535 | 536 | // 4. 准备文件名 537 | let videos: Vec<_> = this 538 | .videos 539 | .iter() 540 | .enumerate() 541 | .map(|(i, v)| { 542 | ( 543 | v.clone(), 544 | this.names.get(i).map(|s| s.as_str()).unwrap_or(""), 545 | ) 546 | }) 547 | .collect(); 548 | 549 | // 5. 上传分P 550 | let mut parts = upload_videos(&client, &progress, &videos, false) 551 | .await? 552 | .into_iter() 553 | .map(|p| p.into()) 554 | .collect(); 555 | video.videos.append(&mut parts); 556 | 557 | // 6. 提交视频 558 | eprintln!("准备投稿…"); 559 | let mut retry = config.submit_retry(); 560 | loop { 561 | match client.submit_edit(&video).await { 562 | Ok(_) => { 563 | eprintln!("投稿成功!"); 564 | break; 565 | } 566 | Err(err) => { 567 | if retry == 0 { 568 | bail!("投稿失败:{}", err); 569 | } else { 570 | println!("投稿失败:{}", err); 571 | retry -= 1; 572 | println!("正在重试,剩余 {} 次", retry); 573 | std::thread::sleep(std::time::Duration::from_secs(3)); 574 | } 575 | } 576 | } 577 | } 578 | 579 | Ok(()) 580 | } 581 | 582 | #[derive(Parser, Clone)] 583 | pub(crate) struct SsViewCommand { 584 | /// 使用的帐号 585 | #[clap(short = 'u', long = "user")] 586 | account: Option, 587 | 588 | /// 查看的视频 ID 589 | video_id: VideoId, 590 | } 591 | 592 | #[handler(SsViewCommand)] 593 | async fn handle_view( 594 | this: &SsViewCommand, 595 | config_root: &PathBuf, 596 | config: &Config, 597 | ) -> anyhow::Result<()> { 598 | let credential = credential( 599 | config_root, 600 | this.account.as_deref(), 601 | config.default_user.as_deref(), 602 | ) 603 | .await?; 604 | let client = Client::auto(credential).await?; 605 | let video = client.get_video(&this.video_id).await?; 606 | println!("{:#?}", video); 607 | Ok(()) 608 | } 609 | 610 | #[derive(Parser, Clone)] 611 | pub(crate) struct SsCardCommand { 612 | /// 使用的帐号 613 | #[clap(short = 'u', long = "user")] 614 | account: Option, 615 | 616 | /// 视频 ID 617 | #[clap(short, long)] 618 | video_id: VideoId, 619 | 620 | /// 视频分P编号 621 | #[clap(short, long = "part")] 622 | part_id: Option, 623 | 624 | /// 是否强制进度条显示 625 | /// 在客户端上会遮挡字幕,如有需要请手动开启 626 | #[clap(long)] 627 | permanent: bool, 628 | 629 | /// 分段文件路径 630 | /// 631 | /// 分段文件的格式如下: 632 | /// [start],[description] 633 | card_file: PathBuf, 634 | } 635 | 636 | #[handler(SsCardCommand)] 637 | async fn handle_card( 638 | this: &SsCardCommand, 639 | config_root: &PathBuf, 640 | config: &Config, 641 | ) -> anyhow::Result<()> { 642 | fn parse_time_point(input: &str) -> Result { 643 | if input.contains(':') { 644 | let mut result = 0; 645 | for part in input.split(':') { 646 | result = result * 60 + part.parse::()?; 647 | } 648 | Ok(result) 649 | } else { 650 | input.parse() 651 | } 652 | } 653 | 654 | // parse file first 655 | let data = fs::read_to_string(&this.card_file).await?; 656 | let time_points: Vec<(u64, &str)> = data 657 | .split('\n') 658 | .into_iter() 659 | .filter_map(|line| { 660 | let line = line.trim(); 661 | if line.is_empty() || line.starts_with('#') { 662 | None 663 | } else { 664 | Some(line) 665 | } 666 | }) 667 | .map::, _>(|line| { 668 | let (start, content) = line 669 | .split_once(',') 670 | .ok_or_else(|| anyhow::anyhow!("invalid line"))?; 671 | let start = parse_time_point(start)?; 672 | Ok((start, content)) 673 | }) 674 | .collect::>() 675 | .with_context(|| "parse card file")?; 676 | 677 | // get video info 678 | let credential = credential( 679 | config_root, 680 | this.account.as_deref(), 681 | config.default_user.as_deref(), 682 | ) 683 | .await?; 684 | let client = Client::auto(credential).await?; 685 | let video = client.get_video(&this.video_id).await?; 686 | 687 | let part_index = match this.part_id { 688 | None => 0, 689 | Some(0) => 0, 690 | Some(i) => i - 1, 691 | }; 692 | if video.videos.len() <= part_index { 693 | bail!("分P不存在!"); 694 | } 695 | let part = &video.videos[part_index]; 696 | let cid = part.cid.expect("cid not found"); 697 | 698 | // prepare cards 699 | let mut cards = Vec::with_capacity(time_points.len()); 700 | let mut prev_end = part.duration; 701 | for i in (0..time_points.len()).rev() { 702 | let (start, content) = &time_points[i]; 703 | cards.push(VideoCardItem { 704 | from: *start, 705 | to: prev_end, 706 | content: content.to_string(), 707 | }); 708 | prev_end = *start; 709 | } 710 | cards.reverse(); 711 | cards[0].from = 0; 712 | // eprintln!("{:#?}", cards); 713 | 714 | client 715 | .edit_card(video.aid, cid, cards, this.permanent) 716 | .await?; 717 | 718 | eprintln!("分段章节修改成功!"); 719 | Ok(()) 720 | } 721 | 722 | #[derive(Parser, Clone)] 723 | pub(crate) struct SsAccountListCommand; 724 | 725 | #[handler(SsAccountListCommand)] 726 | async fn account_list(config_root: &PathBuf) -> anyhow::Result<()> { 727 | let accounts = config_root.join("accounts"); 728 | let mut dir = fs::read_dir(accounts).await?; 729 | while let Some(next) = dir.next_entry().await? { 730 | if let Some("json") = next.path().extension().map(|s| s.to_str().unwrap()) { 731 | println!("{}", next.path().file_stem().unwrap().to_string_lossy()); 732 | } 733 | } 734 | 735 | Ok(()) 736 | } 737 | 738 | #[derive(Parser, Clone)] 739 | pub(crate) struct SsAccountLoginCommand { 740 | /// 可选的 cookie,用于自动登录 741 | #[clap(short, long = "cookie")] 742 | cookies: Vec, 743 | /// 帐号名称,在后续投稿时需要作为参数传递进来 744 | name: String, 745 | } 746 | 747 | #[handler(SsAccountLoginCommand)] 748 | async fn account_login(this: &SsAccountLoginCommand, config_root: &PathBuf) -> anyhow::Result<()> { 749 | let account_path = config_root 750 | .join("accounts") 751 | .join(format!("{}.json", this.name)); 752 | if account_path.exists() { 753 | bail!("帐号 {} 已存在!", this.name); 754 | } 755 | 756 | let credential = if this.cookies.is_empty() { 757 | let qrcode = Credential::get_qrcode().await?; 758 | eprintln!( 759 | "请打开以下链接登录:\n{}", 760 | qrcode["data"]["url"].as_str().unwrap() 761 | ); 762 | Credential::from_qrcode(qrcode).await? 763 | } else { 764 | let cookies: Vec<_> = this 765 | .cookies 766 | .iter() 767 | .filter_map(|c| CookieEntry::from_str(c).ok()) 768 | .collect(); 769 | Credential::from_cookies(&CookieInfo::new(cookies)).await? 770 | }; 771 | 772 | fs::write(account_path, serde_json::to_string(&credential)?).await?; 773 | let nickname = credential.get_nickname().await?; 774 | eprintln!("帐号 {} 已登录!帐号名为:{nickname}", this.name); 775 | Ok(()) 776 | } 777 | 778 | #[derive(Parser, Clone)] 779 | pub(crate) struct SsAccountLogoutCommand { 780 | /// 待删除登录凭据的帐号名称 781 | name: String, 782 | } 783 | 784 | #[handler(SsAccountLogoutCommand)] 785 | async fn account_logout( 786 | this: &SsAccountLogoutCommand, 787 | config_root: &PathBuf, 788 | ) -> anyhow::Result<()> { 789 | let account_path = config_root 790 | .join("accounts") 791 | .join(format!("{}.json", this.name)); 792 | if !account_path.exists() { 793 | bail!("帐号 {} 不存在!", this.name); 794 | } 795 | 796 | fs::remove_file(account_path).await?; 797 | eprintln!("帐号 {} 已删除!", this.name); 798 | Ok(()) 799 | } 800 | -------------------------------------------------------------------------------- /sswa/src/config.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use serde::Deserialize; 3 | use ssup::UploadLine; 4 | 5 | #[derive(Deserialize)] 6 | #[serde(rename_all = "kebab-case")] 7 | pub(crate) struct Config { 8 | /// 手动选择线路 9 | line: Option, 10 | /// 默认投稿用户 11 | pub default_user: Option, 12 | /// 默认是否缩放封面 13 | scale_cover: Option, 14 | /// 提交失败的重试次数 15 | submit_retry: Option, 16 | } 17 | 18 | impl Config { 19 | pub(crate) fn new() -> Self { 20 | Config { 21 | line: None, 22 | default_user: None, 23 | scale_cover: None, 24 | submit_retry: None, 25 | } 26 | } 27 | 28 | pub(crate) fn submit_retry(&self) -> u8 { 29 | self.submit_retry.unwrap_or(3) 30 | } 31 | 32 | pub(crate) fn need_scale_cover(&self) -> bool { 33 | self.scale_cover.unwrap_or(false) 34 | } 35 | 36 | pub(crate) async fn line(&self) -> anyhow::Result { 37 | let line = self.line.as_deref().unwrap_or("auto"); 38 | let line = match line { 39 | "kodo" => UploadLine::kodo(), 40 | "bda2" => UploadLine::bda2(), 41 | "ws" => UploadLine::ws(), 42 | "qn" => UploadLine::qn(), 43 | "auto" => UploadLine::auto() 44 | .await 45 | .with_context(|| "auto select upload line")?, 46 | _ => unimplemented!(), 47 | }; 48 | Ok(line) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /sswa/src/context.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | use parking_lot::RwLock; 3 | use serde_json::Value; 4 | use std::collections::HashMap; 5 | 6 | lazy_static! { 7 | pub static ref CONTEXT: Context = Context::new(); 8 | } 9 | 10 | pub struct Context(pub(crate) RwLock>); 11 | 12 | impl Context { 13 | pub fn new() -> Self { 14 | Self(RwLock::new(HashMap::new())) 15 | } 16 | 17 | pub fn insert_sys(&self, mut key: String, value: S) 18 | where 19 | S: Into, 20 | { 21 | let value = value.into(); 22 | self.insert(format!("${key}"), value.clone()); 23 | 24 | // 兼容考虑的 ss_{var} 25 | key.insert_str(0, "ss_"); 26 | self.0.write().insert(key, value); 27 | } 28 | 29 | pub fn insert(&self, key: String, value: S) 30 | where 31 | S: Into, 32 | { 33 | self.0.write().insert(key, value.into()); 34 | } 35 | 36 | pub fn contains_key(&self, key: &str) -> bool { 37 | self.0.read().contains_key(key) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /sswa/src/ffmpeg.rs: -------------------------------------------------------------------------------- 1 | use std::io::Read; 2 | use std::path::Path; 3 | use std::process::{Command, Stdio}; 4 | use std::str::FromStr; 5 | use tempfile::{NamedTempFile, TempPath}; 6 | 7 | pub(crate) fn get_duration>(video_path: P) -> anyhow::Result { 8 | let command = Command::new("ffprobe") 9 | .args(&[ 10 | "-v", 11 | "error", 12 | "-show_entries", 13 | "format=duration", 14 | "-of", 15 | "default=noprint_wrappers=1:nokey=1", 16 | ]) 17 | .arg(video_path.as_ref().as_os_str()) 18 | .stdout(Stdio::piped()) 19 | .spawn()?; 20 | let mut result = String::new(); 21 | command.stdout.unwrap().read_to_string(&mut result)?; 22 | let num = result 23 | .trim() 24 | .split_once(".") 25 | .map_or(result.as_str(), |r| r.0); 26 | Ok(u32::from_str(num)?) 27 | } 28 | 29 | pub(crate) fn auto_cover>(input_path: P, time: u32) -> anyhow::Result { 30 | let file = NamedTempFile::new()?; 31 | Command::new("ffmpeg") 32 | .args(["-v", "error", "-y", "-ss", &format!("{time}"), "-i"]) 33 | .arg(input_path.as_ref().as_os_str()) 34 | .args([ 35 | "-vf", 36 | "scale=960:600:force_original_aspect_ratio=decrease,pad=960:600:-1:-1:color=black", 37 | "-frames:v", 38 | "1", 39 | "-f", 40 | "image2", 41 | ]) 42 | .arg(file.path().as_os_str()) 43 | .stderr(Stdio::piped()) 44 | .spawn()? 45 | .wait()?; 46 | Ok(file.into_temp_path()) 47 | } 48 | 49 | pub(crate) fn scale_cover>(input_path: P) -> anyhow::Result { 50 | let file = NamedTempFile::new()?; 51 | Command::new("ffmpeg") 52 | .args(["-v", "error", "-y", "-i"]) 53 | .arg(input_path.as_ref().as_os_str()) 54 | .args([ 55 | "-vf", 56 | "scale=960:600:force_original_aspect_ratio=decrease,pad=960:600:-1:-1:color=black", 57 | "-f", 58 | "image2", 59 | ]) 60 | .arg(file.path().as_os_str()) 61 | .stderr(Stdio::piped()) 62 | .spawn()? 63 | .wait()?; 64 | Ok(file.into_temp_path()) 65 | } 66 | -------------------------------------------------------------------------------- /sswa/src/main.rs: -------------------------------------------------------------------------------- 1 | use crate::args::Args; 2 | use clap::Parser; 3 | use clap_handler::Handler; 4 | 5 | mod args; 6 | mod config; 7 | mod context; 8 | mod ffmpeg; 9 | mod template; 10 | 11 | #[tokio::main] 12 | async fn main() -> anyhow::Result<()> { 13 | Args::parse().run().await 14 | } 15 | -------------------------------------------------------------------------------- /sswa/src/template.rs: -------------------------------------------------------------------------------- 1 | use crate::context::CONTEXT; 2 | use chrono::NaiveDateTime; 3 | use date_time_parser::{DateParser, TimeParser}; 4 | use serde::Deserialize; 5 | use ssup::video::{Subtitle, Video, VideoPart}; 6 | use std::collections::HashMap; 7 | use std::path::PathBuf; 8 | use std::process::exit; 9 | use tinytemplate::instruction::PathStep; 10 | use tinytemplate::TinyTemplate; 11 | 12 | #[derive(Deserialize)] 13 | #[serde(rename_all = "kebab-case")] 14 | pub(crate) struct VideoTemplate { 15 | /// 视频标题 16 | title: TemplateString, 17 | /// 简介 18 | description: TemplateString, 19 | /// 转载来源 20 | forward_source: Option, 21 | /// 分区号 22 | tid: i64, 23 | /// 封面图片 24 | #[serde(default)] 25 | cover: TemplateString, 26 | /// 动态文本 27 | #[serde(default)] 28 | dynamic_text: TemplateString, 29 | /// 标签 30 | #[serde(default)] 31 | tags: Vec, 32 | /// 发布时间 33 | display_time: Option, 34 | /// 前缀视频 35 | #[serde(default)] 36 | video_prefix: Vec, 37 | /// 后缀视频 38 | #[serde(default)] 39 | video_suffix: Vec, 40 | /// 默认用户 41 | pub default_user: Option, 42 | /// 变量解释 43 | #[serde(default)] 44 | pub variables: TemplateVariables, 45 | } 46 | 47 | impl VideoTemplate { 48 | /// 获取封面路径 49 | pub(crate) fn cover(&self, template: &TinyTemplate) -> anyhow::Result { 50 | self.cover.to_string(template) 51 | } 52 | 53 | /// 构建模板 54 | pub(crate) fn build(&self, skip_level: u8) -> anyhow::Result { 55 | let mut template = TinyTemplate::new(); 56 | // 常用 Formatter 57 | template.add_formatter("comma2cn", |input, output| { 58 | if input.is_string() { 59 | let result = input.as_str().unwrap().replace(",", "、"); 60 | output.push_str(&result); 61 | } 62 | Ok(()) 63 | }); 64 | 65 | template.add_unnamed_template(&self.title.0)?; 66 | template.add_unnamed_template(&self.description.0)?; 67 | template.add_unnamed_template(&self.dynamic_text.0)?; 68 | template.add_unnamed_template(&self.cover.0)?; 69 | if let Some(display_time) = &self.display_time { 70 | template.add_unnamed_template(&display_time.0)?; 71 | } 72 | 73 | if let Some(forward_source) = &self.forward_source { 74 | template.add_unnamed_template(&forward_source.0)?; 75 | } 76 | 77 | for tag in self.tags.iter() { 78 | template.add_unnamed_template(&tag.0)?; 79 | } 80 | 81 | for video in self.video_prefix.iter() { 82 | template.add_unnamed_template(&video.0)?; 83 | } 84 | 85 | for video in self.video_suffix.iter() { 86 | template.add_unnamed_template(&video.0)?; 87 | } 88 | 89 | self.variables.add_templates(&mut template)?; 90 | 91 | // 检查变量 92 | let paths = template.get_paths(); 93 | for path in paths { 94 | if path.len() == 1 { 95 | // 暂时只检查一层变量 96 | match path[0] { 97 | PathStep::Name(variable) => { 98 | if !CONTEXT.contains_key(variable) { 99 | let default = if let Some(default) = self.variables.default(variable) { 100 | default.to_string(&template)? 101 | } else { 102 | String::new() 103 | }; 104 | 105 | let ans = if self.variables.is_required(variable) { 106 | let description = match self.variables.description(variable) { 107 | Some(description) => format!("{description}({variable})"), 108 | None => format!("{variable}"), 109 | }; 110 | if skip_level < 2 { 111 | // 用户输入变量 112 | let question = requestty::Question::input(variable) 113 | .default(default) 114 | .message(description); 115 | 116 | let question = question.build(); 117 | let ans = requestty::prompt_one(question)?; 118 | ans.as_string().unwrap().to_string() 119 | } else if skip_level == 2 { 120 | // 2级跳过变量输入,但产生报错 121 | panic!("变量未输入:{description}"); 122 | } else 123 | /* if skip_level > 2 */ 124 | { 125 | // 3级+跳过变量输入,且不报错 126 | default 127 | } 128 | } else { 129 | default 130 | }; 131 | 132 | CONTEXT.insert(variable.to_string(), ans); 133 | } 134 | } 135 | PathStep::Index(_, _) => {} 136 | } 137 | } 138 | } 139 | Ok(template) 140 | } 141 | 142 | /// 校验模板字符串 143 | pub(crate) fn validate(&self, template: &TinyTemplate, skip_level: u8) -> anyhow::Result<()> { 144 | let title = self.title.to_string(template)?; 145 | let desc = self.description.to_string(template)?; 146 | let dynamic = self.dynamic_text.to_string(template)?; 147 | let cover = self.cover.to_string(template)?; 148 | 149 | let forward_source = if let Some(forward_source) = &self.forward_source { 150 | forward_source.to_string(template)? 151 | } else { 152 | String::new() 153 | }; 154 | 155 | let tags = self.tags(template)?; 156 | 157 | let display_time = match self.display_timestamp(template)? { 158 | Some(time) => { 159 | let time = NaiveDateTime::from_timestamp(time + 60 * 60 * 8, 0); 160 | time.format("%Y-%m-%d %H:%M:%S (UTC+8)").to_string() 161 | } 162 | None => "未设置".to_string(), 163 | }; 164 | 165 | for video in self.video_prefix.iter() { 166 | video.to_string(template)?; 167 | } 168 | for video in self.video_suffix.iter() { 169 | video.to_string(template)?; 170 | } 171 | 172 | if title.chars().count() >= 80 { 173 | anyhow::bail!("标题不得超过80个字符"); 174 | } 175 | 176 | // 输出投稿信息 177 | eprintln!("标题:{title}\n来源:{forward_source}\n简介:\n---简介开始---\n{desc}\n---简介结束---\n标签:{tags}\n动态:{dynamic}\n封面文件路径:{cover}\n公开时间:{display_time}", 178 | dynamic = if dynamic.is_empty() { "(空)" } else { &dynamic }, 179 | ); 180 | // 0级对投稿信息进行确认 181 | if skip_level == 0 { 182 | let question = requestty::Question::confirm("anonymous") 183 | .message("投稿信息如上,是否正确?") 184 | .build(); 185 | let confirm = requestty::prompt_one(question)?; 186 | if !confirm.as_bool().unwrap_or(false) { 187 | exit(0); 188 | } 189 | } 190 | Ok(()) 191 | } 192 | 193 | fn forward_source(&self, template: &TinyTemplate) -> String { 194 | if let Some(source) = &self.forward_source { 195 | source.to_string(&template).unwrap() 196 | } else { 197 | String::new() 198 | } 199 | } 200 | 201 | fn display_timestamp(&self, template: &TinyTemplate) -> anyhow::Result> { 202 | Ok(match &self.display_time { 203 | Some(display_time) => { 204 | let time = display_time.to_string(template)?; 205 | if time.is_empty() { 206 | None 207 | } else { 208 | let date = DateParser::parse(&time); 209 | let time = TimeParser::parse(&time); 210 | match (date, time) { 211 | (Some(date), Some(time)) => { 212 | Some(date.and_time(time).timestamp() - 60 * 60 * 8) 213 | } 214 | _ => anyhow::bail!("定时投稿时间解析失败!"), 215 | } 216 | } 217 | } 218 | None => None, 219 | }) 220 | } 221 | 222 | fn tags(&self, template: &TinyTemplate) -> anyhow::Result { 223 | let mut tags = Vec::new(); 224 | for tag in self.tags.iter() { 225 | let result = tag.to_string(template)?; 226 | let result = result.trim(); 227 | let results = result 228 | .split(',') 229 | .filter_map(|s| { 230 | if s.is_empty() { 231 | None 232 | } else { 233 | Some(s.to_string()) 234 | } 235 | }) 236 | .collect::>(); 237 | tags.extend(results); 238 | } 239 | if tags.len() > 12 { 240 | anyhow::bail!("标签不得超过12个"); 241 | } 242 | for tag in tags.iter() { 243 | if tag.chars().count() >= 20 { 244 | anyhow::bail!("标签不得超过20个字符"); 245 | } 246 | } 247 | Ok(tags.join(",")) 248 | } 249 | 250 | pub(crate) fn to_video( 251 | &self, 252 | template: &TinyTemplate<'_>, 253 | parts: Vec, 254 | cover: String, 255 | ) -> anyhow::Result