├── .gitignore ├── LICENSE ├── README.md ├── client ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public │ ├── favicon-32x32.png │ └── favicon.ico ├── src │ ├── error │ │ └── index.ts │ ├── header.ts │ ├── index.ts │ ├── login │ │ ├── data.ts │ │ └── index.ts │ ├── mixin.ts │ ├── scss │ │ └── index.scss │ ├── setting │ │ ├── data.ts │ │ ├── index.ts │ │ └── view.ts │ ├── task │ │ ├── data.ts │ │ ├── index.ts │ │ └── playerModal.ts │ ├── view.ts │ └── work │ │ ├── data.ts │ │ ├── index.ts │ │ ├── mixin.ts │ │ ├── type.ts │ │ └── view │ │ ├── inputBox.ts │ │ ├── parseModal.ts │ │ ├── videoInfoCard.ts │ │ └── videoItemList.ts ├── tsconfig.json └── vite.config.ts ├── docs └── 2024-11-05_090604.png └── server ├── .air.toml ├── .goreleaser.yaml ├── bilibili ├── bilibili_test.go ├── client.go ├── type.go ├── video.go ├── wbi.go └── wbi_test.go ├── common └── common.go ├── go.mod ├── go.sum ├── main.go ├── router ├── login.go ├── router.go ├── task.go └── video.go ├── task ├── task.go └── task_test.go └── util ├── db.go ├── field.go ├── res_error └── res_error.go ├── response.go ├── semaphore.go ├── util.go └── util_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | tmp/ 4 | static/ 5 | *.db 6 | *.exe 7 | download/ 8 | build/ 9 | .VSCodeCounter/ 10 | -------------------------------------------------------------------------------- /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 2025 iuroc 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 | # Bilidown 2 | 3 | [![GitHub Release](https://img.shields.io/github/v/release/iuroc/bilidown)](https://github.com/iuroc/bilidown/releases) 4 | 5 | 哔哩哔哩视频解析下载工具,支持 8K 视频、Hi-Res 音频、杜比视界下载、批量解析,可扫码登录,常驻托盘。 6 | 7 | > [!Note] 8 | > 正在开发 `3.x` 版本(v3-full-js 分支),使用 Electron 跨平台开发,支持自定义编码类型、仅下载音频或视频、批量下载、自定义命名规则、杜比全景声以及更多优化功能,敬请期待。 9 | 10 | ## 支持解析的链接类型 11 | 12 | - 【单个视频】https://www.bilibili.com/video/BV1LLDCYJEU3/ 13 | - 【番剧和影视剧】https://www.bilibili.com/bangumi/play/ss48831 14 | - 【视频合集】https://space.bilibili.com/282565107/channel/collectiondetail?sid=1427135 15 | - 【收藏夹】https://space.bilibili.com/1176277996/favlist?fid=1234122612 16 | - 【UP 主空间地址】等待 3.x 版本支持 17 | 18 | ## 使用说明 19 | 20 | 1. 从 [Releases](https://github.com/iuroc/bilidown/releases) 下载适合您系统版本的安装包 21 | 2. 非 Windows 系统,请先安装 [FFmpeg 工具](https://www.ffmpeg.org/) 22 | 3. 将安装包解压后执行即可 23 | 24 | ## 软件特色 25 | 26 | 1. 前端采用 [Bootstrap](https://github.com/twbs/bootstrap) 和 [VanJS](https://github.com/vanjs-org/van) 构建,轻量美观 27 | 2. 后端使用 Go 语言开发,数据库采用 SQlite,简化构建和部署过程 28 | 3. 前端通过 [p-queue](https://github.com/sindresorhus/p-queue) 控制并发请求,加快批量解析速度 29 | 30 | ## 其他说明 31 | 32 | - 本程序不支持也不建议 HTTP 代理,直接使用国内网络访问能提升批量解析的成功率和稳定性。 33 | 34 | ## 打包可执行文件 35 | 36 | ```shell 37 | git clone https://github.com/iuroc/bilidown 38 | cd bilidown/client 39 | pnpm install 40 | pnpm build 41 | cd ../server 42 | go mod tidy 43 | CGO_ENABLED=1 go build 44 | ``` 45 | 46 | ## 交叉编译 47 | 48 | ### 说明 49 | 50 | - 镜像名称:`iuroc/cgo-cross-build` 51 | - 支持的系统架构 52 | - `linux/amd64` 53 | - `windows/amd64` 54 | - `windows/386` 55 | - `windows/arm64` 56 | - `darwin/amd64` 57 | - `darwin/arm64` 58 | 59 | ### 拉取镜像和项目源码 60 | 61 | ```shell 62 | docker pull iuroc/cgo-cross-build:latest 63 | git clone https://github.com/iuroc/bilidown 64 | ``` 65 | 66 | ### 交叉编译发行版 67 | 68 | - 执行 `goreleaser` 命令时将自动执行 `pnpm build` 和 `go mod tidy` 69 | 70 | ```shell 71 | cd bilidown/server 72 | # [交叉编译 Releases] 73 | docker run --rm -v .:/usr/src/data iuroc/cgo-cross-build goreleaser release --snapshot --clean 74 | 75 | # [交互式终端] 76 | cd bilidown 77 | docker run --rm -it -v .:/usr/src/data iuroc/cgo-cross-build 78 | ``` 79 | 80 | ### 编译指定系统架构 81 | 82 | ```shell 83 | cd bilidown/server 84 | 85 | # [DEFAULT: linux-amd64] 86 | docker run --rm -v .:/usr/src/data iuroc/cgo-cross-build go build -o dist/bilidown-linux-amd64/bilidown 87 | 88 | # [darwin-amd64] 89 | docker run --rm -v .:/usr/src/data -e GOOS=darwin -e GOARCH=amd64 -e CC=o64-clang -e CGO_ENABLED=1 iuroc/cgo-cross-build go build -o dist/bilidown-darwin-amd64/bilidown 90 | ``` 91 | 92 | ### 非 Docker 环境编译 93 | 94 | 在 Linux amd64 平台上执行 `go build` 时,您可能需要安装以下依赖包: 95 | 96 | ```bash 97 | sudo apt install pkg-config gcc libayatana-appindicator3-dev 98 | ``` 99 | 100 | ## 开发环境 101 | 102 | ```bash 103 | # client 104 | pnpm install 105 | pnpm dev 106 | # server 107 | go build && ./bilidown 108 | ``` 109 | 110 | ## 特别感谢 111 | 112 | - [twbs/bootstrap](https://github.com/twbs/bootstrap) - 前端开发必备的响应式框架,简化页面布局 113 | - [vanjs-org/van](https://github.com/vanjs-org/van) - 轻量级的前端框架,专注于构建高效应用 114 | - [vitejs/vite](https://github.com/vitejs/vite) - 快速的前端构建工具,基于 ES 模块开发 115 | - [SocialSisterYi/bilibili-API-collec](https://github.com/SocialSisterYi/bilibili-API-collect) - B 站 API 集合,支持多种操作接口 116 | - [sindresorhus/p-queue](https://github.com/sindresorhus/p-queue) - 支持并发限制的 JavaScript 队列处理库 117 | - [iuroc/vanjs-router](https://github.com/iuroc/vanjs-router) - 轻量级前端路由工具,适用于 Van.js 框架 118 | - [uuidjs/uuid](https://www.npmjs.com/package/uuid) - 用于生成唯一标识符(UUID)的 JavaScript 库 119 | - [getlantern/systray](https://github.com/getlantern/systray) - 简单的跨平台系统托盘图标库,支持图标管理 120 | - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) - Go 语言的 SQLite3 数据库驱动,轻量高效 121 | - [skip2/go-qrcode](https://github.com/skip2/go-qrcode) - 生成 QR 码的 Go 语言库,简单易用 122 | 123 | ## 软件界面 124 | 125 | ![](./docs/2024-11-05_090604.png) 126 | 127 | 128 | ## Star History 129 | 130 | [![Star History Chart](https://api.star-history.com/svg?repos=iuroc/bilidown&type=Date)](https://www.star-history.com/#iuroc/bilidown&Date) 131 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Bilidown 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bilidown", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "preview": "vite preview" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "@types/bootstrap": "^5.2.10", 15 | "@types/uuid": "^10.0.0", 16 | "sass-embedded": "^1.80.4", 17 | "vite": "^5.4.8" 18 | }, 19 | "type": "module", 20 | "dependencies": { 21 | "bootstrap": "^5.3.3", 22 | "p-queue": "^8.0.1", 23 | "plyr": "^3.7.8", 24 | "uuid": "^10.0.0", 25 | "vanjs-core": "^1.5.2", 26 | "vanjs-router": "^2.1.0" 27 | }, 28 | "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" 29 | } 30 | -------------------------------------------------------------------------------- /client/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iuroc/bilidown/eeb5832edfe7d2904e62da6707637b58e0081260/client/public/favicon-32x32.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iuroc/bilidown/eeb5832edfe7d2904e62da6707637b58e0081260/client/public/favicon.ico -------------------------------------------------------------------------------- /client/src/error/index.ts: -------------------------------------------------------------------------------- 1 | import van from 'vanjs-core' 2 | import { Route } from 'vanjs-router' 3 | import { GLOBAL_ERROR_MESSAGE, GLOBAL_HIDE_PAGE } from '../mixin' 4 | 5 | const { button, div } = van.tags 6 | 7 | export default () => Route({ 8 | rule: 'error', 9 | Loader() { 10 | return div({ class: 'py-5 px-4 container' }, 11 | div({ class: 'py-5 px-3 border rounded-4 vstack align-items-center gap-4' }, 12 | div({ class: 'fs-2 fw-bold' }, '错误提示'), 13 | div({ class: 'text-danger fs-4' }, () => GLOBAL_ERROR_MESSAGE.val || '请刷新页面重试'), 14 | button({ 15 | class: 'btn btn-warning', onclick() { 16 | location.href = location.pathname 17 | } 18 | }, '刷新页面') 19 | ) 20 | ) 21 | }, 22 | }) -------------------------------------------------------------------------------- /client/src/header.ts: -------------------------------------------------------------------------------- 1 | import van from 'vanjs-core' 2 | import { now } from 'vanjs-router' 3 | import { GLOBAL_HAS_LOGIN } from './mixin' 4 | 5 | const { a, div } = van.tags 6 | 7 | export default () => { 8 | const classStr = (name: string) => van.derive(() => `text-nowrap nav-link ${now.val.split('/')[0] == name ? 'active' : ''}`) 9 | 10 | return div({ class: 'hstack gap-4' }, 11 | div({ class: 'fs-4 fw-bold text-nowrap' }, 'Bilidown'), 12 | div({ class: 'nav nav-underline flex-nowrap overflow-auto' }, 13 | div({ class: 'nav-item', hidden: () => !GLOBAL_HAS_LOGIN.val }, 14 | a({ class: classStr('work'), href: '#/work' }, '视频解析') 15 | ), 16 | div({ class: 'nav-item', hidden: () => !GLOBAL_HAS_LOGIN.val }, 17 | a({ class: classStr('task'), href: '#/task' }, '任务列表') 18 | ), 19 | div({ class: 'nav-item', hidden: () => !GLOBAL_HAS_LOGIN.val }, 20 | a({ class: classStr('setting'), href: '#/setting' }, '设置中心') 21 | ), 22 | div({ class: 'nav-item', hidden: GLOBAL_HAS_LOGIN }, 23 | a({ class: classStr('login'), href: '#/login' }, '扫码登录') 24 | ), 25 | ) 26 | ) 27 | } -------------------------------------------------------------------------------- /client/src/index.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import van from 'vanjs-core' 3 | import Header from './header' 4 | import Work from './work' 5 | import Task from './task' 6 | import Login from './login' 7 | import Setting from './setting' 8 | import _Error from './error' 9 | import { redirect } from 'vanjs-router' 10 | import { GLOBAL_HIDE_PAGE } from './mixin' 11 | import 'bootstrap/dist/css/bootstrap.min.css' 12 | import './scss/index.scss' 13 | import { PlayerModalComp } from './task/playerModal' 14 | 15 | const { div } = van.tags 16 | 17 | redirect('home', 'work') 18 | 19 | van.add(document.body, 20 | div({ class: 'container py-4 vstack gap-4', hidden: GLOBAL_HIDE_PAGE }, 21 | Header(), 22 | Work(), 23 | Task(), 24 | Login(), 25 | Setting(), 26 | ), 27 | _Error() 28 | ) 29 | -------------------------------------------------------------------------------- /client/src/login/data.ts: -------------------------------------------------------------------------------- 1 | import { ResJSON } from '../mixin' 2 | 3 | /** 获取新的二维码信息,包含二维码 Base64 数据和二维码 Key */ 4 | export const getQRInfo = async () => { 5 | const res = await fetch('/api/getQRInfo').then(res => res.json()) as ResJSON<{ image: string, key: string }> 6 | if (!res.success) throw new Error(res.message) 7 | return res.data 8 | } 9 | 10 | /** 根据二维码 Key 获取二维码状态 */ 11 | export const getQRStatus = async (key: string) => { 12 | return await fetch('/api/getQRStatus?key=' + key).then(res => res.json()) as ResJSON 13 | } -------------------------------------------------------------------------------- /client/src/login/index.ts: -------------------------------------------------------------------------------- 1 | import van from 'vanjs-core' 2 | import { Route, goto, nowHash } from 'vanjs-router' 3 | import { checkLogin, GLOBAL_HAS_LOGIN } from '../mixin' 4 | import { getQRInfo, getQRStatus } from './data' 5 | 6 | const { div, img } = van.tags 7 | 8 | export default () => { 9 | const qrSrc = van.state('') 10 | const errorMessage = van.state('') 11 | const qrStatusMessage = van.state('') 12 | 13 | return Route({ 14 | rule: 'login', 15 | Loader() { 16 | return div( 17 | div({ class: 'card card-body rounded-4' }, 18 | div({ class: 'row' }, 19 | div({ class: 'col-xl-3 col-lg-4 col-md-5 col-sm-6' }, 20 | div({ class: 'ratio ratio-1x1' }, 21 | img({ 22 | src: qrSrc, 23 | class: 'w-100', 24 | hidden: true, 25 | onload(this: HTMLImageElement) { 26 | this.hidden = false 27 | }, 28 | ondragstart: event => event.preventDefault(), 29 | }) 30 | ) 31 | ), 32 | div({ class: 'col-xl-9 col-lg-8 col-md-7 col-sm-6' }, 33 | div({ class: 'vstack gap-3 h-100 px-3 justify-content-center align-items-center align-items-sm-start pb-4 pb-sm-0' }, 34 | div({ class: 'fs-1' }, '扫码登录'), 35 | div({ class: 'fs-4' }, '使用哔哩哔哩 APP 扫码登录'), 36 | div({ class: 'text-danger fw-bold', hidden: () => !errorMessage.val }, errorMessage), 37 | div({ class: 'text-primary fw-bold', hidden: errorMessage }, 38 | () => qrStatusMessage.val.replace('未扫码', '').replace('二维码已扫码未确认', '已扫码,请点击确认') 39 | ) 40 | ) 41 | ) 42 | ) 43 | ) 44 | ) 45 | }, 46 | async onFirst() { 47 | if (await checkLogin()) return goto('work') 48 | }, 49 | async onLoad() { 50 | // 检查登录标识,如果已经登录过了,则重定向到工作页 51 | if (GLOBAL_HAS_LOGIN.val) return goto('work') 52 | 53 | // 当前活动的二维码标识 54 | let qrKey = '' 55 | 56 | /** 刷新二维码 */ 57 | const refreshQR = async () => { 58 | try { 59 | const qrInfo = await getQRInfo() 60 | // 更新二维码组件内容 61 | qrSrc.val = qrInfo.image 62 | // 更新当前活动的二维码标识 63 | qrKey = qrInfo.key 64 | } catch (error) { 65 | // 加载二维码时失败 66 | errorMessage.val = `加载二维码失败,请刷新页面重试` 67 | clearTimeout(timer) 68 | clearTimeout(statusTimer) 69 | } 70 | } 71 | 72 | /** 检查二维码状态 */ 73 | const checkQRStatus = async () => { 74 | try { 75 | const status = await getQRStatus(qrKey) 76 | if (status.success) { 77 | clearTimeout(statusTimer) 78 | GLOBAL_HAS_LOGIN.val = true 79 | const url = new URL(window.location.href) 80 | url.hash = '' 81 | window.location.href = url.toString() 82 | } else { 83 | qrStatusMessage.val = status.message 84 | } 85 | } catch (error) { 86 | // 出错了,显示错误信息 87 | errorMessage.val = `获取二维码状态失败,请刷新页面重试` 88 | clearTimeout(statusTimer) 89 | clearTimeout(timer) 90 | } 91 | } 92 | 93 | // 载入初始二维码 94 | refreshQR() 95 | 96 | /** 用于定时刷新二维码的定时器 */ 97 | const timer = setInterval(async () => { 98 | if (nowHash().split('/')[0] != 'login') 99 | return clearInterval(timer) 100 | refreshQR() 101 | }, 120000) 102 | 103 | /** 用于轮询检查二维码状态的定时器 */ 104 | const statusTimer = setInterval(async () => { 105 | if (nowHash().split('/')[0] != 'login') 106 | return clearInterval(statusTimer) 107 | checkQRStatus() 108 | }, 1000) 109 | }, 110 | }) 111 | } -------------------------------------------------------------------------------- /client/src/mixin.ts: -------------------------------------------------------------------------------- 1 | import van from 'vanjs-core' 2 | import { goto } from 'vanjs-router' 3 | 4 | export type ResJSON = { 5 | success: boolean 6 | data: Data 7 | message: string 8 | } 9 | 10 | /** 创建请求超时控制器 */ 11 | export const timeoutController = (ms = 15000): { 12 | signal: AbortSignal 13 | timer: number 14 | } => { 15 | const controller = new AbortController() 16 | const timer = setTimeout(() => { 17 | controller.abort(new Error('请求超时')) 18 | }, ms) 19 | return { signal: controller.signal, timer } 20 | } 21 | 22 | /** 全局登录状态 */ 23 | export const GLOBAL_HAS_LOGIN = van.state(false) 24 | 25 | /** 设置页面整体隐藏,该状态值用于设置根 DOM 的 `hidden` 属性 */ 26 | export const GLOBAL_HIDE_PAGE = van.state(true) 27 | 28 | /** 全局错误信息,用于在统一的错误提示页面中展示 */ 29 | export const GLOBAL_ERROR_MESSAGE = van.state('') 30 | 31 | /** 跳转到统一的错误提示页面 */ 32 | export const showErrorPage = (message: string) => { 33 | GLOBAL_HIDE_PAGE.val = true 34 | GLOBAL_ERROR_MESSAGE.val = message 35 | goto('error') 36 | } 37 | 38 | /** 检查后端是否登录,如果未登录,则跳转到登录页 39 | * 40 | * 登录成功或失败,都将更新 `GLOBAL_HAS_LOGIN` 的值,并返回 `boolean`,并将 `GLOBAL_HIDE_PAGE` 设置为 `false` 41 | * 42 | * 用法注意:每个路由 `onFirst` 和 `onLoad` 只能其中一个使用该函数,否则会导致执行两次请求 43 | * 一般在 `onFirst` 中执行本方法,在 `onLoad` 中执行 `if (!GLOBAL_HAS_LOGIN.val) return goto('login')` 44 | */ 45 | export const checkLogin = async (): Promise => { 46 | if (GLOBAL_HAS_LOGIN.val) return GLOBAL_HIDE_PAGE.val = false, true 47 | const res = await fetch('/api/checkLogin').then(res => res.json()) as ResJSON 48 | GLOBAL_HAS_LOGIN.val = res.success 49 | GLOBAL_HIDE_PAGE.val = false 50 | if (!res.success) return goto('login'), false 51 | return true 52 | } 53 | 54 | export interface VanComponent { 55 | element: HTMLElement 56 | } 57 | 58 | export const formatSeconds = (seconds: number) => { 59 | const hours = Math.floor(seconds / 3600) 60 | const minutes = Math.floor((seconds % 3600) / 60) 61 | const secs = seconds % 60 62 | 63 | let str = '' 64 | if (hours > 0) str += `${hours}时` 65 | if (minutes > 0) str += `${minutes}分` 66 | if (secs > 0) str += `${secs}秒` 67 | 68 | return str 69 | } -------------------------------------------------------------------------------- /client/src/scss/index.scss: -------------------------------------------------------------------------------- 1 | @media (max-width: 768px) { 2 | .position-relative-sm-down { 3 | position: relative !important; 4 | } 5 | } 6 | 7 | .max-height-description { 8 | max-height: 300px; 9 | } 10 | 11 | .video-item-btn { 12 | cursor: pointer; 13 | transition: all 0.2s; 14 | border-color: transparent; 15 | outline: none; 16 | 17 | .text-muted { 18 | transition: all 0.2s; 19 | } 20 | 21 | &:hover:not(.active), 22 | &:focus-visible:not(.active) { 23 | --bs-bg-opacity: 0.2; 24 | border-width: 1px !important; 25 | border-style: solid !important; 26 | border-color: var(--bs-success) !important; 27 | } 28 | 29 | &.active { 30 | --bs-bg-opacity: 0.8; 31 | color: white !important; 32 | 33 | .text-muted { 34 | color: white !important; 35 | } 36 | } 37 | } 38 | 39 | 40 | .hover-btn svg { 41 | cursor: pointer; 42 | 43 | &:hover { 44 | fill: red; 45 | } 46 | } 47 | 48 | * { 49 | word-wrap: break-word; 50 | word-break: break-all; 51 | } -------------------------------------------------------------------------------- /client/src/setting/data.ts: -------------------------------------------------------------------------------- 1 | import { Fields } from '.' 2 | import { ResJSON } from '../mixin' 3 | 4 | export const getFields = async (): Promise => { 5 | const res = await fetch('/api/getFields').then(res => res.json()) as ResJSON 6 | if (!res.success) throw new Error(res.message) 7 | return res.data 8 | } 9 | 10 | export const saveFields = async (fields: [string, string][]) => { 11 | const res = await fetch('/api/saveFields', { 12 | method: 'POST', 13 | body: JSON.stringify(fields), 14 | headers: { 15 | 'Content-Type': 'application/json' 16 | } 17 | }).then(res => res.json()) as ResJSON 18 | if (!res.success) throw new Error(res.message) 19 | return res.message 20 | } -------------------------------------------------------------------------------- /client/src/setting/index.ts: -------------------------------------------------------------------------------- 1 | import van from 'vanjs-core' 2 | import { Route, goto } from 'vanjs-router' 3 | import { checkLogin, GLOBAL_HAS_LOGIN, VanComponent } from '../mixin' 4 | import { SaveFolderSetting } from './view' 5 | import { getFields } from './data' 6 | import { LoadingBox } from '../view' 7 | 8 | const { button, div } = van.tags 9 | 10 | export type Fields = Record 11 | 12 | export class SettingRoute implements VanComponent { 13 | element: HTMLElement 14 | 15 | loading = van.state(true) 16 | 17 | fields = { 18 | download_folder: van.state('') 19 | } 20 | 21 | constructor() { 22 | this.element = this.Root() 23 | } 24 | 25 | Root() { 26 | 27 | const _that = this 28 | 29 | return Route({ 30 | rule: 'setting', 31 | Loader() { 32 | return div( 33 | () => _that.loading.val ? LoadingBox() : '', 34 | () => _that.loading.val ? '' : div({ class: 'vstack gap-4' }, 35 | SaveFolderSetting(_that), 36 | div({ class: 'hstack gap-3' }, 37 | button({ 38 | class: 'btn btn-outline-secondary', onclick() { 39 | if (!confirm('确定要关闭软件吗?')) return 40 | fetch('/api/quit').then(res => res.json()).then(res => { 41 | if (!res.success) alert(res.message) 42 | else document.write(`

软件已关闭

