├── .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 | [](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 | 
126 |
127 |
128 | ## Star History
129 |
130 | [](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 |
--------------------------------------------------------------------------------