`) 43 | }) 44 | } 45 | }, '关闭软件'), 46 | button({ 47 | class: 'btn btn-outline-danger', onclick() { 48 | if (!confirm('确定要退出登录吗?')) return 49 | fetch('/api/logout').then(res => res.json()).then(res => { 50 | if (!res.success) alert(res.message) 51 | else location.reload() 52 | }) 53 | } 54 | }, '退出登录'), 55 | ) 56 | ) 57 | ) 58 | }, 59 | async onFirst() { 60 | if (!await checkLogin()) return 61 | _that.loading.val = true 62 | getFields().then(fields => { 63 | for (const key in fields) { 64 | _that.fields[key as keyof Fields].val = fields[key as keyof Fields] 65 | } 66 | 67 | setTimeout(() => { 68 | _that.loading.val = false 69 | }, 200) 70 | }) 71 | }, 72 | onLoad() { 73 | if (!GLOBAL_HAS_LOGIN.val) return goto('login') 74 | }, 75 | }) 76 | } 77 | } 78 | 79 | export default () => new SettingRoute().element -------------------------------------------------------------------------------- /client/src/setting/view.ts: -------------------------------------------------------------------------------- 1 | import van from 'vanjs-core' 2 | import { SettingRoute } from '.' 3 | import { saveFields } from './data' 4 | 5 | const { a, button, div, input } = van.tags 6 | 7 | export const SaveFolderSetting = (route: SettingRoute) => { 8 | const saveFolder = route.fields.download_folder 9 | const folderPickerDisabled = van.state(false) 10 | const buttonText = '保存' 11 | 12 | return div({ class: 'input-group' }, 13 | div({ class: 'input-group-text' }, '下载目录'), 14 | input({ 15 | class: 'form-control', 16 | value: saveFolder, 17 | oninput: event => saveFolder.val = event.target.value, 18 | }), 19 | button({ 20 | class: 'btn btn-success', onclick() { 21 | folderPickerDisabled.val = true 22 | saveFields([ 23 | ['download_folder', saveFolder.val] 24 | ]).then(message => { 25 | alert(message) 26 | }).catch(error => { 27 | if (error instanceof Error) alert(error.message) 28 | }).finally(() => { 29 | folderPickerDisabled.val = false 30 | }) 31 | }, disabled: folderPickerDisabled 32 | }, buttonText) 33 | ) 34 | } -------------------------------------------------------------------------------- /client/src/task/data.ts: -------------------------------------------------------------------------------- 1 | import { ResJSON } from "../mixin" 2 | import { TaskInDB, TaskStatus, VideoFormat } from "../work/type" 3 | 4 | let getActiveTaskController: AbortController | undefined 5 | 6 | export const getActiveTask = async (): Promise => { 7 | getActiveTaskController?.abort() 8 | getActiveTaskController = new AbortController() 9 | try { 10 | const res = await fetch('/api/getActiveTask', { 11 | signal: getActiveTaskController.signal 12 | }).then(res => res.json()) as ResJSON 13 | if (!res.success) throw new Error(res.message) 14 | return res.data 15 | } catch (error) { 16 | if (error instanceof Error && error.name === 'AbortError') return null 17 | throw error 18 | } 19 | } 20 | 21 | let getTaskListController: AbortController | undefined 22 | 23 | export const getTaskList = async (page: number, pageSize: number): Promise => { 24 | getTaskListController?.abort() 25 | getTaskListController = new AbortController() 26 | try { 27 | const res = await fetch(`/api/getTaskList?page=${page}&pageSize=${pageSize}`, { 28 | signal: getTaskListController.signal 29 | }).then(res => res.json()) as ResJSON 30 | if (!res.success) throw new Error(res.message) 31 | return res.data 32 | } catch (error) { 33 | if (error instanceof Error && error.name === 'AbortError') return null 34 | throw error 35 | } 36 | } 37 | 38 | export const showFile = async (path: string) => { 39 | const res = await fetch(`/api/showFile?filePath=${encodeURIComponent(path)}`).then(res => res.json()) as ResJSON 40 | if (!res.success) throw new Error(res.message) 41 | } 42 | 43 | /** 用于刷新任务实时进度 */ 44 | type ActiveTask = { 45 | bvid: string 46 | cid: number 47 | /** 分辨率代码 */ 48 | format: VideoFormat 49 | /** 视频标题 */ 50 | title: string 51 | /** 视频发布者 */ 52 | owner: string 53 | /** 视频封面 */ 54 | cover: string 55 | /** 任务进度 */ 56 | status: TaskStatus 57 | /** 文件保存到的目录 */ 58 | folder: string 59 | /** 任务 ID */ 60 | id: number 61 | /** 音频文件下载进度 */ 62 | audioProgress: number 63 | /** 视频文件下载进度 */ 64 | videoProgress: number 65 | /** 音视频合并进度 */ 66 | mergeProgress: number 67 | /** 视频时长,秒 */ 68 | duration: number 69 | } 70 | 71 | export const deleteTask = async (id: number) => { 72 | const res = await fetch(`/api/deleteTask?id=${id}`).then(res => res.json()) as ResJSON 73 | if (!res.success) throw new Error(res.message) 74 | } -------------------------------------------------------------------------------- /client/src/task/index.ts: -------------------------------------------------------------------------------- 1 | import van, { State } from 'vanjs-core' 2 | import { Route, goto, now } from 'vanjs-router' 3 | import { checkLogin, GLOBAL_HAS_LOGIN, GLOBAL_HIDE_PAGE, ResJSON, VanComponent } from '../mixin' 4 | import { deleteTask, getActiveTask, getTaskList, showFile } from './data' 5 | import { TaskInDB, TaskStatus } from '../work/type' 6 | import { LoadingBox } from '../view' 7 | import { PlayerModalComp } from './playerModal' 8 | 9 | const { div } = van.tags 10 | 11 | const { svg, path } = van.tags('http://www.w3.org/2000/svg') 12 | 13 | export class TaskRoute implements VanComponent { 14 | element: HTMLElement 15 | /** 包含视频播放器的模态框 */ 16 | playerModalComp = new PlayerModalComp() 17 | 18 | loading = van.state(false) 19 | 20 | taskList: State<(TaskInDB & { 21 | /** 音频下载进度百分比 */ 22 | audioProgress: State 23 | /** 视频下载进度百分比 */ 24 | videoProgress: State 25 | /** 合并进度百分比 */ 26 | mergeProgress: State 27 | /** 任务状态 */ 28 | statusState: State 29 | /** 是否正在打开 */ 30 | opening: State 31 | /** 是否正在删除 */ 32 | deleting: State 33 | })[]> = van.state([]) 34 | 35 | constructor() { 36 | 37 | this.element = this.Root() 38 | } 39 | 40 | Root() { 41 | const _that = this 42 | return Route({ 43 | rule: 'task', 44 | Loader() { 45 | return div( 46 | () => _that.loading.val ? LoadingBox() : '', 47 | () => div({ class: 'list-group', hidden: _that.loading.val }, 48 | _that.taskList.val.map(task => { 49 | const filename = `${task.title} ${btoa(task.id.toString()).replace(/=/g, '')}.mp4` 50 | return div({ 51 | class: () => `list-group-item p-0 hstack user-select-none ${task.statusState.val != 'done' && task.statusState.val != 'error' || task.opening.val ? 'disabled' : ''}`, 52 | hidden: task.deleting, 53 | }, 54 | div({ 55 | class: 'vstack gap-2 py-2 px-3', 56 | style: `cursor: pointer;`, 57 | onclick() { 58 | if (task.statusState.val != 'done') return 59 | _that.playerModalComp.modal.show() 60 | _that.playerModalComp.playerComp.src.val = `/api/downloadVideo?path=${encodeURIComponent( 61 | `${task.folder}\\${filename}` 62 | )}` 63 | _that.playerModalComp.playerComp.filename.val = task.title 64 | } 65 | }, 66 | div({ 67 | class: () => ` 68 | ${task.statusState.val == 'error' ? 'text-danger' : ''} 69 | ${task.statusState.val == 'waiting' || task.statusState.val == 'running' 70 | ? 'text-primary' : ''}` 71 | }, 72 | () => task.opening.val ? '正在打开文件位置...' : filename), 73 | div({ class: 'text-secondary small' }, 74 | () => { 75 | if (task.statusState.val == 'waiting') return '等待下载' 76 | if (task.statusState.val == 'error') return '下载失败' 77 | if (task.videoProgress.val == 0) { 78 | return `正在下载音频 (${(task.audioProgress.val * 100).toFixed(2)}%)` 79 | } else if (task.mergeProgress.val == 0) { 80 | return `正在下载视频 (${(task.videoProgress.val * 100).toFixed(2)}%)` 81 | } else if (task.statusState.val == 'running') { 82 | return `正在合并音视频 (${(task.mergeProgress.val * 100).toFixed(2)}%)` 83 | } else { 84 | return task.folder 85 | } 86 | } 87 | ), 88 | div({ 89 | class: `progress`, 90 | style: `height: 5px`, 91 | hidden: () => task.statusState.val == 'done' || task.statusState.val == 'error' 92 | }, 93 | div({ 94 | class: () => `progress-bar progress-bar-striped progress-bar-animated bg-${(() => { 95 | if (task.videoProgress.val == 0) return 'primary' 96 | if (task.mergeProgress.val == 0) return 'success' 97 | else return 'info' 98 | })()}`, 99 | style: () => { 100 | let width = 0 101 | if (task.videoProgress.val == 0) width = task.audioProgress.val * 100 102 | else if (task.mergeProgress.val == 0) width = task.videoProgress.val * 100 103 | else width = task.mergeProgress.val * 100 104 | return `width: ${width}%` 105 | } 106 | }), 107 | ) 108 | ), 109 | div({ 110 | class: 'me-4', 111 | hidden: task.statusState.val != 'done' 112 | || task.opening.val // 正在打开文件位置时,不应该显示删除按钮 113 | || task.deleting.val // 正在删除时,不应该显示删除按钮 114 | }, 115 | div({ 116 | class: 'hover-btn', title: '打开文件位置', 117 | onclick() { 118 | showFile(`${task.folder}\\${filename}`) 119 | task.opening.val = true 120 | setTimeout(() => { 121 | task.opening.val = false 122 | }, 3000) 123 | } 124 | }, 125 | _that.FolderSVG() 126 | ) 127 | ), 128 | div({ 129 | class: 'me-4', 130 | hidden: task.statusState.val != 'done' 131 | && task.statusState.val != 'error' 132 | || task.opening.val // 正在打开文件位置时,不应该显示删除按钮 133 | || task.deleting.val // 正在删除时,不应该显示删除按钮 134 | }, 135 | div({ 136 | class: 'hover-btn', title: '删除视频', 137 | onclick() { 138 | task.deleting.val = true 139 | deleteTask(task.id).then(() => { 140 | _that.taskList.val = _that.taskList.val.filter(taskInDB => taskInDB.id != task.id) 141 | }).catch(error => { 142 | alert(error.message) 143 | }) 144 | } 145 | }, 146 | _that.DeleteSVG() 147 | ) 148 | ), 149 | ) 150 | }) 151 | ) 152 | ) 153 | }, 154 | async onFirst() { 155 | if (!await checkLogin()) return 156 | }, 157 | async onLoad() { 158 | if (!GLOBAL_HAS_LOGIN.val) return goto('login') 159 | _that.loading.val = true 160 | 161 | getTaskList(0, 360).then(taskList => { 162 | if (!taskList) return 163 | _that.taskList.val = taskList.map(task => ({ 164 | ...task, 165 | audioProgress: van.state(1), 166 | videoProgress: van.state(1), 167 | mergeProgress: van.state(1), 168 | statusState: van.state(task.status), 169 | opening: van.state(false), 170 | deleting: van.state(false) 171 | })) 172 | 173 | const refresh = async () => { 174 | const activeTaskList = await getActiveTask() 175 | if (!activeTaskList) return false 176 | setTimeout(() => { 177 | _that.loading.val = false 178 | }, 200) 179 | 180 | _that.taskList.val.forEach(taskInDB => { 181 | activeTaskList.forEach(task => { 182 | if (taskInDB.id == task.id) { 183 | taskInDB.audioProgress.val = task.audioProgress 184 | taskInDB.videoProgress.val = task.videoProgress 185 | taskInDB.mergeProgress.val = task.mergeProgress 186 | taskInDB.statusState.val = task.status 187 | } 188 | }) 189 | }) 190 | if (activeTaskList.filter(task => task.status == 'running').length == 0) { 191 | clearInterval(timer) 192 | clearInterval(halper) 193 | } 194 | return true 195 | } 196 | 197 | refresh() 198 | 199 | let timer = setInterval(() => { 200 | refresh() 201 | }, 1000) 202 | let halper = setInterval(() => { 203 | if (now.val.split('/')[0] != 'task') { 204 | clearInterval(halper) 205 | clearInterval(timer) 206 | } 207 | }) 208 | }) 209 | }, 210 | }) 211 | } 212 | 213 | DeleteSVG() { 214 | return svg({ style: `width: 1em; height: 1em`, fill: "currentColor", class: "bi bi-trash3", viewBox: "0 0 16 16" }, 215 | path({ "d": "M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5M11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47M8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5" }), 216 | ) 217 | } 218 | 219 | FolderSVG() { 220 | return svg({ style: `width: 1em; height: 1em`, fill: "currentColor", class: "bi bi-folder2", viewBox: "0 0 16 16" }, 221 | path({ "d": "M1 3.5A1.5 1.5 0 0 1 2.5 2h2.764c.958 0 1.76.56 2.311 1.184C7.985 3.648 8.48 4 9 4h4.5A1.5 1.5 0 0 1 15 5.5v7a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 1 12.5zM2.5 3a.5.5 0 0 0-.5.5V6h12v-.5a.5.5 0 0 0-.5-.5H9c-.964 0-1.71-.629-2.174-1.154C6.374 3.334 5.82 3 5.264 3zM14 7H2v5.5a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5z" }), 222 | ) 223 | } 224 | } 225 | 226 | export default () => new TaskRoute().element -------------------------------------------------------------------------------- /client/src/task/playerModal.ts: -------------------------------------------------------------------------------- 1 | import { VanComponent } from '../mixin' 2 | import van from 'vanjs-core' 3 | import { Modal } from 'bootstrap' 4 | import Plyr from 'plyr' 5 | import 'plyr/dist/plyr.css' 6 | 7 | const { a, button, div, video } = van.tags 8 | 9 | export class PlayerComp implements VanComponent { 10 | element: HTMLElement 11 | /** 使用该对象前,请先将元素加入文档树后调用 `initPlayer` 方法 */ 12 | player!: Plyr 13 | src = van.state('') 14 | filename = van.state('') 15 | 16 | constructor() { 17 | this.element = this.Root() 18 | } 19 | 20 | Root() { 21 | return video({ src: this.src }) 22 | } 23 | 24 | initPlayer() { 25 | if (!this.element.parentNode) throw new Error('请将播放器元素加入 DOM 树') 26 | this.player = new Plyr(this.element, { 27 | }) 28 | } 29 | } 30 | 31 | export class PlayerModalComp implements VanComponent { 32 | element: HTMLElement 33 | playerComp: PlayerComp 34 | modal: Modal 35 | 36 | constructor() { 37 | this.playerComp = new PlayerComp() 38 | this.element = this.Root() 39 | this.initModalEvent() 40 | 41 | van.add(document.body, this.element) 42 | this.playerComp.initPlayer() 43 | this.modal = new Modal(this.element) 44 | } 45 | 46 | initModalEvent() { 47 | this.element.addEventListener('shown.bs.modal', () => { 48 | this.playerComp.player.play() 49 | }) 50 | 51 | this.element.addEventListener('hide.bs.modal', () => { 52 | if (!this.playerComp.player.stopped) { 53 | this.playerComp.player.stop() 54 | this.playerComp.src.val = '' 55 | } 56 | }) 57 | } 58 | 59 | Root() { 60 | const that = this 61 | return div({ class: `modal fade`, tabIndex: -1 }, 62 | div({ class: 'modal-dialog modal-xl modal-fullscreen-xl-down' }, 63 | div({ class: `modal-content` }, 64 | div({ class: `modal-header` }, 65 | div({ class: `h5 modal-title` }, '视频播放器'), 66 | button({ class: `btn-close`, 'data-bs-dismiss': `modal` }) 67 | ), 68 | div({ class: 'modal-body p-0' }, 69 | div({ class: 'ratio ratio-16x9' }, this.playerComp.element), 70 | div({ class: 'vstack p-3 gap-3' }, 71 | div({}, that.playerComp.filename) 72 | ) 73 | ), 74 | div({ class: 'modal-footer' }, 75 | button({ class: 'btn btn-secondary', 'data-bs-dismiss': `modal` }, '关闭'), 76 | button({ 77 | class: 'btn btn-primary', onclick() { 78 | const link = a({ 79 | download: () => that.playerComp.filename.val + '.mp4', 80 | href: that.playerComp.src 81 | }) 82 | that.modal.hide() 83 | link.click() 84 | } 85 | }, '下载') 86 | ) 87 | ) 88 | ) 89 | ) 90 | } 91 | } -------------------------------------------------------------------------------- /client/src/view.ts: -------------------------------------------------------------------------------- 1 | import van from 'vanjs-core' 2 | 3 | const { div } = van.tags 4 | 5 | export const LoadingBox = (color = 'primary') => div({ 6 | class: 'py-4 hstack justify-content-center', 7 | }, 8 | div({ 9 | class: `spinner-border text-${color}`, 10 | }, div({ class: 'visually-hidden' }, 'Loading...')) 11 | ) -------------------------------------------------------------------------------- /client/src/work/data.ts: -------------------------------------------------------------------------------- 1 | import { ResJSON, timeoutController } from '../mixin' 2 | import { FavList, PlayInfo, SeasonInfo, TaskInitData, VideoInfo } from './type' 3 | 4 | /** 5 | * 获取视频信息 6 | * 7 | * @throws {Error} 8 | */ 9 | export const getVideoInfo = async (bvid: string): Promise => { 10 | const { signal, timer } = timeoutController() 11 | 12 | const res = await fetch(`/api/getVideoInfo?bvid=${bvid}`, { 13 | signal 14 | }).then(res => res.json()) as ResJSON 15 | if (!res.success) throw new Error(res.message) 16 | clearTimeout(timer) 17 | return res.data 18 | } 19 | 20 | /** 21 | * 获取剧集信息 22 | * @param epid EP 号 23 | * @param ssid SS 号 24 | * @throws {Error} 25 | */ 26 | export const getSeasonInfo = async (epid: number, ssid: number): Promise => { 27 | const { signal, timer } = timeoutController() 28 | 29 | const res = await fetch(`/api/getSeasonInfo?epid=${epid}&ssid=${ssid}`, { 30 | signal 31 | }).then(res => res.json()) as ResJSON 32 | 33 | if (!res.success) throw new Error(res.message) 34 | clearTimeout(timer) 35 | return res.data 36 | } 37 | 38 | 39 | export const getPlayInfo = async (bvid: string, cid: number, controller: AbortController): Promise => { 40 | const res = await fetch(`/api/getPlayInfo?bvid=${bvid}&cid=${cid}`, { 41 | signal: controller.signal 42 | }).then(res => res.json()) as ResJSON 43 | if (!res.success) throw new Error(res.message) 44 | return res.data 45 | } 46 | 47 | export const createTask = async (tasks: TaskInitData[]): Promise => { 48 | const { signal, timer } = timeoutController() 49 | 50 | const res = await fetch('/api/createTask', { 51 | method: 'POST', 52 | signal, 53 | body: JSON.stringify(tasks), 54 | headers: { 55 | 'Content-Type': 'application/json' 56 | } 57 | }).then(res => res.json()) as ResJSON 58 | 59 | clearTimeout(timer) 60 | 61 | if (!res.success) throw new Error(res.message) 62 | return res 63 | } 64 | 65 | export const getPopularVideoBvids = async (): Promise => { 66 | const res = await fetch('/api/getPopularVideos').then(res => res.json()) as ResJSON 67 | if (!res.success) throw new Error(res.message) 68 | return res.data 69 | } 70 | 71 | export const getRedirectedLocation = async (url: string): Promise => { 72 | return fetch(`/api/getRedirectedLocation?url=${encodeURIComponent(url)}`) 73 | .then(res => res.json()) 74 | .then((data: ResJSON) => { 75 | if (!data.success) throw new Error(data.message) 76 | return data.data 77 | }) 78 | } 79 | 80 | /** 获取收藏夹内视频列表 */ 81 | export const getFavList = async (mediaId: number): Promise => { 82 | return fetch(`/api/getFavList?mediaId=${mediaId}`) 83 | .then(res => res.json()) 84 | .then((body: ResJSON) => { 85 | if (!body.success) throw new Error(body.message) 86 | return body.data 87 | }) 88 | } -------------------------------------------------------------------------------- /client/src/work/index.ts: -------------------------------------------------------------------------------- 1 | import van, { State } from 'vanjs-core' 2 | import { Route, goto } from 'vanjs-router' 3 | import VideoInfoCard from './view/videoInfoCard' 4 | import { checkLogin, GLOBAL_HAS_LOGIN, showErrorPage } from '../mixin' 5 | import { VideoParseResult, VideoInfoCardMode, SectionItem } from './type' 6 | import { IDType, start } from './mixin' 7 | import { ParseModalComp } from './view/parseModal' 8 | import InputBox from './view/inputBox' 9 | import { Modal } from 'bootstrap' 10 | import { LoadingBox } from '../view' 11 | import { getPopularVideoBvids } from './data' 12 | 13 | const { div } = van.tags 14 | 15 | export class WorkRoute { 16 | element: HTMLDivElement 17 | /** 输入框内容是否标识为异常 */ 18 | urlInvalid = van.state(false) 19 | /** 仅作为类名字符串 */ 20 | urlInvalidClass = van.derive(() => this.urlInvalid.val ? 'is-invalid' : '') 21 | urlValue = van.state('') 22 | videoInfocardData = van.state({ 23 | title: '', description: '', cover: '', publishData: '', duration: 0, 24 | pages: [], owner: { face: '', mid: 0, name: '' }, 25 | dimension: { width: 0, height: 0, rotate: 0 }, 26 | staff: [], status: '', areas: [], styles: [], targetURL: '', 27 | section: [] 28 | }) 29 | /** 标识视频信息卡片应该显示普通视频还是剧集,值为 `hide` 时隐藏卡片 */ 30 | videoInfoCardMode: VideoInfoCardMode = van.state('hide') 31 | ownerFaceHide = van.derive(() => this.videoInfocardData.val.owner.face == '') 32 | 33 | /** 全部选项卡和列表数据 */ 34 | allSection 35 | /** 当前选项卡的按钮列表 */ 36 | sectionPages 37 | /** 当前选中的按钮列表 */ 38 | selectedPages 39 | /** 视频列表批量解析模态框 */ 40 | parseModal: Modal 41 | 42 | parseModalComp: ParseModalComp 43 | 44 | /** 按钮是否处于 `loading` 状态,如果是则按钮设置为 `disabled` */ 45 | btnLoading = van.state(false) 46 | 47 | /** 页面初始加载的 loading 状态 */ 48 | initLoading = van.state(true) 49 | 50 | /** 是否是初始的热门推荐结果 */ 51 | isInitPopular = van.state(false) 52 | 53 | sectionTabsActiveIndex = van.state(0) 54 | 55 | constructor() { 56 | const _that = this 57 | this.allSection = van.derive(() => { 58 | const list = ( 59 | this.videoInfoCardMode.val == 'season' 60 | || this.videoInfoCardMode.val == 'video' && this.videoInfocardData.val.section.length == 0 61 | ? [{ title: '正片', pages: this.videoInfocardData.val.pages }] : [] 62 | ).concat(this.videoInfocardData.val.section) 63 | return list 64 | }) 65 | this.sectionPages = van.derive(() => { 66 | return this.allSection.val[this.sectionTabsActiveIndex.val]?.pages || [] 67 | }) 68 | this.selectedPages = van.derive(() => this.sectionPages.val.filter(page => page.selected.val)) 69 | this.parseModalComp = new ParseModalComp({ workRoute: this }) 70 | van.add(document.body, this.parseModalComp.element) 71 | 72 | this.parseModal = new Modal(this.parseModalComp.element) 73 | 74 | this.element = Route({ 75 | rule: 'work', 76 | Loader() { 77 | return div( 78 | () => _that.initLoading.val ? LoadingBox() : '', 79 | div({ class: 'vstack gap-3', hidden: _that.initLoading }, 80 | InputBox(_that), 81 | div({ hidden: () => _that.videoInfoCardMode.val == 'hide' || _that.btnLoading.val }, 82 | VideoInfoCard(_that), 83 | ), 84 | ) 85 | ) 86 | }, 87 | async onFirst() { 88 | if (!await checkLogin()) return 89 | let idType = this.args[0] as IDType 90 | let value = this.args[1] 91 | // if (!value) return goto('work'), _that.initLoading.val = false 92 | if (!value) { 93 | _that.isInitPopular.val = true 94 | const popularBvids = await getPopularVideoBvids() 95 | idType = 'bv' 96 | value = popularBvids[Math.floor(Math.random() * popularBvids.length)] 97 | } 98 | if (idType == 'bv' && !value.match(/^BV1[a-zA-Z0-9]+$/)) return goto('work') 99 | if ((idType == 'ep' || idType == 'ss' || idType == 'fav') 100 | && !value.match(/^\d+$/)) return goto('work') 101 | if (idType == 'bv' && !_that.isInitPopular.val) _that.urlValue.val = value 102 | else if (idType == 'ep' || idType == 'ss') _that.urlValue.val = `${idType}${value}` 103 | start(_that, { 104 | idType, 105 | value, 106 | from: 'onfirst' 107 | }).catch(error => { 108 | const errorMessage = `获取视频信息失败:${error.message}` 109 | showErrorPage(errorMessage) 110 | _that.videoInfoCardMode.val = 'hide' 111 | }).finally(() => { 112 | _that.btnLoading.val = false 113 | _that.isInitPopular.val = false 114 | setTimeout(() => { 115 | _that.initLoading.val = false 116 | }, 200) 117 | }) 118 | }, 119 | async onLoad() { 120 | if (!GLOBAL_HAS_LOGIN.val) return goto('login') 121 | } 122 | }) 123 | } 124 | } 125 | 126 | export default () => new WorkRoute().element -------------------------------------------------------------------------------- /client/src/work/mixin.ts: -------------------------------------------------------------------------------- 1 | import { getFavList, getRedirectedLocation, getSeasonInfo, getVideoInfo } from './data' 2 | import { WorkRoute } from '.' 3 | import van from 'vanjs-core' 4 | import { Episode, PageInParseResult, VideoParseResult } from './type' 5 | import { ResJSON } from '../mixin' 6 | 7 | /** 点击按钮开始解析 */ 8 | export const start = async ( 9 | workRoute: WorkRoute, 10 | option: { 11 | /** 标识字段类型 */ 12 | idType: IDType 13 | /** 标识字段值 */ 14 | value: string | number 15 | /** 调用来源 */ 16 | from: 'click' | 'onfirst' 17 | }) => { 18 | // 如果初始载入路由时,路由参数不带有视频信息,则会随机选择一个热门视频 19 | // 这种情况展示的解析结果,不应该同步修改路由参数,用户直接刷新网页,可以继续随机推荐不同的视频 20 | // 而如果是单击按钮或者路由参数带有视频信息,那么实现的解析结果需要同步修改路由参数 21 | if (!workRoute.isInitPopular.val) 22 | history.replaceState(null, '', `#/work/${option.idType}/${option.value}`) 23 | workRoute.sectionTabsActiveIndex.val = 0 24 | if (option.idType === 'bv') { 25 | const bvid = option.value as string 26 | await getVideoInfo(bvid).then(info => { 27 | workRoute.videoInfocardData.val = { 28 | section: (info.ugc_season.sections || []).map(i => { 29 | let index = 0 30 | return { 31 | title: (info.ugc_season.sections || []).length == 1 ? info.ugc_season.title : i.title, 32 | pages: i.episodes.flatMap(j => j.pages.map(k => { 33 | index++ 34 | return { 35 | bvid: j.bvid, 36 | cid: k.cid, 37 | dimension: k.dimension, 38 | duration: k.duration, 39 | page: index, 40 | part: j.title, 41 | bandge: index.toString(), 42 | selected: van.state(bvid == j.bvid) 43 | } 44 | })) 45 | } 46 | }), 47 | targetURL: `https://www.bilibili.com/video/${bvid}`, 48 | areas: [], 49 | styles: [], 50 | status: '', 51 | cover: info.pic, 52 | title: info.title, 53 | description: info.desc, 54 | publishData: new Date(info.pubdate * 1000).toLocaleString(), 55 | duration: info.duration, 56 | pages: info.pages.map((page, index) => ({ 57 | ...page, 58 | bvid, 59 | bandge: (index + 1).toString(), 60 | selected: van.state(info.pages.length == 1) 61 | })), 62 | dimension: info.dimension, 63 | owner: info.owner, 64 | staff: info.staff?.map(i => `${i.name}[${i.title}]`) || [] 65 | } 66 | workRoute.videoInfoCardMode.val = 'video' 67 | }) 68 | } else if (option.idType === 'ep' || option.idType === 'ss') { 69 | const epid = option.idType === 'ep' ? option.value as number : 0 70 | const ssid = option.idType === 'ss' ? option.value as number : 0 71 | await getSeasonInfo(epid, ssid).then(info => { 72 | workRoute.videoInfocardData.val = { 73 | section: (info.section || []).map(i => ({ 74 | pages: i.episodes.map(episodeToPage), 75 | title: i.title 76 | })), 77 | targetURL: `https://www.bilibili.com/bangumi/play/${option.idType}${option.value}`, 78 | areas: info.areas.map(i => i.name), 79 | styles: info.styles, 80 | duration: 0, 81 | cover: info.cover, 82 | description: info.evaluate, 83 | owner: { name: info.actors, face: '', mid: 0 }, 84 | pages: info.episodes.map(episodeToPage), 85 | status: info.new_ep.desc, 86 | publishData: new Date().toLocaleDateString(), 87 | staff: info.actors.split('\n'), 88 | dimension: { height: 0, rotate: 0, width: 0 }, 89 | title: info.title 90 | } 91 | workRoute.videoInfoCardMode.val = 'season' 92 | }) 93 | } else if (option.idType == 'fav') { 94 | const mediaId = option.value as number 95 | await getFavList(mediaId).then(favList => { 96 | workRoute.videoInfocardData.val = { 97 | areas: [], 98 | cover: favList[0].cover, 99 | description: favList[0].intro, 100 | dimension: { height: 0, width: 0, rotate: 0 }, 101 | duration: favList[0].duration, 102 | owner: favList[0].upper, 103 | pages: favList.map((info, index) => ({ 104 | bandge: (index + 1).toString(), 105 | selected: van.state(index == 0), 106 | bvid: info.bvid, 107 | cid: info.ugc.first_cid, 108 | dimension: { height: 0, width: 0, rotate: 0 }, 109 | duration: info.duration, 110 | page: index, 111 | part: info.title, 112 | })), 113 | publishData: new Date(favList[0].pubtime * 1000).toLocaleString(), 114 | section: [], 115 | staff: [], 116 | status: '', 117 | styles: [], 118 | targetURL: `https://www.bilibili.com/video/${favList[0].bvid}`, 119 | title: favList[0].title 120 | } 121 | workRoute.videoInfoCardMode.val = 'video' 122 | }) 123 | } 124 | } 125 | 126 | const episodeToPage = (episode: Episode, index: number): PageInParseResult => { 127 | return { 128 | bvid: episode.bvid, 129 | cid: episode.cid, 130 | dimension: episode.dimension, 131 | duration: episode.duration, 132 | page: index + 1, 133 | part: episode.long_title || (episode.title.match(/^\d+$/) ? `第 ${episode.title} 集` : episode.title), 134 | bandge: episode.title, 135 | selected: van.state(false) 136 | } 137 | } 138 | 139 | export type IDType = 'bv' | 'ep' | 'ss' | 'fav' 140 | 141 | /** 142 | * 校验用户输入的待解析的视频链接 143 | * @param url 待解析的视频链接 144 | * @returns 如果校验成功,则返回 BV 号,否则返回 `false` 145 | */ 146 | export const checkURL = (url: string): { 147 | type: IDType 148 | value: string | number 149 | } => { 150 | const matchBvid = url.match(/^(?:https?:\/\/www\.bilibili\.com\/video\/)?(BV1[a-zA-Z0-9]+)/) 151 | if (matchBvid) return { type: 'bv', value: matchBvid[1] } 152 | 153 | const matchSeason = url.match(/^(?:https?:\/\/www\.bilibili\.com\/bangumi\/play\/)?(ep|ss)(\d+)/) 154 | if (matchSeason) return { type: matchSeason[1] as 'ep' | 'ss', value: parseInt(matchSeason[2]) } 155 | 156 | try { 157 | const _url = new URL(url) 158 | const mediaId = parseInt(_url.searchParams.get('fid') || '') 159 | if (_url.hostname == 'space.bilibili.com' && _url.pathname.match(/^\/\d+\/favlist$/) && !isNaN(mediaId)) { 160 | return { type: 'fav', value: mediaId } 161 | } 162 | const mlMatch = url.match(/^https:\/\/www.bilibili.com\/medialist\/detail\/ml(\d+)/) 163 | if (mlMatch) return { type: 'fav', value: mlMatch[1] } 164 | } catch { } 165 | throw new Error('您输入的视频链接格式错误') 166 | } 167 | 168 | /** 将秒数转换为 `mm:ss` */ 169 | export const secondToTime = (second: number) => { 170 | return `${Math.floor(second / 60)}:${(second % 60).toString().padStart(2, '0')}` 171 | } 172 | 173 | /** 如果是 B23 地址,则返回重定向后的地址,否则返回 `false` */ 174 | export const handleB23 = async (url: string): Promise => { 175 | if (!url.match(/^https:\/\/b23.tv\//)) return false 176 | const epMatch = url.match(/^https:\/\/b23.tv\/(ep|ss)(\d+)/) 177 | if (epMatch) return `https://www.bilibili.com/bangumi/play/${epMatch[1]}${epMatch[2]}` 178 | const location = await getRedirectedLocation(url) 179 | return location 180 | } 181 | 182 | export const handleSeasonsArchivesList = async (url: string): Promise => { 183 | try { new URL(url) } catch { return false } 184 | const _url = new URL(url) 185 | const mid = _url.pathname.match(/^\/(\d+)\/channel\/collectiondetail$/)?.[1] 186 | const seasonId = parseInt(_url.searchParams.get('sid') || '') 187 | if (_url.hostname == 'space.bilibili.com' && mid && !isNaN(seasonId)) { 188 | return fetch(`/api/getSeasonsArchivesListFirstBvid?mid=${mid}&seasonId=${seasonId}`) 189 | .then(res => res.json()) 190 | .then((body: ResJSON) => { 191 | if (!body.success) throw new Error(body.message) 192 | return body.data 193 | }) 194 | } 195 | return false 196 | } -------------------------------------------------------------------------------- /client/src/work/type.ts: -------------------------------------------------------------------------------- 1 | import { State } from 'vanjs-core' 2 | 3 | /** 4 | * 参考:[qn 视频清晰度标识](https://socialsisteryi.github.io/bilibili-API-collect/docs/video/videostream_url.html#qn%E8%A7%86%E9%A2%91%E6%B8%85%E6%99%B0%E5%BA%A6%E6%A0%87%E8%AF%86) 5 | * 6 | * | 值 | 含义 | 7 | * | --- | ----------: | 8 | * | 6 | 240P 极速 | 9 | * | 16 | 360P 流畅 | 10 | * | 32 | 480P 清晰 | 11 | * | 64 | 720P 高清 | 12 | * | 74 | 720P60 高帧率 | 13 | * | 80 | 1080P 高清 | 14 | * | 112 | 1080P+ 高码率 | 15 | * | 116 | 1080P60 高帧率 | 16 | * | 120 | 4K 超清 | 17 | * | 125 | HDR 真彩色 | 18 | * | 126 | 杜比视界 | 19 | * | 127 | 8K 超高清 | 20 | */ 21 | export type VideoFormat = 6 | 16 | 32 | 64 | 74 | 80 | 112 | 116 | 120 | 125 | 126 | 127 22 | 23 | /** 视频解析结果,数据用于 DOM 渲染,同时兼容 BV、EP、SS */ 24 | export type VideoParseResult = { 25 | /** 合集标题 */ 26 | title: string 27 | /** 合集描述 */ 28 | description: string 29 | /** 合集发布时间 */ 30 | publishData: string 31 | /** 合集封面 */ 32 | cover: string 33 | /** 合集总时长 */ 34 | duration: number 35 | /** 分集列表 */ 36 | pages: PageInParseResult[] 37 | 38 | section: SectionItem[] 39 | 40 | /** 合集作者 */ 41 | owner: { 42 | mid: number 43 | name: string 44 | face: string 45 | } 46 | 47 | /** 合集分辨率 */ 48 | dimension: { 49 | width: number 50 | height: number 51 | rotate: number 52 | } 53 | staff: string[] 54 | /** 更新状态 */ 55 | status: string 56 | /** 地区信息 */ 57 | areas: string[] 58 | /** 分类标签 */ 59 | styles: string[] 60 | /** 播放页面 */ 61 | targetURL: string 62 | } 63 | 64 | export type SectionItem = { 65 | title: string 66 | pages: PageInParseResult[] 67 | } 68 | 69 | export type PageInParseResult = { 70 | /** 分集 CID */ 71 | cid: number 72 | /** 分集 BVID */ 73 | bvid: string 74 | /** 分集在合集中的序号,从 1 开始 */ 75 | page: number 76 | /** 分集标题 */ 77 | part: string 78 | /** 分集时长 */ 79 | duration: number 80 | /** 分集分辨率 */ 81 | dimension: { 82 | width: number 83 | height: number 84 | rotate: number 85 | } 86 | /** 前置胶囊标签内容 */ 87 | bandge: string 88 | /** 是否选中 */ 89 | selected: State 90 | } 91 | 92 | export type StaffItem = { 93 | mid: number 94 | title: string 95 | name: string 96 | face: string 97 | } 98 | 99 | type PageInVideoInfo = { 100 | cid: number 101 | page: number 102 | from: string 103 | part: string 104 | duration: number 105 | dimension: { 106 | width: number 107 | height: number 108 | rotate: number 109 | } 110 | } 111 | 112 | /** 接口返回的视频信息 */ 113 | export type VideoInfo = { 114 | aid: number 115 | staff: null | StaffItem[] 116 | title: string 117 | desc: string 118 | pubdate: number 119 | pic: string 120 | duration: number 121 | bvid: string 122 | pages: PageInVideoInfo[] 123 | owner: { 124 | mid: number 125 | name: string 126 | face: string 127 | } 128 | dimension: { 129 | width: number 130 | height: number 131 | rotate: number 132 | }, 133 | ugc_season: { 134 | sections: { 135 | title: string 136 | episodes: { 137 | title: string 138 | pages: PageInVideoInfo[] 139 | bvid: string 140 | }[] 141 | }[] | null 142 | title: string 143 | } 144 | } 145 | 146 | /** 接口返回的剧集信息 */ 147 | export type SeasonInfo = { 148 | actors: string 149 | areas: { 150 | id: number 151 | name: string 152 | }[] 153 | cover: string 154 | evaluate: string 155 | publish: { 156 | is_finish: number 157 | pub_time: string 158 | }; 159 | season_id: number 160 | season_title: string 161 | stat: { 162 | coins: number 163 | danmakus: number 164 | favorite: number 165 | favorites: number 166 | likes: number 167 | reply: number 168 | share: number 169 | views: number 170 | } 171 | styles: string[] 172 | title: string 173 | total: number 174 | episodes: Episode[] 175 | new_ep: { 176 | desc: string 177 | is_new: number 178 | } 179 | section: { 180 | title: string 181 | episodes: Episode[] 182 | }[] | null 183 | } 184 | 185 | export type Episode = { 186 | aid: number 187 | bvid: string 188 | cid: number 189 | cover: string 190 | dimension: { 191 | width: number 192 | height: number 193 | rotate: number 194 | } 195 | duration: number 196 | ep_id: number 197 | long_title: string 198 | pub_time: number 199 | title: string 200 | } 201 | 202 | export type VideoInfoCardMode = State<"video" | "season" | "hide"> 203 | 204 | export type Media = { 205 | id: Type extends 'video' ? VideoFormat : number 206 | baseUrl: string 207 | backupUrl: string[] 208 | bandwidth: number 209 | mimeType: string 210 | codecs: string 211 | width: number 212 | height: number 213 | frameRate: string 214 | codecid: number 215 | } 216 | 217 | export type PlayInfo = { 218 | // accept_description: string[] 219 | accept_quality: VideoFormat[] 220 | // support_formats: { 221 | // quality: number 222 | // format: string 223 | // new_description: string 224 | // codecs: string[] 225 | // }[] 226 | dash: { 227 | duration: number 228 | video: Media<'video'>[] 229 | audio: Media<'audio'>[] 230 | flac: { 231 | audio: Media 232 | } | null 233 | } 234 | } 235 | 236 | /** 创建任务时的初始数据 */ 237 | export type TaskInitData = { 238 | bvid: string 239 | cid: number 240 | format: number 241 | title: string 242 | owner: string 243 | cover: string 244 | audio: string 245 | video: string 246 | duration: number 247 | } 248 | 249 | /** 任务数据库中的数据 */ 250 | export type TaskInDB = TaskInitData & { 251 | id: number 252 | folder: string 253 | createAt: string 254 | status: TaskStatus 255 | } 256 | 257 | export type TaskStatus = 'done' | 'waiting' | 'running' | 'error' 258 | 259 | export type FavList = FavListItem[] 260 | 261 | export type FavListItem = { 262 | title: string 263 | cover: string 264 | intro: string 265 | duration: number 266 | upper: { 267 | mid: number 268 | name: string 269 | face: string 270 | } 271 | pubtime: number 272 | bvid: string 273 | ugc: { 274 | first_cid: number 275 | } 276 | } -------------------------------------------------------------------------------- /client/src/work/view/inputBox.ts: -------------------------------------------------------------------------------- 1 | import van from 'vanjs-core' 2 | import { goto } from 'vanjs-router' 3 | import { v4 } from 'uuid' 4 | import { checkURL, handleB23, handleSeasonsArchivesList, start } from '../mixin' 5 | import { WorkRoute } from '..' 6 | import { VanComponent } from '../../mixin' 7 | 8 | const { button, div, input, label, span } = van.tags 9 | 10 | class InputBoxComp implements VanComponent { 11 | element: HTMLElement 12 | btnID = v4() 13 | 14 | constructor(public workRoute: WorkRoute) { 15 | this.element = div( 16 | div({ class: () => `hstack gap-3 align-items-stretch ${workRoute.urlInvalidClass.val}` }, 17 | div({ class: () => `form-floating flex-fill` }, 18 | input({ 19 | class: () => `form-control border-3 ${workRoute.urlInvalidClass.val}`, 20 | placeholder: '请输入待解析的视频链接', 21 | value: workRoute.urlValue, 22 | oninput: event => workRoute.urlValue.val = event.target.value, 23 | onkeyup: event => { 24 | if (event.key === 'Enter') document.getElementById(this.btnID)?.click() 25 | } 26 | }), 27 | label({ class: 'w-100' }, '请输入视频链接或 BV/EP/SS 号') 28 | ), 29 | ParseButton(this, false, this.btnID), 30 | ParseButton(this, true) 31 | ), 32 | div({ class: 'invalid-feedback' }, () => workRoute.urlInvalid.val ? '您输入的视频链接格式错误' : ''), 33 | ) 34 | } 35 | } 36 | 37 | const ParseButton = (parent: InputBoxComp, large: boolean, id: string = '') => { 38 | const { workRoute } = parent 39 | 40 | return button({ 41 | class: `btn btn-success text-nowrap ${large ? `btn-lg d-none d-md-block` : 'd-md-none'}`, 42 | async onclick() { 43 | try { 44 | workRoute.btnLoading.val = true 45 | workRoute.urlValue.val = workRoute.urlValue.val.trim() 46 | try { 47 | const handleB23Result = await handleB23(workRoute.urlValue.val) 48 | if (handleB23Result) workRoute.urlValue.val = handleB23Result 49 | } catch (error) { 50 | if (error instanceof Error) return alert(error.message) 51 | } 52 | try { 53 | const handleSeasonsArchivesListResult = await handleSeasonsArchivesList(workRoute.urlValue.val) 54 | if (handleSeasonsArchivesListResult) workRoute.urlValue.val = handleSeasonsArchivesListResult 55 | } catch (error) { 56 | if (error instanceof Error) return alert(error.message) 57 | } 58 | const { type, value } = checkURL(workRoute.urlValue.val) 59 | workRoute.urlInvalid.val = false 60 | await start(workRoute, { 61 | idType: type, 62 | value, 63 | from: 'click' 64 | }).catch(error => { 65 | const errorMessage = `获取视频信息失败:${error.message}` 66 | alert(errorMessage) 67 | goto('work') 68 | workRoute.videoInfoCardMode.val = 'hide' 69 | }) 70 | } catch (error) { 71 | workRoute.urlInvalid.val = true 72 | } finally { 73 | setTimeout(() => { 74 | workRoute.btnLoading.val = false 75 | }, 200) 76 | } 77 | }, 78 | id, 79 | disabled: workRoute.btnLoading 80 | }, span({ class: 'spinner-border spinner-border-sm me-2', hidden: () => !workRoute.btnLoading.val }), 81 | () => workRoute.btnLoading.val ? '解析中' : '解析视频' 82 | ) 83 | } 84 | 85 | export default (workRoute: WorkRoute) => new InputBoxComp(workRoute).element -------------------------------------------------------------------------------- /client/src/work/view/parseModal.ts: -------------------------------------------------------------------------------- 1 | import van, { State } from 'vanjs-core' 2 | import { VanComponent, formatSeconds } from '../../mixin' 3 | import { PageInParseResult, PlayInfo, VideoFormat } from '../type' 4 | import { WorkRoute } from '..' 5 | import { createTask, getPlayInfo } from '../data' 6 | import PQueue from 'p-queue' 7 | 8 | const { a, button, div, input } = van.tags 9 | 10 | type Option = { 11 | workRoute: WorkRoute 12 | } 13 | 14 | const videoFormatMap: Record = { 15 | 127: "超高清 8K", 16 | 126: "杜比视界", 17 | 125: "真彩 HDR", 18 | 120: "超清 4K", 19 | 116: "高清 1080P60", 20 | 112: "高清 1080P+", 21 | 80: "高清 1080P", 22 | 74: "高清 720P60", 23 | 64: "高清 720P", 24 | 32: "清晰 480P", 25 | 16: "流畅 360P", 26 | 6: "极速 240P", 27 | } 28 | 29 | export class ParseModalComp implements VanComponent { 30 | element: HTMLElement 31 | 32 | totalCount: State 33 | finishCount = van.state(0) 34 | 35 | abortControllers: AbortController[] = [] 36 | 37 | currentController?: AbortController 38 | 39 | allPlayInfo: State<{ 40 | page: PageInParseResult 41 | info: PlayInfo | null 42 | selected: State 43 | formatIndex: State 44 | }[]> = van.state([]) 45 | 46 | /** 该属性用于在点击“开始下载”按钮后使按钮变为禁用状态,防止多次点击 */ 47 | downloadBtnDisabled = van.state(false) 48 | 49 | errorList: State = van.state([]) 50 | 51 | constructor(public option: Option) { 52 | this.totalCount = van.derive(() => option.workRoute.selectedPages.val.length) 53 | const allFinish = van.derive(() => this.totalCount.val == this.finishCount.val) 54 | this.element = div({ class: `modal fade`, tabIndex: -1 }, 55 | div({ class: () => `modal-dialog modal-xl modal-fullscreen-xl-down ${(this.totalCount.val + this.errorList.val.length) < 10 ? '' : 'modal-dialog-scrollable'}` }, 56 | div({ class: `modal-content` }, 57 | div({ class: `modal-header` }, 58 | div({ class: `h5 modal-title` }, () => allFinish.val ? '批量下载' : '批量解析'), 59 | button({ class: `btn-close`, 'data-bs-dismiss': `modal` }) 60 | ), 61 | div({ class: `modal-body vstack gap-3`, tabIndex: -1, style: 'outline: none;' }, 62 | this.ParseProgress(), 63 | div({ class: 'vstack gap-2', hidden: () => this.errorList.val.length == 0 || !allFinish.val }, 64 | div({ class: 'text-danger' }, () => `以下 ${this.errorList.val.length} 个视频解析失败`), 65 | () => div({ class: 'list-group' }, 66 | this.errorList.val.map(error => div({ class: 'list-group-item disabled' }, error)) 67 | ) 68 | ), 69 | this.ListGroup() 70 | ), 71 | this.ModalFooter() 72 | ) 73 | ) 74 | ) 75 | 76 | this.element.addEventListener('hidden.bs.modal', () => { 77 | this.allPlayInfo.val = [] 78 | this.finishCount.val = this.totalCount.val 79 | for (const controller of this.abortControllers) { 80 | controller.abort() 81 | } 82 | this.abortControllers = [] 83 | }) 84 | 85 | this.element.addEventListener('show.bs.modal', () => { 86 | this.start() 87 | }) 88 | } 89 | 90 | /** 开始解析 */ 91 | async start() { 92 | this.finishCount.val = 0 93 | this.errorList.val = [] 94 | const queue = new PQueue({ concurrency: 10 }) 95 | for (const page of this.option.workRoute.selectedPages.val) { 96 | queue.add(async () => { 97 | if (this.totalCount.val == this.finishCount.val) return 98 | const controller = new AbortController() 99 | this.abortControllers.push(controller) 100 | const playInfo = await getPlayInfo(page.bvid, page.cid, controller) 101 | playInfo.accept_quality = [...new Set(playInfo.dash.video.map(video => video.id))].sort((a, b) => b - a) 102 | this.allPlayInfo.val = this.allPlayInfo.val.concat({ 103 | page, 104 | info: playInfo, 105 | selected: van.state(true), 106 | formatIndex: van.state(0), 107 | }) 108 | this.finishCount.val++ 109 | }).catch(() => { 110 | this.finishCount.val++ 111 | const badgeNotNum = !page.bandge.match(/^\d+$/) 112 | this.errorList.val = this.errorList.val.concat(`${page.part}${badgeNotNum ? ` - ${page.bandge}` : ''}`) 113 | }) 114 | } 115 | await queue.onIdle() 116 | } 117 | 118 | download() { 119 | const selectedPlayInfos = this.allPlayInfo.val.filter(info => info.selected.val) 120 | const workRoute = this.option.workRoute 121 | this.downloadBtnDisabled.val = true 122 | // 需要传递给服务器,需要创建下载任务的数据列表 123 | createTask(selectedPlayInfos.map(info => { 124 | const badgeNotNum = !info.page.bandge.match(/^\d+$/) 125 | const isVideoMode = workRoute.videoInfoCardMode.val == 'video' 126 | const cardTitle = workRoute.videoInfocardData.val.title 127 | const owner = workRoute.videoInfocardData.val.staff.length > 0 128 | ? workRoute.videoInfocardData.val.staff[0].split("[")[0].trim() 129 | : workRoute.videoInfocardData.val.owner.name.trim() 130 | const activeVideoInfo = getActiveFormatVideo(info.info!, info.info!.accept_quality[info.formatIndex.val]) 131 | const pagesLength = workRoute.videoInfocardData.val.pages.length 132 | 133 | return ({ 134 | bvid: info.page.bvid, 135 | cid: info.page.cid, 136 | cover: workRoute.videoInfocardData.val.cover, 137 | title: (badgeNotNum 138 | ? [ 139 | info.page.part.trim(), 140 | `[${info.page.bandge.trim()}]`, 141 | `[${cardTitle.trim()}]`, 142 | `[${videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]}]`, 143 | `[${formatSeconds(info.info!.dash.duration)}]` 144 | ] 145 | : [ 146 | pagesLength == 1 ? workRoute.allSection.val[workRoute.sectionTabsActiveIndex.val].title : `[${cardTitle.trim()}]`, 147 | workRoute.sectionPages.val.length == 1 ? '' : `[${info.page.bandge.trim()}]`, 148 | info.page.part.trim(), 149 | isVideoMode ? `[${owner}]` : '', 150 | `[${videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]}]`, 151 | `[${formatSeconds(info.info!.dash.duration)}]` 152 | ]).filter(p => p).join(' '), 153 | format: info.info!.accept_quality[info.formatIndex.val], 154 | owner, 155 | audio: getAudioURL(info.info!), 156 | duration: info.info!.dash.duration, 157 | ...activeVideoInfo 158 | }) 159 | })).then(() => { 160 | workRoute.parseModal.hide() 161 | }).catch(error => { 162 | alert(error.message) 163 | }).finally(() => { 164 | this.downloadBtnDisabled.val = false 165 | }) 166 | } 167 | 168 | ParseProgress() { 169 | return div({ class: 'vstack gap-3', hidden: () => this.totalCount.val == this.finishCount.val }, 170 | div({ class: 'text-center fs-5' }, () => `正在解析,剩余 ${this.totalCount.val - this.finishCount.val} 项`), 171 | div({ class: 'progress' }, div({ 172 | class: 'progress-bar progress-bar-striped progress-bar-animated', 173 | style: () => `width: ${this.finishCount.val / this.totalCount.val * 100}%` 174 | },)), 175 | ) 176 | } 177 | 178 | ListGroup() { 179 | return () => div({ class: 'list-group', hidden: () => this.totalCount.val != this.finishCount.val }, 180 | this.allPlayInfo.val.filter(info => info.info) 181 | .sort((a, b) => a.page.page - b.page.page) 182 | .map(info => { 183 | const badgeNotNum = !info.page.bandge.match(/^\d+$/) 184 | 185 | return div({ 186 | class: () => `list-group-item user-select-none py-0 ${info.info ? '' : 'disabled'}`, 187 | role: 'button', 188 | onclick(event) { 189 | if ((event.target as HTMLElement).getAttribute('class')?.match(/dropdown-?/)) return 190 | info.selected.val = !info.selected.val 191 | } 192 | }, 193 | div({ class: 'hstack gap-2' }, 194 | div({ class: 'hstack gap-3 flex-fill py-1' }, 195 | input({ 196 | class: 'form-check-input', type: 'checkbox', checked: info.selected, 197 | }), 198 | div({}, 199 | div((badgeNotNum ? '' : `${info.page.bandge}. `) + info.page.part), 200 | badgeNotNum ? div({ class: info.page.part ? 'small text-secondary' : '' }, 201 | info.page.bandge) : '' 202 | ), 203 | ), 204 | div({ class: 'dropdown' }, 205 | div({ class: 'dropdown-toggle py-2 text-primary', 'data-bs-toggle': 'dropdown' }, 206 | () => videoFormatMap[info.info!.accept_quality[info.formatIndex.val]] 207 | ), 208 | () => { 209 | return div({ class: 'dropdown-menu shadow' }, 210 | info.info!.accept_quality.map((formatID, index) => { 211 | return div({ 212 | class: () => `dropdown-item ${info.formatIndex.val == index ? 'active' : ''}`, 213 | onclick() { 214 | info.formatIndex.val = index 215 | } 216 | }, videoFormatMap[formatID as VideoFormat]) 217 | }) 218 | ) 219 | } 220 | ) 221 | ) 222 | ) 223 | }) 224 | ) 225 | } 226 | 227 | ModalFooter() { 228 | const _that = this 229 | 230 | const selectedCount = van.derive(() => this.allPlayInfo.val.filter(info => info.selected.val).length) 231 | const totalCount = van.derive(() => this.allPlayInfo.val.length) 232 | /** 解析完成列表全部选中 */ 233 | const allSelected = van.derive(() => selectedCount.val == totalCount.val) 234 | /** 是否全部解析完成 */ 235 | const allFinish = van.derive(() => this.totalCount.val == this.finishCount.val) 236 | 237 | return div({ class: `modal-footer` }, 238 | div({ class: 'me-auto', hidden: () => !allFinish.val || totalCount.val == 0 }, 239 | () => `已选择 (${selectedCount.val}/${totalCount.val}) 项` 240 | ), 241 | button({ 242 | class: `btn btn-secondary`, 243 | 'data-bs-dismiss': `modal`, 244 | hidden: allFinish 245 | }, '取消解析'), 246 | button({ 247 | class: 'btn btn-secondary', hidden: () => !allFinish.val || allSelected.val || totalCount.val == 0, 248 | onclick() { 249 | _that.allPlayInfo.val.forEach(info => info.selected.val = true) 250 | } 251 | }, '全选'), 252 | button({ 253 | class: 'btn btn-warning', hidden: () => !allFinish.val || !allSelected.val || totalCount.val == 0, 254 | onclick() { 255 | _that.allPlayInfo.val.forEach(info => info.selected.val = false) 256 | } 257 | }, '全不选'), 258 | button({ 259 | class: `btn btn-primary`, onclick() { 260 | _that.download() 261 | }, 262 | hidden: () => !allFinish.val, 263 | disabled: () => selectedCount.val <= 0 || _that.downloadBtnDisabled.val 264 | }, '开始下载'), 265 | ) 266 | } 267 | } 268 | 269 | const getAudioURL = (playInfo: PlayInfo): string => { 270 | if (playInfo.dash.flac) { 271 | return playInfo.dash.flac.audio.baseUrl 272 | } else { 273 | return playInfo.dash.audio.sort((a, b) => b.id - a.id)[0].baseUrl 274 | } 275 | } 276 | 277 | const getActiveFormatVideo = (playInfo: PlayInfo, format: VideoFormat): { video: string, width: number, height: number } => { 278 | for (const code of [12, 7, 13]) { 279 | for (const item of playInfo.dash.video) { 280 | if (item.id == format && item.codecid == code) { 281 | return { 282 | video: item.baseUrl, 283 | width: item.width, 284 | height: item.height 285 | } 286 | } 287 | } 288 | } 289 | throw new Error('未找到对应视频分辨率格式') 290 | } 291 | -------------------------------------------------------------------------------- /client/src/work/view/videoInfoCard.ts: -------------------------------------------------------------------------------- 1 | import van, { State, Val } from 'vanjs-core' 2 | import { VideoParseResult, VideoInfoCardMode } from '../type' 3 | import { secondToTime } from '../mixin' 4 | import { VanComponent } from '../../mixin' 5 | import VideoItemList from './videoItemList' 6 | import { WorkRoute } from '..' 7 | 8 | const { a, div, img } = van.tags 9 | 10 | class VideoInfoCardComp implements VanComponent { 11 | element: HTMLElement 12 | 13 | constructor( 14 | public workRoute: WorkRoute, 15 | ) { 16 | const { 17 | videoInfocardData: data, 18 | videoInfoCardMode: mode, 19 | ownerFaceHide 20 | } = workRoute 21 | 22 | this.element = div({ class: 'card border-2 shadow-sm' }, 23 | div({ class: 'card-header' }, 24 | a({ 25 | class: 'link-dark text-decoration-none fw-bold focus-ring', href: () => data.val.targetURL, 26 | target: '_blank', 27 | }, 28 | () => data.val.title, 29 | ) 30 | ), 31 | div({ class: 'card-body vstack gap-4' }, 32 | div({ class: 'vstack gap-3' }, 33 | div({ class: 'row gx-3 gy-3' }, 34 | // 封面 35 | div({ 36 | class: () => mode.val == 'video' 37 | ? 'col-md-5 col-xl-4' 38 | : 'col-8 col-sm-6 mx-auto col-md-5 col-lg-3 col-xl-2' 39 | }, 40 | div({ class: 'position-relative shadow-sm rounded overflow-hidden' }, 41 | a({ 42 | href: () => data.val.targetURL, 43 | title: () => `打开视频播放页面`, 44 | target: '_blank', 45 | }, 46 | img({ 47 | src: () => data.val.cover, 48 | class: 'w-100', 49 | ondragstart: event => event.preventDefault(), 50 | referrerPolicy: 'no-referrer', 51 | }) 52 | ), 53 | a({ 54 | href: () => `https://space.bilibili.com/${data.val.owner.mid}`, 55 | title: () => `查看用户主页:${data.val.owner.name}`, 56 | target: '_blank', 57 | tabIndex: () => mode.val == 'video' ? 0 : -1, 58 | }, 59 | img({ 60 | src: () => data.val.owner.face, 61 | hidden: ownerFaceHide, 62 | referrerPolicy: 'no-referrer', 63 | ondragstart: event => event.preventDefault(), 64 | style: `right: 1rem; bottom: 1rem;`, 65 | class: 'rounded-3 border shadow position-absolute w-25' 66 | }) 67 | ), 68 | ), 69 | ), 70 | // 字段信息 71 | div({ 72 | class: () => mode.val == 'video' 73 | ? 'col-md-7 col-xl-8 vstack gap-2' 74 | : 'col-md-7 col-lg-9 col-xl-10 vstack gap-2' 75 | }, 76 | div({ class: 'position-relative h-100' }, 77 | div({ class: 'position-absolute top-0 bottom-0 position-relative-sm-down' }, 78 | Right(this) 79 | ) 80 | ), 81 | ), 82 | ), 83 | DescriptionGroup(this, true), 84 | ), 85 | VideoItemList(workRoute) 86 | ) 87 | ) 88 | } 89 | } 90 | 91 | const Right = (parent: VideoInfoCardComp) => { 92 | const mode = parent.workRoute.videoInfoCardMode 93 | const data = parent.workRoute.videoInfocardData 94 | 95 | return div({ class: 'vstack gap-2 h-100' }, 96 | div({ class: 'row gx-2 gy-2' }, 97 | div({ class: 'col-xl-7 col-xxl-8' }, 98 | InputGroup( 99 | van.derive(() => mode.val == 'video' 100 | ? (data.val.staff.length > 0 ? '制作信息' : '发布者') 101 | : '参演人员'), 102 | van.derive(() => { 103 | if (data.val.staff.length > 0) 104 | return data.val.staff.map(i => i.trim()).join(', ') 105 | return data.val.owner.name 106 | }), { disabled: true } 107 | ), 108 | ), 109 | div({ class: 'col-xl-5 col-xxl-4' }, 110 | InputGroup('发布时间', 111 | van.derive(() => data.val.publishData), { disabled: true } 112 | ) 113 | ), 114 | div({ class: 'col-sm col-md-12 col-lg-4', hidden: () => mode.val != 'video' }, 115 | InputGroup('分辨率', 116 | van.derive(() => `${data.val.dimension.width}x${data.val.dimension.height}`), 117 | { disabled: true } 118 | ) 119 | ), 120 | div({ class: 'col col-lg-4', hidden: () => mode.val != 'video' }, 121 | InputGroup('时长', 122 | van.derive(() => `${secondToTime(data.val.duration)}`), 123 | { disabled: true } 124 | ) 125 | ), 126 | div({ class: 'col col-lg-4', hidden: () => mode.val != 'video' }, 127 | InputGroup('集数', 128 | van.derive(() => data.val.pages.length.toString()), 129 | { disabled: true } 130 | ) 131 | ), 132 | div({ class: 'col-md-12 col-lg-4', hidden: () => mode.val != 'season' }, 133 | InputGroup('状态', 134 | van.derive(() => data.val.status), 135 | { disabled: true } 136 | ) 137 | ), 138 | div({ class: 'col-sm col-lg-4', hidden: () => mode.val != 'season' }, 139 | InputGroup('地区', 140 | van.derive(() => data.val.areas.map(i => i.trim()).join(', ')), 141 | { disabled: true } 142 | ) 143 | ), 144 | div({ class: 'col-sm col-lg-4', hidden: () => mode.val != 'season' }, 145 | InputGroup('标签', 146 | van.derive(() => data.val.styles.join(', ')), 147 | { disabled: true } 148 | ) 149 | ), 150 | ), 151 | DescriptionGroup(parent), 152 | ) 153 | } 154 | 155 | /** 用于显示 `description` 字段的 `.input-group` 156 | * 157 | * @param parent 父组件 158 | * @param bottom 是否在底部 159 | */ 160 | const DescriptionGroup = (parent: VideoInfoCardComp, bottom = false) => { 161 | const mode = parent.workRoute.videoInfoCardMode 162 | const data = parent.workRoute.videoInfocardData 163 | const _class = van.derive(() => mode.val == 'video' ? 'd-md-flex' : '') 164 | const size = van.derive(() => mode.val == 'video' ? 'lg' : 'lg') 165 | return div({ 166 | class: () => `shadow-sm input-group input-group-sm ${bottom 167 | ? `d-none d-lg-none ${_class.val}` 168 | : `overflow-hidden flex-fill ${mode.val == 'video' ? 'd-md-none d-lg-flex ' : ''}` 169 | }`, 170 | }, 171 | div({ class: 'input-group-text align-items-start' }, '描述'), 172 | () => { 173 | const lines = (data.val.description.match(/^(\s*|.)$/) ? '暂无描述' : data.val.description).split('\n') 174 | return div({ class: `form-control overflow-auto ${bottom ? `max-height-description` : `h-100`}` }, 175 | lines.map(line => div(line)) 176 | ) 177 | } 178 | ) 179 | } 180 | 181 | const InputGroup = (title: Val, value: State, option?: { 182 | disabled?: Val 183 | elementType?: 'input' | 'textarea' 184 | }) => { 185 | return div({ class: 'input-group input-group-sm shadow-sm rounded' }, 186 | div({ class: 'input-group-text' }, title), 187 | van.tags[option?.elementType || 'input']({ 188 | class: 'form-control bg-white', 189 | disabled: option?.disabled || false, 190 | style: 'cursor: text;', 191 | value 192 | }) 193 | ) 194 | } 195 | 196 | export default ( 197 | workRoute: WorkRoute 198 | ) => new VideoInfoCardComp(workRoute).element -------------------------------------------------------------------------------- /client/src/work/view/videoItemList.ts: -------------------------------------------------------------------------------- 1 | import van, { State } from 'vanjs-core' 2 | import { VideoParseResult, VideoInfoCardMode, PageInParseResult, SectionItem } from '../type' 3 | import { VanComponent } from '../../mixin' 4 | import { WorkRoute } from '..' 5 | 6 | const { button, div, span } = van.tags 7 | 8 | class VideoItemListComp implements VanComponent { 9 | element: HTMLElement 10 | 11 | constructor( 12 | public workRoute: WorkRoute 13 | ) { 14 | const { videoInfocardData: data } = workRoute 15 | 16 | this.element = div({ 17 | hidden: () => false && data.val.pages.length <= 1, 18 | class: 'vstack gap-4' 19 | }, 20 | div({ class: 'vstack gap-4' }, 21 | div({ hidden: () => workRoute.allSection.val.length == 1 && workRoute.allSection.val[0].title == '正片' }, SectionTabs(this, workRoute.allSection)), 22 | ButtonGroup(workRoute), 23 | ListBox(workRoute.sectionPages), 24 | ) 25 | ) 26 | } 27 | } 28 | 29 | const SectionTabs = (parent: VideoItemListComp, allSection: State) => { 30 | return () => div({ class: 'nav nav-underline' }, 31 | allSection.val.map((item, index) => div({ class: 'nav-item', role: 'button' }, 32 | div({ 33 | tabIndex: 0, 34 | class: `nav-link ${parent.workRoute.sectionTabsActiveIndex.val == index ? 'active' : ''}`, 35 | onclick() { 36 | parent.workRoute.sectionTabsActiveIndex.val = index 37 | }, 38 | onkeyup(e) { 39 | if (e.key == 'Enter') { 40 | e.target.click() 41 | } 42 | } 43 | }, () => item.title) 44 | )) 45 | ) 46 | } 47 | 48 | const ButtonGroup = (workRoute: WorkRoute) => { 49 | const pages = workRoute.sectionPages 50 | const selectedCount = van.derive(() => pages.val.filter(page => page.selected.val).length) 51 | const totalCount = van.derive(() => pages.val.length) 52 | 53 | return div({ class: 'hstack gap-3' }, 54 | button({ 55 | class: 'btn btn-secondary', 56 | onclick() { 57 | pages.val.forEach(page => page.selected.val = selectedCount.val < totalCount.val) 58 | } 59 | }, () => `${selectedCount.val < totalCount.val ? '全选' : '取消全选'} (${selectedCount.val}/${totalCount.val})`), 60 | button({ 61 | class: 'btn btn-primary', 62 | disabled: () => selectedCount.val <= 0, 63 | async onclick() { 64 | workRoute.parseModal.show() 65 | } 66 | }, '解析选中项目') 67 | ) 68 | } 69 | 70 | const ListBox = (pages: State) => { 71 | return () => div({ class: 'row gy-3 gx-3' }, 72 | pages.val.map(page => { 73 | const bandgeNotNum = !page.bandge.match(/^\d+$/) 74 | const active = page.selected 75 | return div({ class: 'col-xxl-3 col-lg-4 col-md-6' }, 76 | div({ 77 | tabIndex: 0, 78 | class: () => `${bandgeNotNum 79 | ? `vstack gap-2 justify-content-center` 80 | : `hstack gap-3` 81 | } shadow-sm h-100 text-break user-select-none card card-body video-item-btn bg-success bg-opacity-10 ${active.val ? 'active' : ''}`, 82 | onclick() { 83 | active.val = !active.val 84 | }, 85 | onkeyup(e) { 86 | if (e.key == 'Enter') { 87 | active.val = !active.val 88 | } 89 | } 90 | }, 91 | span({ class: 'badge text-bg-success bg-opacity-75 border', hidden: bandgeNotNum }, page.bandge), 92 | div(page.part), 93 | div({ class: `${page.part ? 'small text-muted' : ''}`, hidden: !bandgeNotNum }, page.bandge), 94 | ) 95 | ) 96 | }), 97 | ) 98 | } 99 | 100 | export default ( 101 | workRoute: WorkRoute 102 | ) => new VideoItemListComp(workRoute).element -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "target": "ESNext" 7 | } 8 | } -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | 3 | export default defineConfig({ 4 | clearScreen: false, 5 | server: { 6 | proxy: { 7 | '/api': 'http://127.0.0.1:8098' 8 | }, 9 | host: '0.0.0.0' 10 | }, 11 | build: { 12 | outDir: '../server/static', 13 | emptyOutDir: true, 14 | }, 15 | css: { 16 | preprocessorOptions: { 17 | scss: { 18 | api: 'modern-compiler' 19 | } 20 | } 21 | } 22 | }) -------------------------------------------------------------------------------- /docs/2024-11-05_090604.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iuroc/bilidown/eeb5832edfe7d2904e62da6707637b58e0081260/docs/2024-11-05_090604.png -------------------------------------------------------------------------------- /server/.air.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | exclude_dir = ["download", "bin"] 3 | include_ext = ["go"] -------------------------------------------------------------------------------- /server/.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 2 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | # you may remove this if you don't need go generate 16 | - go generate ./... 17 | 18 | builds: 19 | - id: bilidown_windows 20 | env: 21 | - CGO_ENABLED=1 22 | - >- 23 | {{- if eq .Arch "amd64" }}CC=x86_64-w64-mingw32-gcc{{ end }} 24 | {{- if eq .Arch "386" }}CC=i686-w64-mingw32-gcc{{ end }} 25 | goos: 26 | - windows 27 | ldflags: 28 | - -s -w -H windowsgui 29 | - id: bilidown_darwin 30 | env: 31 | - CGO_ENABLED=1 32 | - CC=o64-clang 33 | goos: 34 | - darwin 35 | ldflags: 36 | - -s -w 37 | - id: bilidown_linux 38 | env: 39 | - CGO_ENABLED=1 40 | - >- 41 | {{- if eq .Arch "arm64" }}CC=aarch64-linux-gnu-gcc{{ end }} 42 | goos: 43 | - linux 44 | goarch: 45 | - amd64 46 | ldflags: 47 | - -s -w 48 | archives: 49 | - format: tar.gz 50 | # this name template makes the OS and Arch compatible with the results of `uname`. 51 | name_template: >- 52 | {{ .ProjectName }}_ 53 | {{- title .Os }}_ 54 | {{- if eq .Arch "amd64" }}x86_64 55 | {{- else if eq .Arch "386" }}i386 56 | {{- else }}{{ .Arch }}{{ end }} 57 | {{- if .Arm }}v{{ .Arm }}{{ end }} 58 | # use zip for windows archives 59 | format_overrides: 60 | - goos: windows 61 | format: zip 62 | files: 63 | - static/** 64 | - >- 65 | {{- if eq .Os "windows" }}bin/ffmpeg.exe{{- end }} 66 | changelog: 67 | sort: asc 68 | filters: 69 | exclude: 70 | - "^docs:" 71 | - "^test:" 72 | -------------------------------------------------------------------------------- /server/bilibili/bilibili_test.go: -------------------------------------------------------------------------------- 1 | package bilibili_test 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "bilidown/bilibili" 10 | "bilidown/util" 11 | 12 | "github.com/skip2/go-qrcode" 13 | _ "modernc.org/sqlite" 14 | ) 15 | 16 | const TEST_SESSDATA = "" 17 | const TEST_BVID = "BV1KX4y1V7sA" // 普通合集 18 | const TEST_CID = 305542578 // 普通分集 19 | const TEST_BVID_HDR = "BV1rp4y1e745" // HDR 合集 20 | const TEST_CID_HDR = 244954665 // HDR 分集 21 | const TEST_EPID = 835909 // 剧集 22 | const TEST_SSID = 48744 // 番剧 23 | 24 | func TestBiliClient(t *testing.T) { 25 | client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA} 26 | check, err := client.CheckLogin() 27 | if err != nil { 28 | t.Error(err) 29 | } 30 | if check { 31 | fmt.Println("SESSDATA 有效") 32 | } else { 33 | fmt.Println("SESSDATA 无效") 34 | } 35 | } 36 | 37 | func TestNewQRInfo(t *testing.T) { 38 | client := bilibili.BiliClient{} 39 | info, err := client.NewQRInfo() 40 | if err != nil { 41 | t.Error(err) 42 | } 43 | fmt.Printf("二维码信息:%+v", info) 44 | } 45 | 46 | func TestGetQRStatus(t *testing.T) { 47 | client := bilibili.BiliClient{} 48 | qrInfo, err := client.NewQRInfo() 49 | if err != nil { 50 | t.Error(err) 51 | } 52 | qr, err := qrcode.New(qrInfo.URL, qrcode.Low) 53 | if err != nil { 54 | t.Error(err) 55 | } 56 | fmt.Println(qr.ToSmallString(false)) 57 | 58 | for { 59 | qrStatus, sessdata, err := client.GetQRStatus(qrInfo.QrcodeKey) 60 | fmt.Println(qrStatus.Message) 61 | if err != nil { 62 | t.Error(err) 63 | } 64 | if qrStatus.Code == bilibili.QR_SUCCESS { 65 | fmt.Println("登录成功") 66 | fmt.Println(sessdata) 67 | break 68 | } else if qrStatus.Code == bilibili.QR_EXPIRES { 69 | break 70 | } 71 | time.Sleep(time.Second) 72 | } 73 | } 74 | 75 | func TestGetBVInfo(t *testing.T) { 76 | client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA} 77 | videoInfo, err := client.GetVideoInfo(TEST_BVID) 78 | if err != nil { 79 | t.Error(err) 80 | } 81 | fmt.Printf("%+v", videoInfo) 82 | } 83 | 84 | func TestGetSeasonInfo(t *testing.T) { 85 | client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA} 86 | seasonInfo, err := client.GetSeasonInfo(TEST_EPID, TEST_SSID) 87 | if err != nil { 88 | t.Error(err) 89 | } 90 | fmt.Printf("%+v", seasonInfo) 91 | } 92 | 93 | func TestGetPlayInfo(t *testing.T) { 94 | client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA} 95 | playInfo, err := client.GetPlayInfo(TEST_BVID_HDR, TEST_CID_HDR) 96 | if err != nil { 97 | t.Error(err) 98 | } 99 | a, _ := json.Marshal(playInfo.AcceptQuality) 100 | b, _ := json.Marshal(playInfo.AcceptDescription) 101 | c, _ := json.Marshal(playInfo.SupportFormats) 102 | 103 | fmt.Println(string(a)) 104 | fmt.Println(string(b)) 105 | fmt.Println(string(c)) 106 | } 107 | 108 | func TestSaveSessdata(t *testing.T) { 109 | db := util.MustGetDB("../data.db") 110 | defer db.Close() 111 | err := bilibili.SaveSessdata(db, TEST_SESSDATA) 112 | if err != nil { 113 | t.Error(err) 114 | } else { 115 | fmt.Println("SESSDATA 保存成功") 116 | } 117 | sessdata, err := bilibili.GetSessdata(db) 118 | if err != nil { 119 | t.Error(err) 120 | } 121 | if sessdata != TEST_SESSDATA { 122 | t.Error("SESSDATA 读写不一致") 123 | } else { 124 | fmt.Println("SESSDATA 读取成功") 125 | } 126 | } 127 | 128 | func TestGetSessdata(t *testing.T) { 129 | db := util.MustGetDB("../data.db") 130 | defer db.Close() 131 | sessdata, err := bilibili.GetSessdata(db) 132 | if err != nil { 133 | t.Error(err) 134 | } 135 | 136 | fmt.Println(sessdata) 137 | } 138 | 139 | func TestGetPopularVideos(t *testing.T) { 140 | client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA} 141 | videos, err := client.GetPopularVideos() 142 | if err != nil { 143 | t.Error(err) 144 | } 145 | fmt.Printf("%+v", videos) 146 | } 147 | -------------------------------------------------------------------------------- /server/bilibili/client.go: -------------------------------------------------------------------------------- 1 | package bilibili 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "net/url" 9 | "regexp" 10 | 11 | "bilidown/util" 12 | ) 13 | 14 | type BiliClient struct { 15 | SESSDATA string 16 | } 17 | 18 | // SimpleGET 简单的 GET 请求 19 | func (client *BiliClient) SimpleGET(_url string, params map[string]string) (*http.Response, error) { 20 | values := url.Values{} 21 | for k, v := range params { 22 | values.Set(k, v) 23 | } 24 | _client := http.Client{ 25 | Transport: &http.Transport{ 26 | Proxy: http.ProxyURL(nil), 27 | }, 28 | } 29 | request, err := http.NewRequest("GET", _url+"?"+values.Encode(), nil) 30 | if err != nil { 31 | return nil, err 32 | } 33 | request.Header = client.MakeHeader() 34 | return _client.Do(request) 35 | } 36 | 37 | // MakeHeader 生成请求头 38 | func (client *BiliClient) MakeHeader() http.Header { 39 | header := http.Header{} 40 | header.Set("Cookie", "SESSDATA="+client.SESSDATA) 41 | header.Set("User-Agent", "Mozilla/5.0") 42 | header.Set("Referer", "https://www.bilibili.com") 43 | return header 44 | } 45 | 46 | // CheckLogin 检查是否已经登录 47 | func (client *BiliClient) CheckLogin() (bool, error) { 48 | response, err := client.SimpleGET("https://api.bilibili.com/x/space/myinfo", nil) 49 | if err != nil { 50 | return false, err 51 | } 52 | defer response.Body.Close() 53 | body := BaseResV2{} 54 | err = json.NewDecoder(response.Body).Decode(&body) 55 | if err != nil { 56 | return false, err 57 | } 58 | if body.Code != 0 { 59 | return false, errors.New(body.Message) 60 | } 61 | return body.Success(), nil 62 | } 63 | 64 | // NewQRInfo 获取登录二维码信息 65 | func (client *BiliClient) NewQRInfo() (*QRInfo, error) { 66 | response, err := client.SimpleGET("https://passport.bilibili.com/x/passport-login/web/qrcode/generate", nil) 67 | if err != nil { 68 | return nil, err 69 | } 70 | defer response.Body.Close() 71 | body := BaseResV2{} 72 | err = json.NewDecoder(response.Body).Decode(&body) 73 | if err != nil { 74 | return nil, err 75 | } 76 | if body.Code != 0 { 77 | return nil, errors.New(body.Message) 78 | } 79 | qrInfo := QRInfo{} 80 | err = json.Unmarshal(body.Data, &qrInfo) 81 | if err != nil { 82 | return nil, err 83 | } 84 | return &qrInfo, nil 85 | } 86 | 87 | func (client *BiliClient) getWbiKeyRemote() (wbiKey string, err error) { 88 | if client.SESSDATA == "" { 89 | return "", errors.New("SESSDATA 不能为空") 90 | } 91 | response, err := client.SimpleGET("https://api.bilibili.com/x/web-interface/nav", nil) 92 | if err != nil { 93 | return "", err 94 | } 95 | defer response.Body.Close() 96 | body := BaseResV2{} 97 | if err = json.NewDecoder(response.Body).Decode(&body); err != nil { 98 | return "", err 99 | } 100 | var data struct { 101 | WbiImg struct { 102 | ImgURL string `json:"img_url"` 103 | SubURL string `json:"sub_url"` 104 | } `json:"wbi_img"` 105 | } 106 | if err = json.Unmarshal(body.Data, &data); err != nil { 107 | return "", err 108 | } 109 | match := regexp.MustCompile(`/bfs/wbi/([a-z0-9]+)\.`) 110 | imgKey := match.FindStringSubmatch(data.WbiImg.ImgURL)[1] 111 | subKey := match.FindStringSubmatch(data.WbiImg.SubURL)[1] 112 | if imgKey == "" || subKey == "" { 113 | return "", errors.New("regexp.MustCompile(`/bfs/wbi/([a-z0-9])\\.`)") 114 | } 115 | return imgKey + subKey, nil 116 | } 117 | 118 | // GetQRStatus 获取二维码状态 119 | func (client *BiliClient) GetQRStatus(qrKey string) (qrStatus *QRStatus, sessdata string, err error) { 120 | params := map[string]string{ 121 | "qrcode_key": qrKey, 122 | } 123 | response, err := client.SimpleGET("https://passport.bilibili.com/x/passport-login/web/qrcode/poll", params) 124 | if err != nil { 125 | return nil, "", err 126 | } 127 | defer response.Body.Close() 128 | body := BaseResV2{} 129 | err = json.NewDecoder(response.Body).Decode(&body) 130 | if err != nil { 131 | return nil, "", err 132 | } 133 | if body.Code != 0 { 134 | return nil, "", errors.New(body.Message) 135 | } 136 | qrStatus = &QRStatus{} 137 | err = json.Unmarshal(body.Data, &qrStatus) 138 | if err != nil { 139 | return nil, "", err 140 | } 141 | if qrStatus.Code != 0 { 142 | return qrStatus, "", nil 143 | } 144 | sessdata, err = GetCookieValue(response.Cookies(), "SESSDATA") 145 | if err != nil { 146 | return nil, "", err 147 | } 148 | return qrStatus, sessdata, nil 149 | } 150 | 151 | // GetCookieValue 获取指定 Name 的 Cookie 值 152 | func GetCookieValue(cookies []*http.Cookie, name string) (string, error) { 153 | for _, cookie := range cookies { 154 | if cookie.Name == name { 155 | return cookie.Value, nil 156 | } 157 | } 158 | return "", errors.New("cookie with name " + name + " not found") 159 | } 160 | 161 | // SaveSessdata 保存 SESSDATA 162 | func SaveSessdata(db *sql.DB, sessdata string) error { 163 | util.SqliteLock.Lock() 164 | _, err := db.Exec(`INSERT OR REPLACE INTO "field" ("name", "value") VALUES ("sessdata", ?)`, sessdata) 165 | util.SqliteLock.Unlock() 166 | return err 167 | } 168 | 169 | // GetSessdata 获取 SESSDATA 170 | func GetSessdata(db *sql.DB) (string, error) { 171 | util.SqliteLock.Lock() 172 | row := db.QueryRow(`SELECT "value" FROM "field" WHERE "name" = "sessdata"`) 173 | util.SqliteLock.Unlock() 174 | var sessdata string 175 | err := row.Scan(&sessdata) 176 | return sessdata, err 177 | } 178 | -------------------------------------------------------------------------------- /server/bilibili/type.go: -------------------------------------------------------------------------------- 1 | package bilibili 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "bilidown/common" 7 | ) 8 | 9 | // BaseRes 来自 Bilibili 的接口响应,Message 字段为 msg 10 | type BaseRes struct { 11 | Code int `json:"code"` 12 | Message string `json:"msg"` 13 | Data json.RawMessage `json:"data"` 14 | } 15 | 16 | // BaseRes2 来自 Bilibili 的接口响应,Message 字段为 message 而不是 msg 17 | type BaseResV2 struct { 18 | Code int `json:"code"` 19 | Message string `json:"message"` 20 | Data json.RawMessage `json:"data"` 21 | } 22 | 23 | // BaseResV3 来自 Bilibili 的接口响应,Message 字段为 message 而不是 msg,Data 字段为 result 24 | type BaseResV3 struct { 25 | Code int `json:"code"` 26 | Message string `json:"message"` 27 | Result json.RawMessage `json:"result"` 28 | } 29 | 30 | func (b *BaseRes) Success() bool { 31 | return b.Code == 0 32 | } 33 | 34 | func (b *BaseResV2) Success() bool { 35 | return b.Code == 0 36 | } 37 | 38 | func (b *BaseResV3) Success() bool { 39 | return b.Code == 0 40 | } 41 | 42 | type QRInfo struct { 43 | URL string `json:"url"` 44 | QrcodeKey string `json:"qrcode_key"` 45 | } 46 | 47 | type QRStatus struct { 48 | URL string `json:"string"` 49 | RefreshToken string `json:"refresh_token"` 50 | Code int `json:"code"` 51 | Message string `json:"message"` 52 | } 53 | 54 | const ( 55 | QR_NO_SCAN = 86101 // 未扫码 56 | QR_NO_CLICK = 86090 // 已扫码未确认 57 | QR_EXPIRES = 86038 // 已过期 58 | QR_SUCCESS = 0 // 已确认登录 59 | ) 60 | 61 | // Dimension 视频分辨率 62 | type Dimension struct { 63 | Width int `json:"width"` 64 | Height int `json:"height"` 65 | Rotate int `json:"rotate"` 66 | } 67 | 68 | // StaffItem 视频人员 69 | type StaffItem struct { 70 | Mid int `json:"mid"` 71 | Title string `json:"title"` 72 | Name string `json:"name"` 73 | Face string `json:"face"` 74 | } 75 | 76 | // Page 分集信息 77 | type Page struct { 78 | // 配合 Bvid 用于获取播放地址 79 | Cid int `json:"cid"` 80 | // 当前分集在合集中的序号,从 1 开始 81 | Page int `json:"page"` 82 | // 分集标题 83 | Part string `json:"part"` 84 | // 分集时长 85 | Duration int `json:"duration"` 86 | // 分集分辨率 87 | Dimension `json:"dimension"` 88 | } 89 | 90 | // 通过 BVID 获取的视频信息 91 | type VideoInfo struct { 92 | Bvid string `json:"bvid"` 93 | Aid int `json:"aid"` 94 | Pic string `json:"pic"` 95 | Title string `json:"title"` 96 | Pubdate int `json:"pubdate"` 97 | Desc string `json:"desc"` 98 | Owner struct { 99 | Mid int `json:"mid"` 100 | Name string `json:"name"` 101 | Face string `json:"face"` 102 | } `json:"owner"` 103 | Dimension `json:"dimension"` 104 | Staff []StaffItem `json:"staff"` 105 | Pages []Page `json:"pages"` 106 | Duration int `json:"duration"` 107 | Stat struct { 108 | Aid int `json:"aid"` 109 | View int `json:"view"` // 播放数量 110 | Danmaku int `json:"danmaku"` // 弹幕数量 111 | Reply int `json:"reply"` // 评论数量 112 | Favorite int `json:"favorite"` // 收藏数量 113 | Coin int `json:"coin"` // 投币数量 114 | Share int `json:"share"` // 转发数量 115 | NowRank int `json:"now_rank"` // 当前排名 116 | HisRank int `json:"his_rank"` // 历史最高排名 117 | Like int `json:"like"` // 点赞数量 118 | Dislike int `json:"dislike"` // 不喜欢 119 | } `json:"stat"` 120 | UgcSeason struct { 121 | Sections []struct { 122 | Title string `json:"title"` 123 | Episodes []struct { 124 | Title string `json:"title"` 125 | Pages []Page `json:"pages"` 126 | Bvid string `json:"bvid"` 127 | } `json:"episodes"` 128 | } `json:"sections"` 129 | Title string `json:"title"` 130 | } `json:"ugc_season"` 131 | } 132 | 133 | // EpisodeInBV 通过 BV 获取的视频信息中的合集信息 134 | type EpisodeInBV struct { 135 | Aid int `json:"aid"` 136 | Bvid string `json:"bvid"` 137 | Cid int `json:"cid"` 138 | Pic string `json:"pic"` // 封面 139 | Dimension `json:"dimension"` // 分辨率 140 | Duration int `json:"duration"` // 时长 141 | EPID int `json:"ep_id"` 142 | LongTitle string `json:"long_title"` // 分集完整标题,比如【法外狂徒张三现身!】 143 | PubTime int `json:"pub_time"` // 发布时间 144 | Title string `json:"title"` // 分集简略标题,比如【1】 145 | } 146 | 147 | // Episode 剧集分集信息 148 | type Episode struct { 149 | Aid int `json:"aid"` 150 | Bvid string `json:"bvid"` 151 | Cid int `json:"cid"` 152 | Cover string `json:"cover"` // 封面 153 | Dimension `json:"dimension"` // 分辨率 154 | Duration int `json:"duration"` // 时长 155 | EPID int `json:"ep_id"` 156 | LongTitle string `json:"long_title"` // 分集完整标题,比如【法外狂徒张三现身!】 157 | PubTime int `json:"pub_time"` // 发布时间 158 | Title string `json:"title"` // 分集简略标题,比如【1】 159 | } 160 | 161 | // 通过 EPID 获取的视频信息 162 | type SeasonInfo struct { 163 | Actors string `json:"actors"` // 演员名单 164 | Areas []struct { 165 | ID int `json:"id"` 166 | Name string `json:"name"` 167 | } `json:"areas"` // 地区列表 168 | Cover string `json:"cover"` // 封面 169 | Evaluate string `json:"evaluate"` // 简介 170 | Publish struct { 171 | IsFinish int `json:"is_finish"` // 是否完结 172 | PubTime string `json:"pub_time"` // 发布时间 173 | } `json:"publish"` 174 | SeasonID int `json:"season_id"` // 剧集编号 175 | SeasonTitle string `json:"season_title"` // 剧集标题 176 | Stat struct { 177 | Coins int `json:"coins"` // 投币数量 178 | Danmakus int `json:"danmakus"` // 弹幕数量 179 | Favorite int `json:"favorite"` // 收藏数量 180 | Favorites int `json:"favorites"` // 追剧数量 181 | Likes int `json:"likes"` // 点赞数量 182 | Reply int `json:"reply"` // 评论数量 183 | Share int `json:"share"` // 分享数量 184 | Views int `json:"views"` // 播放数量 185 | } `json:"stat"` 186 | Styles []string `json:"styles"` // 剧集内容类型,例如 [ "短剧", "奇幻", "搞笑" ] 187 | Title string `json:"title"` // 剧集标题 188 | Total int `json:"total"` // 总集数 189 | 190 | Episodes []Episode `json:"episodes"` // 分集信息列表 191 | 192 | NewEp struct { 193 | Desc string `json:"desc"` // 更新状态文本 194 | IsNew int `json:"is_new"` // 是否是连载,0 为完结,1 为连载 195 | } `json:"new_ep"` 196 | 197 | Section []struct { 198 | Title string `json:"title"` 199 | Episodes []Episode `json:"episodes"` 200 | } `json:"section"` 201 | } 202 | 203 | type PlayInfo struct { 204 | AcceptDescription []string `json:"accept_description"` 205 | AcceptQuality []common.MediaFormat `json:"accept_quality"` 206 | SupportFormats []struct { 207 | Quality common.MediaFormat `json:"quality"` 208 | Format string `json:"format"` 209 | NewDescription string `json:"new_description"` 210 | Codecs []string `json:"codecs"` 211 | } `json:"support_formats"` 212 | Dash *Dash `json:"dash"` 213 | } 214 | 215 | type Dash struct { 216 | // 视频时长(秒) 217 | Duration int `json:"duration"` 218 | Video []Media `json:"video"` 219 | Audio []Media `json:"audio"` 220 | Flac *struct { 221 | Audio Media `json:"audio"` 222 | } `json:"flac"` 223 | } 224 | 225 | type Media struct { 226 | ID common.MediaFormat `json:"id"` 227 | BaseURL string `json:"baseUrl"` 228 | BackupURL []string `json:"backupUrl"` 229 | Bandwidth int `json:"bandwidth"` 230 | MimeType string `json:"mimeType"` 231 | Codecs string `json:"codecs"` 232 | Width int `json:"width"` 233 | Height int `json:"height"` 234 | FrameRate string `json:"frameRate"` 235 | Codecid int `json:"codecid"` 236 | } 237 | 238 | type FavList []struct { 239 | Title string `json:"title"` 240 | Cover string `json:"cover"` 241 | Intro string `json:"intro"` 242 | Duration int `json:"duration"` 243 | Upper struct { 244 | Mid int `json:"mid"` 245 | Name string `json:"name"` 246 | Face string `json:"face"` 247 | } `json:"upper"` 248 | PubTime int `json:"pubtime"` 249 | Bvid string `json:"bvid"` 250 | Ugc struct { 251 | FirstCid int `json:"first_cid"` 252 | } `json:"ugc"` 253 | } 254 | -------------------------------------------------------------------------------- /server/bilibili/video.go: -------------------------------------------------------------------------------- 1 | package bilibili 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "math/rand" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | // GetBVInfo 根据 BVID 获取视频信息 13 | func (client *BiliClient) GetVideoInfo(bvid string) (*VideoInfo, error) { 14 | if client.SESSDATA == "" { 15 | return nil, errors.New("SESSDATA 不能为空") 16 | } 17 | params := map[string]string{"bvid": bvid} 18 | response, err := client.SimpleGET("https://api.bilibili.com/x/web-interface/wbi/view", params) 19 | if err != nil { 20 | return nil, err 21 | } 22 | body := BaseResV2{} 23 | err = json.NewDecoder(response.Body).Decode(&body) 24 | if err != nil { 25 | return nil, err 26 | } 27 | if body.Code != 0 { 28 | return nil, errors.New(body.Message) 29 | } 30 | bvInfo := VideoInfo{} 31 | err = json.Unmarshal(body.Data, &bvInfo) 32 | if err != nil { 33 | return nil, err 34 | } 35 | return &bvInfo, nil 36 | } 37 | 38 | // GetSeasonInfo 根据 EPID 或 SSID 获取剧集信息 39 | func (client *BiliClient) GetSeasonInfo(epid int, ssid int) (*SeasonInfo, error) { 40 | if client.SESSDATA == "" { 41 | return nil, errors.New("SESSDATA 不能为空") 42 | } 43 | params := map[string]string{ 44 | "ep_id": strconv.Itoa(epid), 45 | "season_id": strconv.Itoa(ssid), 46 | } 47 | response, err := client.SimpleGET("https://api.bilibili.com/pgc/view/web/season", params) 48 | if err != nil { 49 | return nil, err 50 | } 51 | body := BaseResV3{} 52 | err = json.NewDecoder(response.Body).Decode(&body) 53 | if err != nil { 54 | return nil, err 55 | } 56 | if body.Code != 0 { 57 | return nil, errors.New(body.Message) 58 | } 59 | seasonInfo := SeasonInfo{} 60 | err = json.Unmarshal(body.Result, &seasonInfo) 61 | if err != nil { 62 | return nil, err 63 | } 64 | return &seasonInfo, nil 65 | } 66 | 67 | // GetPlayInfo 根据 BVID 和 CID 获取视频播放信息 68 | func (client *BiliClient) GetPlayInfo(bvid string, cid int) (*PlayInfo, error) { 69 | if client.SESSDATA == "" { 70 | return nil, errors.New("SESSDATA 不能为空") 71 | } 72 | params := map[string]string{ 73 | "bvid": bvid, 74 | "cid": strconv.Itoa(cid), 75 | "fnval": "4048", 76 | "fnver": "0", 77 | "fourk": "1", 78 | } 79 | response, err := client.SimpleGET("https://api.bilibili.com/x/player/playurl", params) 80 | if err != nil { 81 | return nil, err 82 | } 83 | body := BaseResV2{} 84 | err = json.NewDecoder(response.Body).Decode(&body) 85 | if err != nil { 86 | return nil, err 87 | } 88 | if body.Code != 0 { 89 | return nil, errors.New(body.Message) 90 | } 91 | playInfo := PlayInfo{} 92 | err = json.Unmarshal(body.Data, &playInfo) 93 | if err != nil { 94 | return nil, err 95 | } 96 | return &playInfo, nil 97 | } 98 | 99 | func (client *BiliClient) GetPopularVideos() ([]VideoInfo, error) { 100 | if client.SESSDATA == "" { 101 | return nil, errors.New("SESSDATA 不能为空") 102 | } 103 | urls := []string{ 104 | "https://api.bilibili.com/x/web-interface/popular", 105 | "https://api.bilibili.com/x/web-interface/popular/precious", 106 | "https://api.bilibili.com/x/web-interface/ranking/v2", 107 | } 108 | response, err := client.SimpleGET(urls[rand.Intn(len(urls))], nil) 109 | if err != nil { 110 | return nil, err 111 | } 112 | defer response.Body.Close() 113 | body := BaseResV2{} 114 | 115 | err = json.NewDecoder(response.Body).Decode(&body) 116 | if err != nil { 117 | return nil, err 118 | } 119 | if body.Code != 0 { 120 | return nil, errors.New(body.Message) 121 | } 122 | data := struct { 123 | List []VideoInfo `json:"list"` 124 | }{} 125 | 126 | err = json.Unmarshal(body.Data, &data) 127 | if err != nil { 128 | return nil, err 129 | } 130 | return data.List, nil 131 | } 132 | 133 | // GetSeasonsArchivesList 获取合集中的第一个视频的 BVID 134 | func (client *BiliClient) GetSeasonsArchivesListFirstBvid(mid int, seasonId int) (string, error) { 135 | url := "https://api.bilibili.com/x/polymer/web-space/seasons_archives_list" 136 | params := map[string]string{ 137 | "mid": strconv.Itoa(mid), 138 | "season_id": strconv.Itoa(seasonId), 139 | "page_num": "1", 140 | "page_size": "1", 141 | } 142 | 143 | response, err := client.SimpleGET(url, params) 144 | if err != nil { 145 | return "", err 146 | } 147 | defer response.Body.Close() 148 | 149 | body := BaseResV2{} 150 | if err = json.NewDecoder(response.Body).Decode(&body); err != nil { 151 | return "", err 152 | } 153 | if body.Code != 0 { 154 | return "", errors.New(body.Message) 155 | } 156 | var data struct { 157 | Archives []struct { 158 | Bvid string `json:"bvid"` 159 | } `json:"archives"` 160 | } 161 | if err = json.Unmarshal(body.Data, &data); err != nil { 162 | return "", nil 163 | } 164 | if len(data.Archives) == 0 { 165 | return "", errors.New("视频列表为空") 166 | } 167 | return data.Archives[0].Bvid, nil 168 | } 169 | 170 | func (client *BiliClient) GetFavlist(mediaId int) (*FavList, error) { 171 | if client.SESSDATA == "" { 172 | return nil, errors.New("SESSDATA 不能为空") 173 | } 174 | page := 0 175 | retry := 0 176 | allFavList := FavList{} 177 | for { 178 | favList, hasMore, err := client.GetFavlistByPage(mediaId, page, 40) 179 | if err != nil { 180 | fmt.Println(err.Error()) 181 | if retry == 5 || strings.HasPrefix(err.Error(), "body.Code not 0") { 182 | return nil, err 183 | } 184 | retry++ 185 | continue 186 | } 187 | allFavList = append(allFavList, *favList...) 188 | if !hasMore { 189 | break 190 | } 191 | page++ 192 | } 193 | return &allFavList, nil 194 | } 195 | 196 | func (client *BiliClient) GetFavlistByPage(mediaId int, page int, pageSize int) (favlist *FavList, hasMore bool, err error) { 197 | if client.SESSDATA == "" { 198 | return nil, false, errors.New("SESSDATA 不能为空") 199 | } 200 | response, err := client.SimpleGET("https://api.bilibili.com/x/v3/fav/resource/list", map[string]string{ 201 | "media_id": strconv.Itoa(mediaId), 202 | "pn": strconv.Itoa(page + 1), 203 | "ps": strconv.Itoa(pageSize), 204 | "order": "mtime", 205 | "type": "0", 206 | "tid": "0", 207 | "platform": "web", 208 | }) 209 | if err != nil { 210 | return nil, false, err 211 | } 212 | defer response.Body.Close() 213 | body := BaseResV2{} 214 | if err = json.NewDecoder(response.Body).Decode(&body); err != nil { 215 | return nil, false, err 216 | } 217 | if body.Code != 0 { 218 | return nil, false, fmt.Errorf("body.Code not 0, %s", body.Message) 219 | } 220 | data := struct { 221 | Medias FavList `json:"medias"` 222 | HasMore bool `json:"has_more"` 223 | }{} 224 | if err = json.Unmarshal(body.Data, &data); err != nil { 225 | return nil, false, err 226 | } 227 | return &data.Medias, data.HasMore, nil 228 | } 229 | -------------------------------------------------------------------------------- /server/bilibili/wbi.go: -------------------------------------------------------------------------------- 1 | package bilibili 2 | 3 | import ( 4 | "bilidown/util" 5 | "database/sql" 6 | "net/url" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | var MIXIN_KEY_ENC_TAB = []int{ 13 | 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 14 | 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 15 | 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 16 | 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52, 17 | } 18 | 19 | // GetWebKey 获取最新可用的 WbiKey,自动刷新缓存 20 | func (client *BiliClient) getWbiKey(db *sql.DB) (wbiKey string, err error) { 21 | wbiKey, err = getWbiKeyFromDB(db) 22 | if err != nil { 23 | return "", err 24 | } 25 | if wbiKey == "" { 26 | // 更新数据库中的 WebKey 27 | wbiKey, err = client.getWbiKeyRemote() 28 | if err != nil { 29 | return "", err 30 | } 31 | if err = saveWbiKey(db, wbiKey); err != nil { 32 | return "", err 33 | } 34 | } 35 | return wbiKey, nil 36 | } 37 | 38 | func (client *BiliClient) GetMixinKey(db *sql.DB) (string, error) { 39 | wbiKey, err := client.getWbiKey(db) 40 | if err != nil { 41 | return "", err 42 | } 43 | var result string 44 | for _, index := range MIXIN_KEY_ENC_TAB { 45 | result += string(wbiKey[index]) 46 | } 47 | return result[:32], nil 48 | } 49 | 50 | func WbiSign(params map[string]string, mixinKey string) url.Values { 51 | values := url.Values{} 52 | for key, value := range params { 53 | values.Set(key, value) 54 | } 55 | wts := strconv.FormatInt(time.Now().Unix(), 10) 56 | values.Set("wts", wts) 57 | encodeStr := strings.ReplaceAll(values.Encode(), "+", "%20") + mixinKey 58 | w_rid := util.MD5Hash(encodeStr) 59 | values.Set("w_rid", w_rid) 60 | return values 61 | } 62 | 63 | // GetWbiKey 从数据库中获取 wbiKey,如果数据库中不存在记录或记录过期,则返回空字符串但不返回错误 64 | func getWbiKeyFromDB(db *sql.DB) (wbiKey string, err error) { 65 | fields, err := util.GetFields(db, "wbi_key", "wbi_key_update_at") 66 | if err != nil { 67 | return "", err 68 | } 69 | // 获取上次更新时间的时间戳,单位是秒 70 | updateAt, err := strconv.ParseInt(fields["wbi_key_update_at"], 10, 64) 71 | if err != nil { 72 | return "", nil 73 | } 74 | // 注意 key_update_at 单位是秒,判断上次刷新时间是否超过 1 天 75 | if time.Now().Unix()-updateAt > 24*60*60 { 76 | return "", nil 77 | } 78 | return fields["wbi_key"], nil 79 | } 80 | 81 | // SaveWbiKey 保存 imgKey 和 subKey 到数据库 82 | func saveWbiKey(db *sql.DB, wbiKey string) error { 83 | return util.SaveFields(db, [][2]string{ 84 | {"wbi_key", wbiKey}, 85 | {"wbi_key_update_at", strconv.FormatInt(time.Now().Unix(), 10)}, 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /server/bilibili/wbi_test.go: -------------------------------------------------------------------------------- 1 | package bilibili 2 | 3 | import ( 4 | "bilidown/util" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestGetWbiKey(t *testing.T) { 13 | db := util.MustGetDB("../data.db") 14 | defer db.Close() 15 | sessdata, err := GetSessdata(db) 16 | if err != nil { 17 | t.Error(err) 18 | } 19 | client := BiliClient{SESSDATA: sessdata} 20 | mixinKey, err := client.GetMixinKey(db) 21 | if err != nil { 22 | t.Error(err) 23 | } 24 | fmt.Println(mixinKey) 25 | } 26 | 27 | func TestTime(t *testing.T) { 28 | fmt.Println(time.Now().Unix()) 29 | } 30 | 31 | func TestURLEncode(t *testing.T) { 32 | str := url.Values{ 33 | "foo": {"one one four"}, 34 | "bar": {"五一四"}, 35 | "baz": {"1919810"}, 36 | }.Encode() 37 | str = strings.ReplaceAll(str, "+", "%20") 38 | fmt.Println(str) 39 | } 40 | 41 | func TestWbiSign(t *testing.T) { 42 | db := util.MustGetDB("../data.db") 43 | defer db.Close() 44 | sessdata, err := GetSessdata(db) 45 | if err != nil { 46 | t.Error(err) 47 | } 48 | client := BiliClient{SESSDATA: sessdata} 49 | mixinKey, err := client.GetMixinKey(db) 50 | if err != nil { 51 | t.Error(err) 52 | } 53 | newParams := WbiSign(map[string]string{ 54 | "dm_cover_img_str": "QU5HTEUgKEludGVsLCBJbnRlbChSKSBJcmlzKFIpIFhlIEdyYXBoaWNzICgweDAwMDA5QTQ5KSBEaXJlY3QzRDExIHZzXzVfMCBwc181XzAsIEQzRDExKUdvb2dsZSBJbmMuIChJbnRlbC", 55 | "dm_img_inter": `{"ds":[{"t":10,"c":"YmUtcGFnZXItaXRlbSBiZS1wYWdlci1pdGVtLWFjdGl2ZQ","p":[330,110,110],"s":[179,179,358]}],"wh":[5762,6704,10],"of":[318,636,318]}`, 56 | "dm_img_list": `[{"x":1847,"y":-1616,"z":0,"timestamp":2486463,"k":70,"type":0},{"x":1972,"y":-1005,"z":24,"timestamp":2486563,"k":120,"type":0},{"x":2564,"y":-638,"z":72,"timestamp":2486665,"k":107,"type":0},{"x":4200,"y":-753,"z":199,"timestamp":2486765,"k":91,"type":0},{"x":4922,"y":-73,"z":426,"timestamp":2486865,"k":106,"type":0},{"x":4656,"y":-512,"z":35,"timestamp":2486965,"k":94,"type":0},{"x":4942,"y":-590,"z":79,"timestamp":2487066,"k":126,"type":0},{"x":5200,"y":-414,"z":123,"timestamp":2487167,"k":60,"type":0},{"x":4916,"y":-820,"z":228,"timestamp":2563922,"k":111,"type":0},{"x":5004,"y":-50,"z":570,"timestamp":2564022,"k":110,"type":0},{"x":4784,"y":330,"z":666,"timestamp":2564122,"k":98,"type":0},{"x":5029,"y":767,"z":956,"timestamp":2564223,"k":115,"type":0},{"x":5327,"y":1400,"z":1307,"timestamp":2564324,"k":107,"type":0},{"x":4598,"y":671,"z":578,"timestamp":2564424,"k":125,"type":0},{"x":5228,"y":1283,"z":1193,"timestamp":2564525,"k":83,"type":0},{"x":5210,"y":932,"z":978,"timestamp":2564625,"k":66,"type":0},{"x":5034,"y":195,"z":484,"timestamp":2564725,"k":112,"type":0},{"x":6496,"y":1467,"z":1849,"timestamp":2564825,"k":83,"type":0},{"x":6592,"y":1387,"z":1852,"timestamp":2564929,"k":122,"type":0},{"x":6001,"y":728,"z":1235,"timestamp":2565030,"k":95,"type":0},{"x":6666,"y":1393,"z":1900,"timestamp":2627309,"k":121,"type":1},{"x":5486,"y":5603,"z":2283,"timestamp":2627842,"k":102,"type":0},{"x":5269,"y":4186,"z":1733,"timestamp":2627947,"k":117,"type":0},{"x":4439,"y":3174,"z":897,"timestamp":2628056,"k":117,"type":0},{"x":5231,"y":3959,"z":1687,"timestamp":2628268,"k":79,"type":0},{"x":6197,"y":4786,"z":2587,"timestamp":2628368,"k":96,"type":0},{"x":4667,"y":3298,"z":724,"timestamp":2628470,"k":91,"type":0},{"x":4658,"y":3436,"z":205,"timestamp":2629433,"k":88,"type":0},{"x":5380,"y":3443,"z":1117,"timestamp":2630390,"k":121,"type":0},{"x":5559,"y":2678,"z":1920,"timestamp":2630492,"k":116,"type":0},{"x":5517,"y":2465,"z":2023,"timestamp":2630634,"k":86,"type":0},{"x":6584,"y":3533,"z":3087,"timestamp":2630782,"k":96,"type":0},{"x":6183,"y":3242,"z":2839,"timestamp":2630892,"k":103,"type":0},{"x":6277,"y":580,"z":1449,"timestamp":2663911,"k":68,"type":0},{"x":7556,"y":3077,"z":3053,"timestamp":2664013,"k":114,"type":0},{"x":5321,"y":1642,"z":1201,"timestamp":2664114,"k":104,"type":0},{"x":5839,"y":2261,"z":1761,"timestamp":2664215,"k":72,"type":0},{"x":6303,"y":2726,"z":2222,"timestamp":2664317,"k":93,"type":0},{"x":7553,"y":4255,"z":3532,"timestamp":2664420,"k":104,"type":0},{"x":7677,"y":4381,"z":3650,"timestamp":2664521,"k":119,"type":0},{"x":8184,"y":4762,"z":4098,"timestamp":2664622,"k":122,"type":0},{"x":4555,"y":740,"z":406,"timestamp":2664722,"k":118,"type":0},{"x":4305,"y":241,"z":75,"timestamp":2664822,"k":105,"type":0},{"x":6373,"y":2218,"z":2117,"timestamp":2664934,"k":110,"type":0},{"x":6079,"y":1757,"z":1680,"timestamp":2665036,"k":90,"type":0},{"x":8158,"y":3462,"z":3524,"timestamp":2665136,"k":100,"type":0},{"x":9312,"y":4205,"z":4531,"timestamp":2665236,"k":124,"type":0},{"x":5306,"y":89,"z":487,"timestamp":2665337,"k":89,"type":0},{"x":9721,"y":4399,"z":4941,"timestamp":2665437,"k":100,"type":0},{"x":9058,"y":3727,"z":4282,"timestamp":2665540,"k":107,"type":0}]`, 57 | "dm_img_str": "V2ViR0wgMS4wIChPcGVuR0wgRVMgMi4wIENocm9taXVtKQ", 58 | "keyword": "", 59 | "mid": "596866446", 60 | "order": "pubdate", 61 | "order_avoided": "true", 62 | "platform": "web", 63 | "pn": "3", 64 | "ps": "30", 65 | "tid": "0", 66 | "w_webid": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzcG1faWQiOiIwLjAiLCJidXZpZCI6IjBGRTlGRDMxLTZGQTQtRjU2RC1GQjEwLUI4OTg2ODUyRUZDNzUwMDUyaW5mb2MiLCJ1c2VyX2FnZW50IjoiTW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NCkgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzEzMS4wLjAuMCBTYWZhcmkvNTM3LjM2IEVkZy8xMzEuMC4wLjAiLCJidXZpZF9mcCI6IjY3YzZkNzg2NTgyYzQ2OGYwNjRiZTE5MTk2OWU4ZWE4IiwiYmlsaV90aWNrZXQiOiI4ODA0MDgzYmYzZGQ1NGNkNzI4OWZjZDE0MmIwN2U4ZCIsImNyZWF0ZWRfYXQiOjE3MzI0NzE3MTIsInR0bCI6ODY0MDAsInVybCI6Ii81OTY4NjY0NDYvdmlkZW8_dGlkPTBcdTAwMjZwbj0yXHUwMDI2a2V5d29yZD1cdTAwMjZvcmRlcj1wdWJkYXRlIiwicmVzdWx0Ijoibm9ybWFsIiwiaXNzIjoiZ2FpYSIsImlhdCI6MTczMjQ3MTcxMn0.pTVyhdRcB2VFcXqeYoi3TjnlEFLAXMpNNJK-dW9Gq3vqBL4YldGgZBuGBXXF7Ldwg_vW6TQg6pQCQ7vz357ws2Z9g2-kLIuNmR3j8oMg2zAXAND1q5oJNw0jNnevhLlB8_vOcip0eIJSHRjqbPbNShKLOcSnSfLaiI64EHwjRFAOEPYVw3evLXKB4TFnxSRi1WDSI684TfNrXp0_2yTJvuPheHQmQC1NcUP_P9tqTuRiDy3YfkuR8PlRcQxmKHVs-byObL5WEPqMMQa8b8zidRtzEkbGV7ra8gTgv1HwgpSDi_Y1VNsX2WtNcBPTSwURWc7zREf-RJqruzgcf1ErpA", 67 | "web_location": "1550101", 68 | }, mixinKey) 69 | 70 | fmt.Println(newParams.Encode()) 71 | } 72 | -------------------------------------------------------------------------------- /server/common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | ) 7 | 8 | func RandomString(length int) string { 9 | randomBytes := make([]byte, length) 10 | rand.Read(randomBytes) 11 | return fmt.Sprintf("%x", randomBytes)[:length] 12 | } 13 | 14 | type MediaFormat int 15 | -------------------------------------------------------------------------------- /server/go.mod: -------------------------------------------------------------------------------- 1 | module bilidown 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/getlantern/systray v1.2.2 7 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 8 | modernc.org/sqlite v1.33.1 9 | ) 10 | 11 | require ( 12 | github.com/dustin/go-humanize v1.0.1 // indirect 13 | github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect 14 | github.com/getlantern/errors v1.0.4 // indirect 15 | github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect 16 | github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc // indirect 17 | github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect 18 | github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 // indirect 19 | github.com/go-logr/logr v1.4.2 // indirect 20 | github.com/go-logr/stdr v1.2.2 // indirect 21 | github.com/go-stack/stack v1.8.1 // indirect 22 | github.com/google/uuid v1.6.0 // indirect 23 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 24 | github.com/mattn/go-isatty v0.0.20 // indirect 25 | github.com/ncruces/go-strftime v0.1.9 // indirect 26 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect 27 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 28 | go.opentelemetry.io/otel v1.31.0 // indirect 29 | go.opentelemetry.io/otel/metric v1.31.0 // indirect 30 | go.opentelemetry.io/otel/trace v1.31.0 // indirect 31 | go.uber.org/multierr v1.11.0 // indirect 32 | go.uber.org/zap v1.27.0 // indirect 33 | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect 34 | golang.org/x/sys v0.26.0 // indirect 35 | modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect 36 | modernc.org/libc v1.61.0 // indirect 37 | modernc.org/mathutil v1.6.0 // indirect 38 | modernc.org/memory v1.8.0 // indirect 39 | modernc.org/strutil v1.2.0 // indirect 40 | modernc.org/token v1.1.0 // indirect 41 | ) 42 | -------------------------------------------------------------------------------- /server/go.sum: -------------------------------------------------------------------------------- 1 | github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 6 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 7 | github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= 8 | github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA= 9 | github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201/go.mod h1:Y9WZUHEb+mpra02CbQ/QczLUe6f0Dezxaw5DCJlJQGo= 10 | github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= 11 | github.com/getlantern/errors v1.0.1/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= 12 | github.com/getlantern/errors v1.0.4 h1:i2iR1M9GKj4WuingpNqJ+XQEw6i6dnAgKAmLj6ZB3X0= 13 | github.com/getlantern/errors v1.0.4/go.mod h1:/Foq8jtSDGP8GOXzAjeslsC4Ar/3kB+UiQH+WyV4pzY= 14 | github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= 15 | github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 h1:NlQedYmPI3pRAXJb+hLVVDGqfvvXGRPV8vp7XOjKAZ0= 16 | github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65/go.mod h1:+ZU1h+iOVqWReBpky6d5Y2WL0sF2Llxu+QcxJFs2+OU= 17 | github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= 18 | github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc h1:sue+aeVx7JF5v36H1HfvcGFImLpSD5goj8d+MitovDU= 19 | github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc/go.mod h1:D9RWpXy/EFPYxiKUURo2TB8UBosbqkiLhttRrZYtvqM= 20 | github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= 21 | github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 h1:cSrD9ryDfTV2yaur9Qk3rHYD414j3Q1rl7+L0AylxrE= 22 | github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770/go.mod h1:GOQsoDnEHl6ZmNIL+5uVo+JWRFWozMEp18Izcb++H+A= 23 | github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= 24 | github.com/getlantern/ops v0.0.0-20220713155959-1315d978fff7/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= 25 | github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag= 26 | github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534/go.mod h1:ZsLfOY6gKQOTyEcPYNA9ws5/XHZQFroxqCOhHjGcs9Y= 27 | github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE= 28 | github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE= 29 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 30 | github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 31 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 32 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 33 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 34 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 35 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 36 | github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= 37 | github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= 38 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 39 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 40 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 41 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= 42 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 43 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 44 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 45 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 46 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 47 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 48 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 49 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 50 | github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= 51 | github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= 52 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 53 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 54 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 55 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 56 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= 57 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= 58 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 59 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 60 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 61 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 62 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 63 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= 64 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 65 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= 66 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 67 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 68 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 69 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 70 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 71 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 72 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 73 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 74 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 75 | go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo= 76 | go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= 77 | go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= 78 | go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= 79 | go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= 80 | go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo= 81 | go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= 82 | go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= 83 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 84 | go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 85 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 86 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 87 | go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 88 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 89 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 90 | go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 91 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 92 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 93 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 94 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 95 | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w= 96 | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= 97 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 98 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 99 | golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= 100 | golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 101 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 102 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 103 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 104 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 105 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 106 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 107 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 108 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 109 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 110 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 111 | golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 112 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 113 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 114 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 115 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 116 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 117 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 118 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 119 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 120 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 121 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 122 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 123 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 124 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 125 | golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 126 | golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= 127 | golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= 128 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 129 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 130 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 131 | gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= 132 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 133 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 134 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 135 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 136 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 137 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 138 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 139 | modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= 140 | modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= 141 | modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4= 142 | modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0= 143 | modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= 144 | modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 145 | modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M= 146 | modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= 147 | modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY= 148 | modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= 149 | modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE= 150 | modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0= 151 | modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= 152 | modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= 153 | modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= 154 | modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= 155 | modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= 156 | modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= 157 | modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= 158 | modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= 159 | modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= 160 | modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= 161 | modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= 162 | modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= 163 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 164 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 165 | -------------------------------------------------------------------------------- /server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | "os/exec" 11 | "runtime" 12 | "time" 13 | 14 | "bilidown/router" 15 | "bilidown/util" 16 | 17 | "github.com/getlantern/systray" 18 | _ "modernc.org/sqlite" 19 | ) 20 | 21 | const ( 22 | HTTP_PORT = 8098 // 限定 HTTP 服务器端口 23 | HTTP_HOST = "" // 限定 HTTP 服务器主机 24 | VERSION = "v2.0.15" // 软件版本号,将影响托盘标题显示 25 | ) 26 | 27 | var urlLocal = fmt.Sprintf("http://127.0.0.1:%d", HTTP_PORT) 28 | var urlLocalUnix = fmt.Sprintf("%s?___%d", urlLocal, time.Now().UnixMilli()) 29 | 30 | func main() { 31 | checkFFmpeg() 32 | // 启动托盘程序 33 | systray.Run(onReady, nil) 34 | } 35 | 36 | func onReady() { 37 | // 设置托盘图标 38 | setIcon() 39 | // 设置托盘标题 40 | setTitle() 41 | // 设置托盘菜单 42 | setMenuItem() 43 | // 初始化数据表 44 | mustInitTables() 45 | // 配置和启动 HTTP 服务器 46 | mustRunServer() 47 | // 调用默认浏览器访问端口 48 | time.Sleep(time.Millisecond * 1000) 49 | openBrowser(urlLocalUnix) 50 | // 保持运行 51 | select {} 52 | } 53 | 54 | // checkFFmpeg 检测 ffmpeg 的安装情况,如果未安装则打印提示信息。 55 | func checkFFmpeg() { 56 | if _, err := util.GetFFmpegPath(); err != nil { 57 | fmt.Println("🚨 FFmpeg is missing. Install it from https://www.ffmpeg.org/download.html or place it in ./bin, then restart the application.") 58 | select {} 59 | } 60 | } 61 | 62 | // 配置和启动 HTTP 服务器 63 | func mustRunServer() { 64 | // 前端打包文件 65 | http.Handle("/", http.FileServer(http.Dir("static"))) 66 | // 后端接口服务 67 | http.Handle("/api/", http.StripPrefix("/api", router.API())) 68 | // 启动 HTTP 服务器 69 | go func() { 70 | err := http.ListenAndServe(fmt.Sprintf("%s:%d", HTTP_HOST, HTTP_PORT), nil) 71 | if err != nil { 72 | log.Fatal("http.ListenAndServe:", err) 73 | } 74 | }() 75 | } 76 | 77 | // openBrowser 调用系统默认浏览器打开指定 URL 78 | func openBrowser(url string) { 79 | var cmd *exec.Cmd 80 | switch runtime.GOOS { 81 | case "windows": 82 | cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) 83 | case "darwin": 84 | cmd = exec.Command("open", url) 85 | case "linux": 86 | cmd = exec.Command("xdg-open", url) 87 | default: 88 | log.Printf("openBrowser: %v.", errors.New("unsupported operating system")) 89 | } 90 | if err := cmd.Start(); err != nil { 91 | log.Printf("openBrowser: %v.", err) 92 | } 93 | fmt.Printf("Opened in default browser: %s.\n", url) 94 | } 95 | 96 | // setIcon 设置托盘图标 97 | func setIcon() { 98 | var path string 99 | if runtime.GOOS == "windows" { 100 | path = "static/favicon.ico" 101 | } else { 102 | path = "static/favicon-32x32.png" 103 | } 104 | systray.SetIcon(mustReadFile(path)) 105 | } 106 | 107 | // mustReadFile 返回文件字节内容 108 | func mustReadFile(path string) []byte { 109 | data, err := os.ReadFile(path) 110 | if err != nil { 111 | log.Fatalln("os.ReadFile:", err) 112 | } 113 | return data 114 | } 115 | 116 | // setTitle 设置托盘标题和工具提示 117 | func setTitle() { 118 | title := "Bilidown" 119 | tooltip := fmt.Sprintf("%s 视频解析器 %s (port:%d)", title, VERSION, HTTP_PORT) 120 | // only available on Mac and Windows. 121 | systray.SetTooltip(tooltip) 122 | } 123 | 124 | // setMenuItem 设置托盘菜单 125 | func setMenuItem() { 126 | openBrowserItemText := fmt.Sprintf("打开主界面 (port:%d)", HTTP_PORT) 127 | openBrowserItem := systray.AddMenuItem(openBrowserItemText, openBrowserItemText) 128 | go func() { 129 | for { 130 | <-openBrowserItem.ClickedCh 131 | openBrowser(urlLocalUnix) 132 | } 133 | }() 134 | 135 | aboutItemText := "Github 项目主页" 136 | aboutItem := systray.AddMenuItem(aboutItemText, aboutItemText) 137 | go func() { 138 | for { 139 | <-aboutItem.ClickedCh 140 | openBrowser("https://github.com/iuroc/bilidown") 141 | } 142 | }() 143 | 144 | exitItemText := "退出应用" 145 | exitItem := systray.AddMenuItem(exitItemText, exitItemText) 146 | go func() { 147 | <-exitItem.ClickedCh 148 | log.Printf("Bilidown has exited.") 149 | systray.Quit() 150 | }() 151 | } 152 | 153 | // mustInitTables 初始化数据表 154 | func mustInitTables() { 155 | db := util.MustGetDB() 156 | defer db.Close() 157 | 158 | if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS "field" ( 159 | "name" TEXT PRIMARY KEY NOT NULL, 160 | "value" TEXT 161 | )`); err != nil { 162 | log.Fatalln("create table field:", err) 163 | } 164 | 165 | if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS "log" ( 166 | "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, 167 | "content" TEXT NOT NULL, 168 | "create_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP 169 | )`); err != nil { 170 | log.Fatalln("create table log:", err) 171 | } 172 | 173 | if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS "task" ( 174 | "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, 175 | "bvid" text NOT NULL, 176 | "cid" integer NOT NULL, 177 | "format" integer NOT NULL, 178 | "title" text NOT NULL, 179 | "owner" text NOT NULL, 180 | "cover" text NOT NULL, 181 | "status" text NOT NULL, 182 | "folder" text NOT NULL, 183 | "duration" integer NOT NULL, 184 | "create_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP 185 | )`); err != nil { 186 | log.Fatalln("create table task:", err) 187 | } 188 | 189 | if _, err := util.GetCurrentFolder(db); err != nil { 190 | log.Fatalln("util.GetCurrentFolder:", err) 191 | } 192 | 193 | if err := initHistoryTask(db); err != nil { 194 | log.Fatalln("initHistoryTask:", err) 195 | } 196 | } 197 | 198 | // initHistoryTask 将上一次程序运行时未完成的任务进度全部变为 error 199 | func initHistoryTask(db *sql.DB) error { 200 | util.SqliteLock.Lock() 201 | _, err := db.Exec(`UPDATE "task" SET "status" = 'error' WHERE "status" IN ('waiting', 'running')`) 202 | util.SqliteLock.Unlock() 203 | return err 204 | } 205 | -------------------------------------------------------------------------------- /server/router/login.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "encoding/base64" 5 | "net/http" 6 | 7 | "bilidown/bilibili" 8 | "bilidown/util" 9 | 10 | "github.com/skip2/go-qrcode" 11 | ) 12 | 13 | func getQRInfo(w http.ResponseWriter, r *http.Request) { 14 | w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") 15 | client := bilibili.BiliClient{} 16 | qrInfo, err := client.NewQRInfo() 17 | if err != nil { 18 | util.Res{Success: false, Message: err.Error()}.Write(w) 19 | return 20 | } 21 | imageData, err := qrcode.Encode(qrInfo.URL, qrcode.Medium, 256) 22 | if err != nil { 23 | util.Res{Success: false, Message: err.Error()}.Write(w) 24 | return 25 | } 26 | base64Str := base64.StdEncoding.EncodeToString(imageData) 27 | util.Res{ 28 | Success: true, 29 | Message: "获取成功", 30 | Data: struct { 31 | Key string `json:"key"` 32 | Image string `json:"image"` 33 | }{ 34 | Key: qrInfo.QrcodeKey, 35 | Image: "data:image/png;base64," + base64Str, 36 | }}.Write(w) 37 | } 38 | 39 | func checkLogin(w http.ResponseWriter, r *http.Request) { 40 | w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") 41 | db := util.MustGetDB() 42 | defer db.Close() 43 | sessdata, err := bilibili.GetSessdata(db) 44 | if err != nil || sessdata == "" { 45 | util.Res{Success: false, Message: "未登录"}.Write(w) 46 | return 47 | } 48 | client := bilibili.BiliClient{SESSDATA: sessdata} 49 | check, err := client.CheckLogin() 50 | if err != nil { 51 | util.Res{Success: false, Message: err.Error()}.Write(w) 52 | return 53 | } 54 | if check { 55 | util.Res{Success: true, Message: "登录成功"}.Write(w) 56 | } else { 57 | util.Res{Success: false, Message: "登录失败"}.Write(w) 58 | } 59 | } 60 | 61 | // getQRStatus 获取二维码状态 62 | func getQRStatus(w http.ResponseWriter, r *http.Request) { 63 | if r.ParseForm() != nil { 64 | util.Res{Success: false, Message: "参数错误"}.Write(w) 65 | return 66 | } 67 | 68 | key := r.FormValue("key") 69 | if key == "" { 70 | util.Res{Success: false, Message: "key 不能为空"}.Write(w) 71 | return 72 | } 73 | client := bilibili.BiliClient{} 74 | qrStatus, sessdata, err := client.GetQRStatus(key) 75 | if err != nil { 76 | util.Res{Success: false, Message: err.Error()}.Write(w) 77 | return 78 | } 79 | if qrStatus.Code != bilibili.QR_SUCCESS { 80 | util.Res{Success: false, Message: qrStatus.Message}.Write(w) 81 | return 82 | } 83 | db := util.MustGetDB() 84 | defer db.Close() 85 | err = bilibili.SaveSessdata(db, sessdata) 86 | if err != nil { 87 | util.Res{Success: false, Message: err.Error()}.Write(w) 88 | return 89 | } 90 | util.Res{Success: true, Message: "登录成功"}.Write(w) 91 | } 92 | 93 | func logout(w http.ResponseWriter, r *http.Request) { 94 | db := util.MustGetDB() 95 | defer db.Close() 96 | err := bilibili.SaveSessdata(db, "") 97 | if err != nil { 98 | util.Res{Success: false, Message: err.Error()}.Write(w) 99 | return 100 | } 101 | util.Res{Success: true, Message: "退出成功"}.Write(w) 102 | } 103 | -------------------------------------------------------------------------------- /server/router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | 9 | "bilidown/util" 10 | "bilidown/util/res_error" 11 | ) 12 | 13 | func API() *http.ServeMux { 14 | router := http.NewServeMux() 15 | router.HandleFunc("/getVideoInfo", getVideoInfo) 16 | router.HandleFunc("/getSeasonInfo", getSeasonInfo) 17 | router.HandleFunc("/getQRInfo", getQRInfo) 18 | router.HandleFunc("/getQRStatus", getQRStatus) 19 | router.HandleFunc("/checkLogin", checkLogin) 20 | router.HandleFunc("/getPlayInfo", getPlayInfo) 21 | router.HandleFunc("/createTask", createTask) 22 | router.HandleFunc("/getActiveTask", getActiveTask) 23 | router.HandleFunc("/getTaskList", getTaskList) 24 | router.HandleFunc("/showFile", showFile) 25 | router.HandleFunc("/getFields", getFields) 26 | router.HandleFunc("/saveFields", saveFields) 27 | router.HandleFunc("/logout", logout) 28 | router.HandleFunc("/quit", quit) 29 | router.HandleFunc("/getPopularVideos", getPopularVideos) 30 | router.HandleFunc("/deleteTask", deleteTask) 31 | router.HandleFunc("/getRedirectedLocation", getRedirectedLocation) 32 | router.HandleFunc("/downloadVideo", downloadVideo) 33 | router.HandleFunc("/getSeasonsArchivesListFirstBvid", getSeasonsArchivesListFirstBvid) 34 | router.HandleFunc("/getFavList", getFavList) 35 | return router 36 | } 37 | 38 | func getRedirectedLocation(w http.ResponseWriter, r *http.Request) { 39 | if r.ParseForm() != nil { 40 | res_error.Send(w, res_error.ParamError) 41 | return 42 | } 43 | url := r.FormValue("url") 44 | if !util.IsValidURL(url) { 45 | res_error.Send(w, res_error.URLFormatError) 46 | return 47 | } 48 | if location, err := util.GetRedirectedLocation(url); err != nil { 49 | res_error.Send(w, res_error.NoLocationError) 50 | return 51 | } else { 52 | util.Res{Success: true, Message: "获取成功", Data: location}.Write(w) 53 | return 54 | } 55 | } 56 | 57 | func quit(w http.ResponseWriter, r *http.Request) { 58 | util.Res{Success: true, Message: "退出成功"}.Write(w) 59 | go func() { 60 | os.Exit(0) 61 | }() 62 | } 63 | 64 | func getFields(w http.ResponseWriter, r *http.Request) { 65 | db := util.MustGetDB() 66 | defer db.Close() 67 | 68 | fields, err := util.GetFields(db, util.FieldUtil{}.AllowSelect()...) 69 | if err != nil { 70 | util.Res{Success: false, Message: err.Error()}.Write(w) 71 | return 72 | } 73 | util.Res{Success: true, Data: fields}.Write(w) 74 | } 75 | 76 | func saveFields(w http.ResponseWriter, r *http.Request) { 77 | if r.Method != http.MethodPost { 78 | util.Res{Success: false, Message: "不支持的请求方法"}.Write(w) 79 | return 80 | } 81 | defer r.Body.Close() 82 | var body [][2]string 83 | 84 | err := json.NewDecoder(r.Body).Decode(&body) 85 | if err != nil { 86 | util.Res{Success: false, Message: "参数错误"}.Write(w) 87 | return 88 | } 89 | 90 | db := util.MustGetDB() 91 | defer db.Close() 92 | 93 | fu := util.FieldUtil{} 94 | 95 | for _, d := range body { 96 | if !fu.IsAllowUpdate(d[0]) { 97 | util.Res{Success: false, Message: fmt.Sprintf("字段 %s 不允许修改", d[0])}.Write(w) 98 | return 99 | } 100 | 101 | if d[0] == "download_folder" { 102 | if _, err := os.Stat(d[1]); os.IsNotExist(err) { 103 | if err := os.MkdirAll(d[1], os.ModePerm); err != nil { 104 | util.Res{Success: false, Message: fmt.Sprintf("目录创建失败:%s", d[1])}.Write(w) 105 | return 106 | } 107 | } else if err != nil { 108 | util.Res{Success: false, Message: fmt.Sprintf("路径设置失败:%v", err)}.Write(w) 109 | return 110 | } 111 | } 112 | } 113 | 114 | err = util.SaveFields(db, body) 115 | if err != nil { 116 | util.Res{Success: false, Message: err.Error()}.Write(w) 117 | return 118 | } 119 | util.Res{Success: true, Message: "保存成功"}.Write(w) 120 | } 121 | -------------------------------------------------------------------------------- /server/router/task.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "os/exec" 10 | "runtime" 11 | "strconv" 12 | 13 | "bilidown/task" 14 | "bilidown/util" 15 | ) 16 | 17 | func createTask(w http.ResponseWriter, r *http.Request) { 18 | defer r.Body.Close() 19 | if r.Method != http.MethodPost { 20 | util.Res{Success: false, Message: "不支持的请求方法"}.Write(w) 21 | return 22 | } 23 | var body []task.TaskInDB 24 | err := json.NewDecoder(r.Body).Decode(&body) 25 | if err != nil { 26 | util.Res{Success: false, Message: "参数错误"}.Write(w) 27 | return 28 | } 29 | db := util.MustGetDB() 30 | defer db.Close() 31 | for _, item := range body { 32 | if !util.CheckBvidFormat(item.Bvid) { 33 | util.Res{Success: false, Message: "bvid 格式错误"}.Write(w) 34 | return 35 | } 36 | if item.Cover == "" || item.Title == "" || item.Owner == "" { 37 | util.Res{Success: false, Message: "参数错误"}.Write(w) 38 | } 39 | 40 | if !util.IsValidURL(item.Cover) { 41 | util.Res{Success: false, Message: "封面链接格式错误"}.Write(w) 42 | return 43 | } 44 | if !util.IsValidURL(item.Audio) { 45 | util.Res{Success: false, Message: "音频链接格式错误"}.Write(w) 46 | return 47 | } 48 | if !util.IsValidURL(item.Video) { 49 | util.Res{Success: false, Message: "视频链接格式错误"}.Write(w) 50 | return 51 | } 52 | if !util.IsValidFormatCode(item.Format) { 53 | util.Res{Success: false, Message: "清晰度代码错误"}.Write(w) 54 | return 55 | } 56 | item.Folder, err = util.GetCurrentFolder(db) 57 | item.Status = "waiting" 58 | if err != nil { 59 | util.Res{Success: false, Message: fmt.Sprintf("util.GetCurrentFolder: %v.", err)}.Write(w) 60 | return 61 | } 62 | _task := task.Task{TaskInDB: item} 63 | _task.Title = util.FilterFileName(_task.Title) 64 | err = _task.Create(db) 65 | if err != nil { 66 | util.Res{Success: false, Message: fmt.Sprintf("_task.Create: %v.", err)}.Write(w) 67 | return 68 | } 69 | go _task.Start() 70 | } 71 | util.Res{Success: true, Message: "创建成功"}.Write(w) 72 | } 73 | 74 | func getActiveTask(w http.ResponseWriter, r *http.Request) { 75 | util.Res{Success: true, Data: task.GlobalTaskList}.Write(w) 76 | } 77 | 78 | func getTaskList(w http.ResponseWriter, r *http.Request) { 79 | err := r.ParseForm() 80 | if err != nil { 81 | util.Res{Success: false, Message: "参数错误"}.Write(w) 82 | return 83 | } 84 | db := util.MustGetDB() 85 | defer db.Close() 86 | page, err := strconv.Atoi(r.FormValue("page")) 87 | if err != nil { 88 | page = 0 89 | } 90 | pageSize, err := strconv.Atoi(r.FormValue("pageSize")) 91 | if err != nil { 92 | pageSize = 360 93 | } 94 | tasks, err := task.GetTaskList(db, page, pageSize) 95 | if err != nil { 96 | util.Res{Success: false, Message: err.Error()}.Write(w) 97 | return 98 | } 99 | util.Res{Success: true, Message: "获取成功", Data: tasks}.Write(w) 100 | } 101 | 102 | // showFile 调用 Explorer 查看文件位置 103 | func showFile(w http.ResponseWriter, r *http.Request) { 104 | if err := r.ParseForm(); err != nil { 105 | util.Res{Success: false, Message: "参数错误"}.Write(w) 106 | return 107 | } 108 | filePath := r.FormValue("filePath") 109 | 110 | var cmd *exec.Cmd 111 | 112 | // 根据操作系统选择命令 113 | switch runtime.GOOS { 114 | case "windows": 115 | // Windows 使用 explorer 116 | cmd = exec.Command("explorer", "/select,", filePath) 117 | case "darwin": 118 | // macOS 使用 open 119 | cmd = exec.Command("open", "-R", filePath) 120 | case "linux": 121 | // Linux 使用 xdg-open 122 | cmd = exec.Command("xdg-open", filePath) 123 | default: 124 | util.Res{Success: false, Message: "不支持的操作系统"}.Write(w) 125 | return 126 | } 127 | err := cmd.Start() 128 | if err != nil { 129 | util.Res{Success: false, Message: err.Error()}.Write(w) 130 | return 131 | } 132 | util.Res{Success: true, Message: "操作成功"}.Write(w) 133 | } 134 | 135 | func deleteTask(w http.ResponseWriter, r *http.Request) { 136 | taskIDStr := r.FormValue("id") 137 | taskID, err := strconv.Atoi(taskIDStr) 138 | if err != nil { 139 | util.Res{Success: false, Message: "参数错误"}.Write(w) 140 | return 141 | } 142 | db := util.MustGetDB() 143 | defer db.Close() 144 | 145 | _task, err := task.GetTask(db, taskID) 146 | if err == sql.ErrNoRows { 147 | util.Res{Success: true, Message: "数据库中没有该条记录,所以本次操作被忽略,可以算作成功。"}.Write(w) 148 | return 149 | } 150 | if err != nil { 151 | util.Res{Success: false, Message: fmt.Sprintf("task.GetTask: %v", err)}.Write(w) 152 | return 153 | } 154 | filePath := _task.FilePath() 155 | err = os.Remove(filePath) 156 | if err != nil && !os.IsNotExist(err) { 157 | util.Res{Success: false, Message: fmt.Sprintf("文件删除失败 os.Remove: %v", err)}.Write(w) 158 | return 159 | } 160 | 161 | err = task.DeleteTask(db, taskID) 162 | if err != nil { 163 | util.Res{Success: false, Message: fmt.Sprintf("task.DeleteTask: %v", err)}.Write(w) 164 | return 165 | } 166 | util.Res{Success: true, Message: "删除成功"}.Write(w) 167 | } 168 | -------------------------------------------------------------------------------- /server/router/video.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "path/filepath" 7 | "strconv" 8 | "strings" 9 | "bilidown/bilibili" 10 | "bilidown/util" 11 | "bilidown/util/res_error" 12 | ) 13 | 14 | // getVideoInfo 通过 BV 号获取视频信息 15 | func getVideoInfo(w http.ResponseWriter, r *http.Request) { 16 | if r.ParseForm() != nil { 17 | res_error.Send(w, res_error.ParamError) 18 | return 19 | } 20 | bvid := r.FormValue("bvid") 21 | if !util.CheckBvidFormat(bvid) { 22 | res_error.Send(w, res_error.BvidFormatError) 23 | return 24 | } 25 | db := util.MustGetDB() 26 | defer db.Close() 27 | 28 | sessdata, err := bilibili.GetSessdata(db) 29 | if err != nil || sessdata == "" { 30 | res_error.Send(w, res_error.NotLogin) 31 | return 32 | } 33 | client := bilibili.BiliClient{SESSDATA: sessdata} 34 | videoInfo, err := client.GetVideoInfo(bvid) 35 | if err != nil { 36 | util.Res{Success: false, Message: err.Error()}.Write(w) 37 | return 38 | } 39 | util.Res{Success: true, Message: "获取成功", Data: videoInfo}.Write(w) 40 | } 41 | 42 | // getSeasonInfo 通过 EP 号或 SS 号获取视频信息 43 | func getSeasonInfo(w http.ResponseWriter, r *http.Request) { 44 | if r.ParseForm() != nil { 45 | util.Res{Success: false, Message: "参数错误"}.Write(w) 46 | return 47 | } 48 | var epid int 49 | epid, err := strconv.Atoi(r.FormValue("epid")) 50 | if r.FormValue("epid") != "" && err != nil { 51 | util.Res{Success: false, Message: "epid 格式错误"}.Write(w) 52 | return 53 | } 54 | var ssid int 55 | if epid == 0 { 56 | ssid, err = strconv.Atoi(r.FormValue("ssid")) 57 | if r.FormValue("ssid") != "" && err != nil { 58 | util.Res{Success: false, Message: "ssid 格式错误"}.Write(w) 59 | return 60 | } 61 | } 62 | db := util.MustGetDB() 63 | defer db.Close() 64 | sessdata, err := bilibili.GetSessdata(db) 65 | if err != nil || sessdata == "" { 66 | res_error.Send(w, res_error.NotLogin) 67 | return 68 | } 69 | 70 | client := bilibili.BiliClient{SESSDATA: sessdata} 71 | seasonInfo, err := client.GetSeasonInfo(epid, ssid) 72 | if err != nil { 73 | util.Res{Success: false, Message: err.Error()}.Write(w) 74 | return 75 | } 76 | util.Res{Success: true, Message: "获取成功", Data: seasonInfo}.Write(w) 77 | } 78 | 79 | // getPlayInfo 通过 BVID 和 CID 获取视频播放信息 80 | func getPlayInfo(w http.ResponseWriter, r *http.Request) { 81 | if r.ParseForm() != nil { 82 | util.Res{Success: false, Message: "参数错误"}.Write(w) 83 | return 84 | } 85 | 86 | bvid := r.FormValue("bvid") 87 | if !util.CheckBvidFormat(bvid) { 88 | util.Res{Success: false, Message: "bvid 格式错误"}.Write(w) 89 | return 90 | } 91 | cid, err := strconv.Atoi(r.FormValue("cid")) 92 | if err != nil { 93 | util.Res{Success: false, Message: "cid 格式错误"}.Write(w) 94 | return 95 | } 96 | db := util.MustGetDB() 97 | defer db.Close() 98 | sessdata, err := bilibili.GetSessdata(db) 99 | if err != nil || sessdata == "" { 100 | res_error.Send(w, res_error.NotLogin) 101 | return 102 | } 103 | client := bilibili.BiliClient{SESSDATA: sessdata} 104 | playInfo, err := client.GetPlayInfo(bvid, cid) 105 | if err != nil { 106 | util.Res{Success: false, Message: fmt.Sprintf("client.GetPlayInfo: %v", err)}.Write(w) 107 | return 108 | } 109 | util.Res{Success: true, Message: "获取成功", Data: playInfo}.Write(w) 110 | } 111 | 112 | func getPopularVideos(w http.ResponseWriter, r *http.Request) { 113 | db := util.MustGetDB() 114 | defer db.Close() 115 | sessdata, err := bilibili.GetSessdata(db) 116 | if err != nil || sessdata == "" { 117 | res_error.Send(w, res_error.NotLogin) 118 | return 119 | } 120 | 121 | client := bilibili.BiliClient{SESSDATA: sessdata} 122 | videos, err := client.GetPopularVideos() 123 | if err != nil { 124 | util.Res{Success: false, Message: err.Error()}.Write(w) 125 | return 126 | } 127 | bvids := make([]string, 0) 128 | for _, v := range videos { 129 | bvids = append(bvids, v.Bvid) 130 | } 131 | util.Res{Success: true, Message: "获取成功", Data: bvids}.Write(w) 132 | } 133 | 134 | var downloadVideo = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 135 | path := r.URL.Query().Get("path") 136 | safePath := filepath.Clean(path) 137 | safePath = strings.ReplaceAll(safePath, "\\", "/") 138 | http.ServeFile(w, r, safePath) 139 | }) 140 | 141 | var getSeasonsArchivesListFirstBvid = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 142 | var mid int 143 | var seasonId int 144 | var err error 145 | if mid, err = strconv.Atoi(r.URL.Query().Get("mid")); err != nil { 146 | res_error.Send(w, res_error.MidFormatError) 147 | return 148 | } 149 | if seasonId, err = strconv.Atoi(r.URL.Query().Get("seasonId")); err != nil { 150 | res_error.Send(w, res_error.SeasonIdFormatError) 151 | return 152 | } 153 | client := bilibili.BiliClient{} 154 | bvid, err := client.GetSeasonsArchivesListFirstBvid(mid, seasonId) 155 | if err != nil { 156 | res_error.Send(w, fmt.Sprintf("client.GetSeasonsArchivesList: %v", err)) 157 | return 158 | } 159 | util.Res{Success: true, Message: "获取成功", Data: bvid}.Write(w) 160 | }) 161 | 162 | var getFavList = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 163 | mediaId, err := strconv.Atoi(r.URL.Query().Get("mediaId")) 164 | if err != nil { 165 | res_error.Send(w, res_error.ParamError) 166 | return 167 | } 168 | db := util.MustGetDB() 169 | defer db.Close() 170 | sessdata, err := bilibili.GetSessdata(db) 171 | if err != nil || sessdata == "" { 172 | res_error.Send(w, res_error.NotLogin) 173 | return 174 | } 175 | client := bilibili.BiliClient{SESSDATA: sessdata} 176 | favList, err := client.GetFavlist(mediaId) 177 | if err != nil { 178 | res_error.Send(w, err.Error()) 179 | return 180 | } 181 | util.Res{Success: true, Message: "获取成功", Data: favList}.Write(w) 182 | }) 183 | -------------------------------------------------------------------------------- /server/task/task.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "bufio" 5 | "database/sql" 6 | "encoding/base64" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "log" 11 | "net/http" 12 | "os" 13 | "os/exec" 14 | "path/filepath" 15 | "regexp" 16 | "strconv" 17 | "strings" 18 | "sync" 19 | "time" 20 | 21 | "bilidown/bilibili" 22 | "bilidown/common" 23 | "bilidown/util" 24 | ) 25 | 26 | // TaskInitOption 创建任务时需要从 POST 请求获取的参数 27 | type TaskInitOption struct { 28 | Bvid string `json:"bvid"` 29 | Cid int `json:"cid"` 30 | Format common.MediaFormat `json:"format"` 31 | Title string `json:"title"` 32 | Owner string `json:"owner"` 33 | Cover string `json:"cover"` 34 | Status TaskStatus `json:"status"` 35 | Folder string `json:"folder"` 36 | Audio string `json:"audio"` 37 | Video string `json:"video"` 38 | Duration int `json:"duration"` 39 | } 40 | 41 | // TaskInDB 任务数据库中的数据 42 | type TaskInDB struct { 43 | TaskInitOption 44 | ID int64 `json:"id"` 45 | CreateAt time.Time `json:"createAt"` 46 | } 47 | 48 | func (task *TaskInDB) FilePath() string { 49 | return filepath.Join(task.Folder, 50 | fmt.Sprintf("%s %s.mp4", task.Title, 51 | strings.Replace(base64.StdEncoding.EncodeToString([]byte(strconv.FormatInt(task.ID, 10))), "=", "", -1), 52 | ), 53 | ) 54 | } 55 | 56 | // done | waiting | running | error 57 | type TaskStatus string 58 | 59 | type Task struct { 60 | TaskInDB 61 | AudioProgress float64 `json:"audioProgress"` 62 | VideoProgress float64 `json:"videoProgress"` 63 | MergeProgress float64 `json:"mergeProgress"` 64 | } 65 | 66 | var GlobalTaskList = []*Task{} 67 | var GlobalTaskMux = &sync.Mutex{} 68 | var GlobalDownloadSem = util.NewSemaphore(3) 69 | var GlobalMergeSem = util.NewSemaphore(3) 70 | 71 | func (task *Task) Create(db *sql.DB) error { 72 | util.SqliteLock.Lock() 73 | result, err := db.Exec(`INSERT INTO "task" ("bvid", "cid", "format", "title", "owner", "cover", "status", "folder", "duration") 74 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 75 | task.Bvid, 76 | task.Cid, 77 | task.Format, 78 | task.Title, 79 | task.Owner, 80 | task.Cover, 81 | task.Status, 82 | task.Folder, 83 | task.Duration, 84 | ) 85 | util.SqliteLock.Unlock() 86 | if err != nil { 87 | return err 88 | } 89 | 90 | task.ID, err = result.LastInsertId() 91 | task.CreateAt = time.Now() 92 | return err 93 | } 94 | 95 | // Create 创建任务,并将任务加入全局任务列表 96 | func (task *Task) Start() { 97 | GlobalTaskMux.Lock() 98 | GlobalTaskList = append(GlobalTaskList, task) 99 | GlobalTaskMux.Unlock() 100 | db := util.MustGetDB() 101 | defer db.Close() 102 | sessdata, err := bilibili.GetSessdata(db) 103 | if err != nil { 104 | task.UpdateStatus(db, "error", fmt.Errorf("bilibili.GetSessdata: %v", err)) 105 | return 106 | } 107 | client := &bilibili.BiliClient{SESSDATA: sessdata} 108 | 109 | GlobalDownloadSem.Acquire() 110 | task.UpdateStatus(db, "running") 111 | err = DownloadMedia(client, task.Audio, task, "audio") 112 | if err != nil { 113 | GlobalDownloadSem.Release() 114 | task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err)) 115 | return 116 | } 117 | err = DownloadMedia(client, task.Video, task, "video") 118 | if err != nil { 119 | GlobalDownloadSem.Release() 120 | task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err)) 121 | return 122 | } 123 | GlobalDownloadSem.Release() 124 | 125 | outputPath := task.TaskInDB.FilePath() 126 | 127 | videoPath := filepath.Join(task.Folder, strconv.FormatInt(task.ID, 10)+".video") 128 | audioPath := filepath.Join(task.Folder, strconv.FormatInt(task.ID, 10)+".audio") 129 | GlobalMergeSem.Acquire() 130 | err = task.MergeMedia(outputPath, videoPath, audioPath) 131 | if err != nil { 132 | GlobalMergeSem.Release() 133 | task.UpdateStatus(db, "error", fmt.Errorf("task.MergeMedia: %v", err)) 134 | return 135 | } 136 | err = os.Remove(videoPath) 137 | if err != nil { 138 | GlobalMergeSem.Release() 139 | task.UpdateStatus(db, "error", fmt.Errorf("os.Remove: %v", err)) 140 | return 141 | } 142 | err = os.Remove(audioPath) 143 | if err != nil { 144 | GlobalMergeSem.Release() 145 | task.UpdateStatus(db, "error", fmt.Errorf("os.Remove: %v", err)) 146 | return 147 | } 148 | GlobalMergeSem.Release() 149 | task.UpdateStatus(db, "done") 150 | } 151 | 152 | // 合并音视频 153 | func (task *Task) MergeMedia(outputPath string, inputPaths ...string) error { 154 | inputs := []string{} 155 | for _, path := range inputPaths { 156 | inputs = append(inputs, "-i", path) 157 | } 158 | 159 | ffmpegPath, err := util.GetFFmpegPath() 160 | if err != nil { 161 | return err 162 | } 163 | 164 | cmd := exec.Command(ffmpegPath, append(inputs, "-c:v", "copy", "-c:a", "copy", "-progress", "pipe:1", "-strict", "-2", outputPath)...) 165 | 166 | stdout, err := cmd.StdoutPipe() 167 | if err != nil { 168 | return err 169 | } 170 | 171 | if err := cmd.Start(); err != nil { 172 | return err 173 | } 174 | scanner := bufio.NewScanner(stdout) 175 | 176 | progress := newProgressBar(int64(task.Duration)) 177 | outTimeRegex := regexp.MustCompile(`out_time_ms=(\d+)`) // 毫秒 178 | 179 | for scanner.Scan() { 180 | line := scanner.Text() 181 | match := outTimeRegex.FindStringSubmatch(line) 182 | if len(match) == 2 { 183 | outTime, err := strconv.ParseInt(match[1], 10, 64) 184 | if err != nil { 185 | return err 186 | } 187 | progress.current = outTime / 1000000 188 | task.MergeProgress = progress.percent() 189 | } 190 | } 191 | 192 | if err := scanner.Err(); err != nil { 193 | return err 194 | } 195 | 196 | if err := cmd.Wait(); err != nil { 197 | return err 198 | } 199 | task.MergeProgress = 1 200 | return nil 201 | } 202 | 203 | func GetVideoURL(medias []bilibili.Media, format common.MediaFormat) (string, error) { 204 | for _, code := range []int{12, 7, 13} { 205 | for _, item := range medias { 206 | if item.ID == format && item.Codecid == code { 207 | return item.BaseURL, nil 208 | } 209 | } 210 | } 211 | return "", errors.New("未找到对应视频分辨率格式") 212 | } 213 | 214 | func GetAudioURL(dash *bilibili.Dash) string { 215 | if dash.Flac != nil { 216 | return dash.Flac.Audio.BaseURL 217 | } 218 | var maxAudioID common.MediaFormat 219 | var audioURL string 220 | for _, item := range dash.Audio { 221 | if item.ID > maxAudioID { 222 | maxAudioID = item.ID 223 | audioURL = item.BaseURL 224 | } 225 | } 226 | return audioURL 227 | } 228 | 229 | func (task *Task) UpdateStatus(db *sql.DB, status TaskStatus, errs ...error) error { 230 | util.SqliteLock.Lock() 231 | _, err := db.Exec(`UPDATE "task" SET "status" = ? WHERE "id" = ?`, status, task.ID) 232 | util.SqliteLock.Unlock() 233 | if err != nil { 234 | return nil 235 | } 236 | for _, err := range errs { 237 | if err != nil { 238 | err = util.CreateLog(db, fmt.Sprintf("Task-%d-Error: %v", task.ID, err)) 239 | if err != nil { 240 | log.Fatalln("CreateLog:", err) 241 | } 242 | } 243 | } 244 | task.Status = status 245 | return err 246 | } 247 | 248 | func DownloadMedia(client *bilibili.BiliClient, _url string, task *Task, mediaType string) error { 249 | var resp *http.Response 250 | var err error 251 | for i := 0; i < 5; i++ { 252 | resp, err = client.SimpleGET(_url, nil) 253 | if err == nil { 254 | break 255 | } 256 | } 257 | 258 | if err != nil { 259 | return err 260 | } 261 | 262 | filename := strconv.FormatInt(task.ID, 10) + "." + mediaType 263 | filepath := filepath.Join(task.Folder, filename) 264 | 265 | progress := newProgressBar(resp.ContentLength) 266 | 267 | file, err := os.Create(filepath) 268 | if err != nil { 269 | return err 270 | } 271 | defer file.Close() 272 | reader := io.TeeReader(resp.Body, file) 273 | buf := make([]byte, 1024) 274 | for { 275 | n, err := reader.Read(buf) 276 | if err != nil && err != io.EOF { 277 | return err 278 | } 279 | if n == 0 { 280 | break 281 | } 282 | 283 | progress.add(n) 284 | GlobalTaskMux.Lock() 285 | if mediaType == "video" { 286 | task.VideoProgress = progress.percent() 287 | } else { 288 | task.AudioProgress = progress.percent() 289 | } 290 | GlobalTaskMux.Unlock() 291 | } 292 | return nil 293 | } 294 | 295 | type progressBar struct { 296 | total int64 297 | current int64 298 | } 299 | 300 | func (p *progressBar) add(n int) { 301 | p.current += int64(n) 302 | } 303 | 304 | func (p *progressBar) percent() float64 { 305 | return float64(p.current) / float64(p.total) 306 | } 307 | 308 | func newProgressBar(total int64) *progressBar { 309 | return &progressBar{ 310 | total: total, 311 | } 312 | } 313 | 314 | func GetTaskList(db *sql.DB, page int, pageSize int) ([]TaskInDB, error) { 315 | tasks := []TaskInDB{} 316 | util.SqliteLock.Lock() 317 | rows, err := db.Query(`SELECT 318 | "id", "bvid", "cid", "format", "title", 319 | "owner", "cover", "status", "folder", "create_at" 320 | FROM "task" ORDER BY "id" DESC LIMIT ?, ?`, 321 | page*pageSize, pageSize, 322 | ) 323 | util.SqliteLock.Unlock() 324 | if err != nil { 325 | return nil, err 326 | } 327 | 328 | createAt := "" 329 | 330 | for rows.Next() { 331 | task := TaskInDB{} 332 | err = rows.Scan( 333 | &task.ID, 334 | &task.Bvid, 335 | &task.Cid, 336 | &task.Format, 337 | &task.Title, 338 | &task.Owner, 339 | &task.Cover, 340 | &task.Status, 341 | &task.Folder, 342 | &createAt, 343 | ) 344 | if err != nil { 345 | return nil, err 346 | } 347 | task.CreateAt, err = time.Parse("2006-01-02 15:04:05", createAt) 348 | if err != nil { 349 | return nil, err 350 | } 351 | tasks = append(tasks, task) 352 | } 353 | return tasks, nil 354 | } 355 | 356 | func DeleteTask(db *sql.DB, taskID int) error { 357 | util.SqliteLock.Lock() 358 | _, err := db.Exec(`DELETE FROM "task" WHERE "id" = ?`, taskID) 359 | util.SqliteLock.Unlock() 360 | return err 361 | } 362 | 363 | func GetTask(db *sql.DB, taskID int) (*TaskInDB, error) { 364 | task := TaskInDB{} 365 | createAt := "" 366 | util.SqliteLock.Lock() 367 | err := db.QueryRow(`SELECT 368 | "id", "bvid", "cid", "format", "title", 369 | "owner", "cover", "status", "folder", "create_at" 370 | FROM "task" WHERE "id" = ?`, 371 | taskID, 372 | ).Scan( 373 | &task.ID, 374 | &task.Bvid, 375 | &task.Cid, 376 | &task.Format, 377 | &task.Title, 378 | &task.Owner, 379 | &task.Cover, 380 | &task.Status, 381 | &task.Folder, 382 | &createAt, 383 | ) 384 | util.SqliteLock.Unlock() 385 | if err != nil { 386 | return nil, err 387 | } 388 | 389 | task.CreateAt, err = time.Parse("2006-01-02 15:04:05", createAt) 390 | if err != nil { 391 | return nil, err 392 | } 393 | return &task, nil 394 | } 395 | -------------------------------------------------------------------------------- /server/task/task_test.go: -------------------------------------------------------------------------------- 1 | package task_test 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os/exec" 7 | "testing" 8 | ) 9 | 10 | func TestFFMPEG(t *testing.T) { 11 | cmd := exec.Command("ffmpeg", "-i", `E:\bilidown\27.video`, "-i", `E:\bilidown\27.audio`, "-c:v", "copy", "-c:a", "copy", "-progress", "pipe:1", `E:\bilidown\27.mp4`) 12 | stdout, err := cmd.StdoutPipe() 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | 17 | if err := cmd.Start(); err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | scanner := bufio.NewScanner(stdout) 22 | for scanner.Scan() { 23 | fmt.Println(">>>", scanner.Text()) 24 | } 25 | 26 | if err := scanner.Err(); err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | if err := cmd.Wait(); err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | t.Log("done") 35 | } 36 | 37 | func TestNum(t *testing.T) { 38 | t.Log(int64(100999) / 1000) 39 | } 40 | -------------------------------------------------------------------------------- /server/util/db.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "os" 9 | "strings" 10 | "sync" 11 | ) 12 | 13 | func CreateLog(db *sql.DB, content string) error { 14 | SqliteLock.Lock() 15 | _, err := db.Exec(`INSERT INTO "log" ("content") VALUES (?)`, content) 16 | SqliteLock.Unlock() 17 | return err 18 | } 19 | 20 | func GetFields(db *sql.DB, names ...string) (map[string]string, error) { 21 | 22 | if len(names) == 0 { 23 | return nil, nil 24 | } 25 | 26 | placeholders := make([]string, len(names)) 27 | for i := 0; i < len(names); i++ { 28 | placeholders[i] = "?" 29 | } 30 | query := fmt.Sprintf(`SELECT "name", "value" FROM "field" WHERE "name" IN (%s)`, strings.Join(placeholders, ",")) 31 | 32 | values := make([]interface{}, len(names)) 33 | for i := 0; i < len(names); i++ { 34 | values[i] = names[i] 35 | } 36 | SqliteLock.Lock() 37 | row, err := db.Query(query, values...) 38 | SqliteLock.Unlock() 39 | if err != nil { 40 | return nil, err 41 | } 42 | defer row.Close() 43 | var name, value string 44 | fields := make(map[string]string) 45 | for row.Next() { 46 | if err := row.Scan(&name, &value); err != nil { 47 | return nil, err 48 | } 49 | fields[name] = value 50 | } 51 | return fields, nil 52 | } 53 | 54 | func SaveFields(db *sql.DB, data [][2]string) error { 55 | 56 | if len(data) == 0 { 57 | return nil 58 | } 59 | 60 | tx, err := db.Begin() 61 | if err != nil { 62 | return err 63 | } 64 | defer func() { 65 | if err != nil { 66 | tx.Rollback() 67 | } else { 68 | tx.Commit() 69 | } 70 | }() 71 | 72 | stmt, err := tx.Prepare(`INSERT OR REPLACE INTO "field" ("name", "value") VALUES (?, ?)`) 73 | if err != nil { 74 | return err 75 | } 76 | defer stmt.Close() 77 | 78 | for _, d := range data { 79 | SqliteLock.Lock() 80 | _, err = stmt.Exec(d[0], d[1]) 81 | SqliteLock.Unlock() 82 | if err != nil { 83 | return err 84 | } 85 | } 86 | return nil 87 | } 88 | 89 | // GetCurrentFolder 获取数据库中的下载保存路径,如果不存在则将默认路径保存到数据库 90 | func GetCurrentFolder(db *sql.DB) (string, error) { 91 | var folder string 92 | SqliteLock.Lock() 93 | err := db.QueryRow(`SELECT "value" FROM "field" WHERE "name" = 'download_folder'`).Scan(&folder) 94 | SqliteLock.Unlock() 95 | if err != nil && err == sql.ErrNoRows { 96 | folder, err = GetDefaultDownloadFolder() 97 | if err != nil { 98 | return "", err 99 | } 100 | err = os.MkdirAll(folder, os.ModePerm) 101 | if err != nil { 102 | return "", err 103 | } 104 | err = SaveDownloadFolder(db, folder) 105 | if err != nil { 106 | return "", err 107 | } 108 | return folder, nil 109 | } 110 | err = os.MkdirAll(folder, os.ModePerm) 111 | if err != nil { 112 | return "", err 113 | } 114 | return folder, nil 115 | } 116 | 117 | // SaveDownloadFolder 保存下载路径,不存在则自动创建 118 | func SaveDownloadFolder(db *sql.DB, downloadFolder string) error { 119 | _, err := os.Stat(downloadFolder) 120 | if err != nil { 121 | if os.IsNotExist(err) { 122 | err = os.MkdirAll(downloadFolder, os.ModePerm) 123 | if err != nil { 124 | return err 125 | } 126 | } 127 | return err 128 | } 129 | SqliteLock.Lock() 130 | _, err = db.Exec(`INSERT OR REPLACE INTO "field" ("name", "value") VALUES ('download_folder', ?)`, downloadFolder) 131 | SqliteLock.Unlock() 132 | return err 133 | } 134 | 135 | var SqliteLock sync.Mutex 136 | 137 | func MustGetDB(path ...string) *sql.DB { 138 | pathStr := "" 139 | if len(path) == 0 { 140 | pathStr = "./data.db" 141 | } else if len(path) > 1 { 142 | log.Fatalln(errors.New("len(path) <= 1")) 143 | } else { 144 | pathStr = path[0] 145 | } 146 | db, err := sql.Open("sqlite", pathStr) 147 | if err != nil { 148 | log.Fatalln("sql.Open:", err) 149 | } 150 | return db 151 | } 152 | -------------------------------------------------------------------------------- /server/util/field.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | type FieldUtil struct{} 4 | 5 | func (f FieldUtil) AllowSelect() []string { 6 | return []string{ 7 | "download_folder", 8 | } 9 | } 10 | 11 | func (f FieldUtil) AllowUpdate() []string { 12 | return []string{ 13 | "download_folder", 14 | } 15 | } 16 | 17 | func (f FieldUtil) IsAllow(allFields []string, names ...string) bool { 18 | allowedFields := make(map[string]struct{}) 19 | for _, field := range allFields { 20 | allowedFields[field] = struct{}{} 21 | } 22 | for _, name := range names { 23 | if _, exists := allowedFields[name]; !exists { 24 | return false 25 | } 26 | } 27 | return true 28 | } 29 | 30 | func (f FieldUtil) IsAllowSelect(names ...string) bool { 31 | return f.IsAllow(f.AllowSelect(), names...) 32 | } 33 | 34 | func (f FieldUtil) IsAllowUpdate(names ...string) bool { 35 | return f.IsAllow(f.AllowUpdate(), names...) 36 | } 37 | -------------------------------------------------------------------------------- /server/util/res_error/res_error.go: -------------------------------------------------------------------------------- 1 | package res_error 2 | 3 | import ( 4 | "net/http" 5 | 6 | "bilidown/util" 7 | ) 8 | 9 | // Send 发送异常响应 10 | func Send(w http.ResponseWriter, message string) { 11 | util.Res{Message: message, Success: false}.Write(w) 12 | } 13 | 14 | const ( 15 | BvidFormatError = "错误的 Bvid 格式" 16 | URLFormatError = "错误的 URL 格式" 17 | MidFormatError = "错误的 Mid 格式" 18 | SeasonIdFormatError = "错误的 SeasonId 格式" 19 | ParamError = "参数错误" 20 | MethodNotAllowError = "不允许的请求方式" 21 | NoLocationError = "无重定向目标地址" 22 | FileNotFountError = "文件不存在" 23 | FileTypeNotAllowError = "不允许的文件类型" 24 | SystemError = "系统错误" 25 | NotLogin = "未登录" 26 | ) 27 | -------------------------------------------------------------------------------- /server/util/response.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | // 统一的 JSON 响应结构 9 | type Res struct { 10 | Success bool `json:"success"` 11 | Message string `json:"message"` 12 | Data any `json:"data"` 13 | } 14 | 15 | // 发送响应 16 | func (r Res) Write(w http.ResponseWriter) { 17 | bs, err := json.Marshal(r) 18 | if err != nil { 19 | w.Write([]byte(`{"success":false,"message":"系统错误","data":null}`)) 20 | } 21 | w.Header().Set("Content-Type", "application/json") 22 | w.Write(bs) 23 | } 24 | -------------------------------------------------------------------------------- /server/util/semaphore.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "sync" 4 | 5 | type Semaphore struct { 6 | ch chan struct{} 7 | wg sync.WaitGroup 8 | } 9 | 10 | func NewSemaphore(concurrency int) *Semaphore { 11 | return &Semaphore{ 12 | ch: make(chan struct{}, concurrency), 13 | } 14 | } 15 | 16 | func (s *Semaphore) Acquire() { 17 | s.ch <- struct{}{} 18 | s.wg.Add(1) 19 | } 20 | 21 | func (s *Semaphore) Release() { 22 | <-s.ch 23 | s.wg.Done() 24 | } 25 | 26 | func (s *Semaphore) Wait() { 27 | s.wg.Wait() 28 | } 29 | -------------------------------------------------------------------------------- /server/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "errors" 7 | "net/http" 8 | "net/url" 9 | "os/exec" 10 | "path/filepath" 11 | "regexp" 12 | "strconv" 13 | 14 | "bilidown/common" 15 | ) 16 | 17 | func CheckBvidFormat(bvid string) bool { 18 | return regexp.MustCompile("^BV1[a-zA-Z0-9]+").MatchString(bvid) 19 | } 20 | 21 | // GetDefaultDownloadFolder 获取默认下载路径 22 | func GetDefaultDownloadFolder() (string, error) { 23 | return filepath.Abs("./download") 24 | } 25 | 26 | func IsNumber(str string) bool { 27 | _, err := strconv.Atoi(str) 28 | return err == nil 29 | } 30 | 31 | // IsValidURL 判断字符串是否为合法的URL 32 | func IsValidURL(u string) bool { 33 | _, err := url.ParseRequestURI(u) 34 | return err == nil 35 | } 36 | 37 | // IsValidFormatCode 判断格式码是否合法 38 | func IsValidFormatCode(format common.MediaFormat) bool { 39 | allowed := []common.MediaFormat{6, 16, 32, 64, 74, 80, 112, 116, 120, 125, 126, 127} 40 | for _, v := range allowed { 41 | if v == format { 42 | return true 43 | } 44 | } 45 | return false 46 | } 47 | 48 | // FilterFileName 过滤字符串中的特殊字符,使其允许作为文件名。 49 | func FilterFileName(fileName string) string { 50 | return regexp.MustCompile(`[\\/:*?"<>|\n]`).ReplaceAllString(fileName, "") 51 | } 52 | 53 | // GetFFmpegPath 获取可用的 FFmpeg 执行路径。 54 | func GetFFmpegPath() (string, error) { 55 | if err := exec.Command("ffmpeg", "-version").Run(); err == nil { 56 | return "ffmpeg", nil 57 | } 58 | if err := exec.Command("bin/ffmpeg", "-version").Run(); err == nil { 59 | return "bin/ffmpeg", nil 60 | } 61 | return "", errors.New("ffmpeg not found") 62 | } 63 | 64 | // GetRedirectedLocation 获取响应头中的 Location,不会自动跟随重定向。 65 | func GetRedirectedLocation(url string) (string, error) { 66 | client := &http.Client{ 67 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 68 | return http.ErrUseLastResponse 69 | }, 70 | } 71 | request, err := http.NewRequest("HEAD", url, nil) 72 | if err != nil { 73 | return "", err 74 | } 75 | response, err := client.Do(request) 76 | if err != nil { 77 | return "", err 78 | } 79 | if locationURL, err := response.Location(); err != nil { 80 | return "", err 81 | } else { 82 | return locationURL.String(), nil 83 | } 84 | } 85 | 86 | func MD5Hash(str string) string { 87 | hasher := md5.New() 88 | hasher.Write([]byte(str)) 89 | hash := hasher.Sum(nil) 90 | hashString := hex.EncodeToString(hash) 91 | return hashString 92 | } 93 | -------------------------------------------------------------------------------- /server/util/util_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "bilidown/common" 9 | "bilidown/util" 10 | ) 11 | 12 | func TestRandomString(t *testing.T) { 13 | for i := 4; i < 10; i++ { 14 | for j := 0; j < 3; j++ { 15 | str := common.RandomString(i) 16 | t.Log(str) 17 | } 18 | t.Log("\n") 19 | } 20 | } 21 | 22 | func TestGetRedirectedLocation(t *testing.T) { 23 | os.Setenv("https_proxy", "http://192.168.1.5:9000") 24 | if location, err := util.GetRedirectedLocation("https://b23.tv/Ga6sbzT"); err != nil { 25 | t.Error(err) 26 | } else { 27 | fmt.Println(location) 28 | } 29 | } 30 | --------------------------------------------------------------------------------