This commit is contained in:
2026-04-02 15:16:34 +08:00
commit 4facf82956
58 changed files with 6630 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules/
dist/
tmp/
static/
*.db
*.exe
download/
build/
.VSCodeCounter/
.claude/
server/bilidown.exe~
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 iuroc
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+136
View File
@@ -0,0 +1,136 @@
# Bilidown
[![GitHub Release](https://img.shields.io/github/v/release/iuroc/bilidown)](https://github.com/iuroc/bilidown/releases)
哔哩哔哩视频解析下载工具,支持 8K 视频、Hi-Res 音频、杜比视界下载、批量解析,可扫码登录,常驻托盘。
## 支持解析的链接类型
- 【单个视频】https://www.bilibili.com/video/BV1LLDCYJEU3/
- 【番剧和影视剧】https://www.bilibili.com/bangumi/play/ss48831
- 【视频合集】https://space.bilibili.com/282565107/channel/collectiondetail?sid=1427135
- 【收藏夹】https://space.bilibili.com/1176277996/favlist?fid=1234122612
- 【UP 主空间地址】等待 3.x 版本支持
## 使用说明
1. 从 [Releases](https://github.com/iuroc/bilidown/releases) 下载适合您系统版本的安装包
2. 非 Windows 系统,请先安装 [FFmpeg 工具](https://www.ffmpeg.org/)
3. 将安装包解压后执行即可
## 软件特色
1. 前端采用 [Bootstrap](https://github.com/twbs/bootstrap) 和 [VanJS](https://github.com/vanjs-org/van) 构建,轻量美观
2. 后端使用 Go 语言开发,数据库采用 SQlite,简化构建和部署过程
3. 前端通过 [p-queue](https://github.com/sindresorhus/p-queue) 控制并发请求,加快批量解析速度
## 其他说明
- 本程序不支持也不建议 HTTP 代理,直接使用国内网络访问能提升批量解析的成功率和稳定性。
## 打包可执行文件
```shell
git clone https://github.com/iuroc/bilidown
cd bilidown/client
pnpm install
pnpm build
cd ../server
go mod tidy
CGO_ENABLED=1 go build
```
## 交叉编译
### 说明
- 镜像名称:`iuroc/cgo-cross-build`
- 支持的系统架构
- `linux/amd64`
- `windows/amd64`
- `windows/386`
- `windows/arm64`
- `darwin/amd64`
- `darwin/arm64`
### 拉取镜像和项目源码
```shell
docker pull iuroc/cgo-cross-build:latest
git clone https://github.com/iuroc/bilidown
```
### 交叉编译发行版
> 执行 `goreleaser` 命令时将自动执行 `pnpm build` 和 `go mod tidy`
`ffmpeg.exe` 放入 `server/bin` 目录内。
在项目根目录执行如下代码,进入 Docker 容器。
```shell
docker run --rm -it -v .:/usr/src/data iuroc/cgo-cross-build
```
在容器内的终端执行如下代码,开始交叉编译。
```shell
cd server
git tag v2.1.1
goreleaser release --snapshot --clean
# 正式发行
# GITHUB_TOKEN=xxx goreleaser release --clean
```
### 编译指定系统架构
```ini
# 按上面的步骤进入 Docker 容器内终端
# [darwin-amd64]
GOOS=darwin
GOARCH=amd64
CC=o64-clang
CGO_ENABLED=1
go build
```
### 非 Docker 环境编译
在 Linux amd64 平台上执行 `go build` 时,您可能需要安装以下依赖包:
```bash
sudo apt install pkg-config gcc libayatana-appindicator3-dev
```
## 开发环境
```bash
# client
pnpm install
pnpm dev
# server
go build && ./bilidown
```
## 特别感谢
- [twbs/bootstrap](https://github.com/twbs/bootstrap) - 前端开发必备的响应式框架,简化页面布局
- [vanjs-org/van](https://github.com/vanjs-org/van) - 轻量级的前端框架,专注于构建高效应用
- [vitejs/vite](https://github.com/vitejs/vite) - 快速的前端构建工具,基于 ES 模块开发
- [SocialSisterYi/bilibili-API-collec](https://github.com/SocialSisterYi/bilibili-API-collect) - B 站 API 集合,支持多种操作接口
- [sindresorhus/p-queue](https://github.com/sindresorhus/p-queue) - 支持并发限制的 JavaScript 队列处理库
- [iuroc/vanjs-router](https://github.com/iuroc/vanjs-router) - 轻量级前端路由工具,适用于 Van.js 框架
- [uuidjs/uuid](https://www.npmjs.com/package/uuid) - 用于生成唯一标识符(UUID)的 JavaScript 库
- [getlantern/systray](https://github.com/getlantern/systray) - 简单的跨平台系统托盘图标库,支持图标管理
- [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) - Go 语言的 SQLite3 数据库驱动,轻量高效
- [skip2/go-qrcode](https://github.com/skip2/go-qrcode) - 生成 QR 码的 Go 语言库,简单易用
## 软件界面
![](./docs/2024-11-05_090604.png)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=iuroc/bilidown&type=Date)](https://www.star-history.com/#iuroc/bilidown&Date)
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bilidown</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="src/index.ts"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "bilidown",
"version": "1.0.0",
"description": "",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@types/bootstrap": "^5.2.10",
"@types/uuid": "^10.0.0",
"sass-embedded": "^1.80.4",
"vite": "^5.4.8"
},
"type": "module",
"dependencies": {
"bootstrap": "^5.3.3",
"p-queue": "^8.0.1",
"plyr": "^3.7.8",
"uuid": "^10.0.0",
"vanjs-core": "^1.5.2",
"vanjs-router": "^2.1.0"
},
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
}
+904
View File
@@ -0,0 +1,904 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
bootstrap:
specifier: ^5.3.3
version: 5.3.3(@popperjs/core@2.11.8)
p-queue:
specifier: ^8.0.1
version: 8.0.1
plyr:
specifier: ^3.7.8
version: 3.7.8
uuid:
specifier: ^10.0.0
version: 10.0.0
vanjs-core:
specifier: ^1.5.2
version: 1.5.2
vanjs-router:
specifier: ^2.1.0
version: 2.1.0
devDependencies:
'@types/bootstrap':
specifier: ^5.2.10
version: 5.2.10
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
sass-embedded:
specifier: ^1.80.4
version: 1.80.6
vite:
specifier: ^5.4.8
version: 5.4.11(sass-embedded@1.80.6)
packages:
'@bufbuild/protobuf@2.2.2':
resolution: {integrity: sha512-UNtPCbrwrenpmrXuRwn9jYpPoweNXj8X5sMvYgsqYyaH8jQ6LfUJSk3dJLnBK+6sfYPrF4iAIo5sd5HQ+tg75A==, tarball: https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.2.2.tgz}
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.21.5':
resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.21.5':
resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.21.5':
resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.21.5':
resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.21.5':
resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.21.5':
resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.21.5':
resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.21.5':
resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.21.5':
resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.21.5':
resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.21.5':
resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.21.5':
resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.21.5':
resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.21.5':
resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.21.5':
resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.21.5':
resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-x64@0.21.5':
resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-x64@0.21.5':
resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.21.5':
resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.21.5':
resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.21.5':
resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.21.5':
resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==, tarball: https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz}
'@rollup/rollup-android-arm-eabi@4.25.0':
resolution: {integrity: sha512-CC/ZqFZwlAIbU1wUPisHyV/XRc5RydFrNLtgl3dGYskdwPZdt4HERtKm50a/+DtTlKeCq9IXFEWR+P6blwjqBA==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.25.0.tgz}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.25.0':
resolution: {integrity: sha512-/Y76tmLGUJqVBXXCfVS8Q8FJqYGhgH4wl4qTA24E9v/IJM0XvJCGQVSW1QZ4J+VURO9h8YCa28sTFacZXwK7Rg==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.25.0.tgz}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.25.0':
resolution: {integrity: sha512-YVT6L3UrKTlC0FpCZd0MGA7NVdp7YNaEqkENbWQ7AOVOqd/7VzyHpgIpc1mIaxRAo1ZsJRH45fq8j4N63I/vvg==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.25.0.tgz}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.25.0':
resolution: {integrity: sha512-ZRL+gexs3+ZmmWmGKEU43Bdn67kWnMeWXLFhcVv5Un8FQcx38yulHBA7XR2+KQdYIOtD0yZDWBCudmfj6lQJoA==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.25.0.tgz}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.25.0':
resolution: {integrity: sha512-xpEIXhiP27EAylEpreCozozsxWQ2TJbOLSivGfXhU4G1TBVEYtUPi2pOZBnvGXHyOdLAUUhPnJzH3ah5cqF01g==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.25.0.tgz}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.25.0':
resolution: {integrity: sha512-sC5FsmZGlJv5dOcURrsnIK7ngc3Kirnx3as2XU9uER+zjfyqIjdcMVgzy4cOawhsssqzoAX19qmxgJ8a14Qrqw==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.25.0.tgz}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.25.0':
resolution: {integrity: sha512-uD/dbLSs1BEPzg564TpRAQ/YvTnCds2XxyOndAO8nJhaQcqQGFgv/DAVko/ZHap3boCvxnzYMa3mTkV/B/3SWA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.25.0.tgz}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.25.0':
resolution: {integrity: sha512-ZVt/XkrDlQWegDWrwyC3l0OfAF7yeJUF4fq5RMS07YM72BlSfn2fQQ6lPyBNjt+YbczMguPiJoCfaQC2dnflpQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.25.0.tgz}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.25.0':
resolution: {integrity: sha512-qboZ+T0gHAW2kkSDPHxu7quaFaaBlynODXpBVnPxUgvWYaE84xgCKAPEYE+fSMd3Zv5PyFZR+L0tCdYCMAtG0A==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.25.0.tgz}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.25.0':
resolution: {integrity: sha512-ndWTSEmAaKr88dBuogGH2NZaxe7u2rDoArsejNslugHZ+r44NfWiwjzizVS1nUOHo+n1Z6qV3X60rqE/HlISgw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.25.0.tgz}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-powerpc64le-gnu@4.25.0':
resolution: {integrity: sha512-BVSQvVa2v5hKwJSy6X7W1fjDex6yZnNKy3Kx1JGimccHft6HV0THTwNtC2zawtNXKUu+S5CjXslilYdKBAadzA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.25.0.tgz}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.25.0':
resolution: {integrity: sha512-G4hTREQrIdeV0PE2JruzI+vXdRnaK1pg64hemHq2v5fhv8C7WjVaeXc9P5i4Q5UC06d/L+zA0mszYIKl+wY8oA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.25.0.tgz}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.25.0':
resolution: {integrity: sha512-9T/w0kQ+upxdkFL9zPVB6zy9vWW1deA3g8IauJxojN4bnz5FwSsUAD034KpXIVX5j5p/rn6XqumBMxfRkcHapQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.25.0.tgz}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.25.0':
resolution: {integrity: sha512-ThcnU0EcMDn+J4B9LD++OgBYxZusuA7iemIIiz5yzEcFg04VZFzdFjuwPdlURmYPZw+fgVrFzj4CA64jSTG4Ig==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.25.0.tgz}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.25.0':
resolution: {integrity: sha512-zx71aY2oQxGxAT1JShfhNG79PnjYhMC6voAjzpu/xmMjDnKNf6Nl/xv7YaB/9SIa9jDYf8RBPWEnjcdlhlv1rQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.25.0.tgz}
cpu: [x64]
os: [linux]
'@rollup/rollup-win32-arm64-msvc@4.25.0':
resolution: {integrity: sha512-JT8tcjNocMs4CylWY/CxVLnv8e1lE7ff1fi6kbGocWwxDq9pj30IJ28Peb+Y8yiPNSF28oad42ApJB8oUkwGww==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.25.0.tgz}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.25.0':
resolution: {integrity: sha512-dRLjLsO3dNOfSN6tjyVlG+Msm4IiZnGkuZ7G5NmpzwF9oOc582FZG05+UdfTbz5Jd4buK/wMb6UeHFhG18+OEg==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.25.0.tgz}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.25.0':
resolution: {integrity: sha512-/RqrIFtLB926frMhZD0a5oDa4eFIbyNEwLLloMTEjmqfwZWXywwVVOVmwTsuyhC9HKkVEZcOOi+KV4U9wmOdlg==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.25.0.tgz}
cpu: [x64]
os: [win32]
'@types/bootstrap@5.2.10':
resolution: {integrity: sha512-F2X+cd6551tep0MvVZ6nM8v7XgGN/twpdNDjqS1TUM7YFNEtQYWk+dKAnH+T1gr6QgCoGMPl487xw/9hXooa2g==, tarball: https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.2.10.tgz}
'@types/estree@1.0.6':
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==, tarball: https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==, tarball: https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz}
bootstrap@5.3.3:
resolution: {integrity: sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==, tarball: https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz}
peerDependencies:
'@popperjs/core': ^2.11.8
buffer-builder@0.2.0:
resolution: {integrity: sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==, tarball: https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz}
colorjs.io@0.5.2:
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==, tarball: https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz}
core-js@3.39.0:
resolution: {integrity: sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==, tarball: https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz}
custom-event-polyfill@1.0.7:
resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==, tarball: https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz}
engines: {node: '>=12'}
hasBin: true
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, tarball: https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz}
engines: {node: '>=8'}
immutable@4.3.7:
resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==, tarball: https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz}
loadjs@4.3.0:
resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==, tarball: https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz}
nanoid@3.3.7:
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
p-queue@8.0.1:
resolution: {integrity: sha512-NXzu9aQJTAzbBqOt2hwsR63ea7yvxJc0PwN/zobNAudYfb1B7R08SzB4TsLeSbUCuG467NhnoT0oO6w1qRO+BA==, tarball: https://registry.npmjs.org/p-queue/-/p-queue-8.0.1.tgz}
engines: {node: '>=18'}
p-timeout@6.1.3:
resolution: {integrity: sha512-UJUyfKbwvr/uZSV6btANfb+0t/mOhKV/KXcCUTp8FcQI+v/0d+wXqH4htrW0E4rR6WiEO/EPvUFiV9D5OI4vlw==, tarball: https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.3.tgz}
engines: {node: '>=14.16'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, tarball: https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz}
plyr@3.7.8:
resolution: {integrity: sha512-yG/EHDobwbB/uP+4Bm6eUpJ93f8xxHjjk2dYcD1Oqpe1EcuQl5tzzw9Oq+uVAzd2lkM11qZfydSiyIpiB8pgdA==, tarball: https://registry.npmjs.org/plyr/-/plyr-3.7.8.tgz}
postcss@8.4.49:
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==, tarball: https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz}
engines: {node: ^10 || ^12 || >=14}
rangetouch@2.0.1:
resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==, tarball: https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz}
rollup@4.25.0:
resolution: {integrity: sha512-uVbClXmR6wvx5R1M3Od4utyLUxrmOcEm3pAtMphn73Apq19PDtHpgZoEvqH2YnnaNUuvKmg2DgRd2Sqv+odyqg==, tarball: https://registry.npmjs.org/rollup/-/rollup-4.25.0.tgz}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
rxjs@7.8.1:
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==, tarball: https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz}
sass-embedded-android-arm64@1.80.6:
resolution: {integrity: sha512-4rC4ZGM/k4ENVjLXnK3JTst8e8FI9MHSol2Fl7dCdYyJ3KLnlt4qL4AEYfU8zq1tcBb7CBOSZVR+CzCKubnXdg==, tarball: https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [android]
sass-embedded-android-arm@1.80.6:
resolution: {integrity: sha512-UeUKMTRsnz4/dh7IzvhjONxa4/jmVp539CHDd8VZOsqg9M3HcNJNIkUzQWbuwZ+nSlWrTuo7Tvn3XlypopCBzw==, tarball: https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [android]
sass-embedded-android-ia32@1.80.6:
resolution: {integrity: sha512-Lxz2SXE2KdHnynuHF+D6flDvrd55/zaEAWUeka9MxEr6FmR66d8UBOIy5ETwCSUd//S/SE5Jl6oTnHppgD1zNA==, tarball: https://registry.npmjs.org/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [android]
sass-embedded-android-riscv64@1.80.6:
resolution: {integrity: sha512-hKdxY/oOqB+JJhSoBTDM5DJO1j/xtxQgayh2cLCCUx37IQQe3SEdc3V2JFf/4mIo5peaS4cjqwwSATF+l2zaXg==, tarball: https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [android]
sass-embedded-android-x64@1.80.6:
resolution: {integrity: sha512-Eap2Fi3kTx/rVLBsOnOp5RYPr5+lFjTZ652zR24dmYFe9/sDgasakJIOPjOvD2bRuL9z0uWEY1AXVeeOPeZKrg==, tarball: https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [android]
sass-embedded-darwin-arm64@1.80.6:
resolution: {integrity: sha512-0mnAx8Vq6Gxj3PQt3imgITfK33hhqrSKpyHSuab71gZZni5opsdtoggq2JawW+1taRFTEZwbZJLKZ0MBDbwCCA==, tarball: https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [darwin]
sass-embedded-darwin-x64@1.80.6:
resolution: {integrity: sha512-Ib20yNZFOrJ7YVT+ltoe+JQNKPcRclM3iLAK69XZZYcSeFM/72SCoQBAaVGIpT23dxDp7FXiE4lO602c3xTRwQ==, tarball: https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [darwin]
sass-embedded-linux-arm64@1.80.6:
resolution: {integrity: sha512-n5r98pBXawrQQKaxIYCMM1zDpnngsqxTkOrmvsYLFiAMCSbR0lWf/7sBB33k/Pm0D6dsbp3jpHilCoQNKI3jIw==, tarball: https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
sass-embedded-linux-arm@1.80.6:
resolution: {integrity: sha512-QR0Q6TZox/ThuU2r9c0s3fKCgU2rXAEocpitdgxFp6tta+GsQlMFV3oON2unAa8Bwnuxkmf0YOaK0Oy/TwzkXw==, tarball: https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
sass-embedded-linux-ia32@1.80.6:
resolution: {integrity: sha512-O6dWZdcOkryRdDCxVMGOeVowgblpDgVcAuRtZ1F1X7XfbpDriTQm64D+9vVZIrywYSPoJfQMJJ662cr0wUs9IQ==, tarball: https://registry.npmjs.org/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [linux]
sass-embedded-linux-musl-arm64@1.80.6:
resolution: {integrity: sha512-VeUSHUi3MAsvOlg9QI4X/2j04h1659aE+7qKP/282CYBTrGkjFGSXZhIki9WKWDgIpDiSInRYXfQQRWhPhjCDg==, tarball: https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
sass-embedded-linux-musl-arm@1.80.6:
resolution: {integrity: sha512-X9FC8s8fvQGRiXc+eATlZ57N44Iq3nNa0M0ugi3ysdJwkaNYvOeS4QzBHKQAaw3QiTqdxTnLUHHVBkyzdCi9pw==, tarball: https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
sass-embedded-linux-musl-ia32@1.80.6:
resolution: {integrity: sha512-GqitS2Nab8ah0+wfCqaxW1hnI1piC08FimL6+lM9YWK5DbCOOF82IapbvJOy0feUmd/wNnHmyNTgE9h0zVMFdQ==, tarball: https://registry.npmjs.org/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [linux]
sass-embedded-linux-musl-riscv64@1.80.6:
resolution: {integrity: sha512-ySs15z7QSRRQK/aByEEqaJLYW/sTpfynefNPZCtsVNVEzNRwy+DRpxNChtxo+QjKq97ocXETbdG5KLik7QOTJg==, tarball: https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
sass-embedded-linux-musl-x64@1.80.6:
resolution: {integrity: sha512-DzeNqU/SN0mWFznoOH4RtVGcrg3Eoa41pUQhKMtrhNbCmIE1zNDunUiAEVTNpdHJF4nxf7ELUPXWmStM31CbUQ==, tarball: https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
sass-embedded-linux-riscv64@1.80.6:
resolution: {integrity: sha512-AyoHJ3icV9xuJjq1YzJqpEj2XfiC/KBkVYTUrCELKiXP0DN1gi/BpUwZNCAgCM3CyEdMef4LQM/ztCYJxYzdyg==, tarball: https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
sass-embedded-linux-x64@1.80.6:
resolution: {integrity: sha512-EohsE9CEqx0ycylnsEj/0DNPG99Tb0qAVZspiAs5xHFCJjXOFfp3cRQu0BRf+lZ1b72IhPFXymzVtojvzUHb7g==, tarball: https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
sass-embedded-win32-arm64@1.80.6:
resolution: {integrity: sha512-29wETQi1ykeVvpd4zMVokpQKFSOZskGJzZawuuNCdo7BHjHKIRDsqbz8YT1CewHPBshI0hfD21fenmjxYjGXPQ==, tarball: https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [win32]
sass-embedded-win32-ia32@1.80.6:
resolution: {integrity: sha512-1s3OpK2iTIfIL/a91QhAQnffsbuWfnsM8Lx4Fxt0f7ErnxjCV6q8MUFTV/UhcLtLyTFnPCA62DLjp2KGCjMI9A==, tarball: https://registry.npmjs.org/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [win32]
sass-embedded-win32-x64@1.80.6:
resolution: {integrity: sha512-0pH4Zr9silHkcmLPC0ghnD3DI0vMsjA7dKvGR32/RbbjOSvHV5cDQRLiuVJAPp34dfMA7kJd1ysSchRdH0igAQ==, tarball: https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [win32]
sass-embedded@1.80.6:
resolution: {integrity: sha512-Og4aqBnaA3oJfIpHaLuNATAqzBRgUJDYJy2X15V59cot2wYOtiT/ciPnyuq1o7vpDEeOkHhEd+mSviSlXoETug==, tarball: https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.80.6.tgz}
engines: {node: '>=16.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, tarball: https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz}
engines: {node: '>=0.10.0'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==, tarball: https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz}
engines: {node: '>=10'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, tarball: https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz}
url-polyfill@1.1.12:
resolution: {integrity: sha512-mYFmBHCapZjtcNHW0MDq9967t+z4Dmg5CJ0KqysK3+ZbyoNOWQHksGCTWwDhxGXllkWlOc10Xfko6v4a3ucM6A==, tarball: https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.12.tgz}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==, tarball: https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz}
hasBin: true
vanjs-core@1.5.2:
resolution: {integrity: sha512-z9MHAzxYbNFJ6MYP+4j9l9KMxDU3ryI65JjIfUbGQwF1paLfiuqtIiq9Ve1UGKLd774Z7mtdWzd+/FR785dKjQ==, tarball: https://registry.npmjs.org/vanjs-core/-/vanjs-core-1.5.2.tgz}
vanjs-router@2.1.0:
resolution: {integrity: sha512-ti9qYYLsPu/GoTYOoZz94T3Abd26rdgFjoiRTI/TkbrtjYa20lAaJ+U7VIGypKB/2rnoKaFR6O9qFwLtuKBCVw==, tarball: https://registry.npmjs.org/vanjs-router/-/vanjs-router-2.1.0.tgz}
varint@6.0.0:
resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==, tarball: https://registry.npmjs.org/varint/-/varint-6.0.0.tgz}
vite@5.4.11:
resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==, tarball: https://registry.npmjs.org/vite/-/vite-5.4.11.tgz}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@types/node': ^18.0.0 || >=20.0.0
less: '*'
lightningcss: ^1.21.0
sass: '*'
sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
snapshots:
'@bufbuild/protobuf@2.2.2': {}
'@esbuild/aix-ppc64@0.21.5':
optional: true
'@esbuild/android-arm64@0.21.5':
optional: true
'@esbuild/android-arm@0.21.5':
optional: true
'@esbuild/android-x64@0.21.5':
optional: true
'@esbuild/darwin-arm64@0.21.5':
optional: true
'@esbuild/darwin-x64@0.21.5':
optional: true
'@esbuild/freebsd-arm64@0.21.5':
optional: true
'@esbuild/freebsd-x64@0.21.5':
optional: true
'@esbuild/linux-arm64@0.21.5':
optional: true
'@esbuild/linux-arm@0.21.5':
optional: true
'@esbuild/linux-ia32@0.21.5':
optional: true
'@esbuild/linux-loong64@0.21.5':
optional: true
'@esbuild/linux-mips64el@0.21.5':
optional: true
'@esbuild/linux-ppc64@0.21.5':
optional: true
'@esbuild/linux-riscv64@0.21.5':
optional: true
'@esbuild/linux-s390x@0.21.5':
optional: true
'@esbuild/linux-x64@0.21.5':
optional: true
'@esbuild/netbsd-x64@0.21.5':
optional: true
'@esbuild/openbsd-x64@0.21.5':
optional: true
'@esbuild/sunos-x64@0.21.5':
optional: true
'@esbuild/win32-arm64@0.21.5':
optional: true
'@esbuild/win32-ia32@0.21.5':
optional: true
'@esbuild/win32-x64@0.21.5':
optional: true
'@popperjs/core@2.11.8': {}
'@rollup/rollup-android-arm-eabi@4.25.0':
optional: true
'@rollup/rollup-android-arm64@4.25.0':
optional: true
'@rollup/rollup-darwin-arm64@4.25.0':
optional: true
'@rollup/rollup-darwin-x64@4.25.0':
optional: true
'@rollup/rollup-freebsd-arm64@4.25.0':
optional: true
'@rollup/rollup-freebsd-x64@4.25.0':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.25.0':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.25.0':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.25.0':
optional: true
'@rollup/rollup-linux-arm64-musl@4.25.0':
optional: true
'@rollup/rollup-linux-powerpc64le-gnu@4.25.0':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.25.0':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.25.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.25.0':
optional: true
'@rollup/rollup-linux-x64-musl@4.25.0':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.25.0':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.25.0':
optional: true
'@rollup/rollup-win32-x64-msvc@4.25.0':
optional: true
'@types/bootstrap@5.2.10':
dependencies:
'@popperjs/core': 2.11.8
'@types/estree@1.0.6': {}
'@types/uuid@10.0.0': {}
bootstrap@5.3.3(@popperjs/core@2.11.8):
dependencies:
'@popperjs/core': 2.11.8
buffer-builder@0.2.0: {}
colorjs.io@0.5.2: {}
core-js@3.39.0: {}
custom-event-polyfill@1.0.7: {}
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
'@esbuild/android-arm': 0.21.5
'@esbuild/android-arm64': 0.21.5
'@esbuild/android-x64': 0.21.5
'@esbuild/darwin-arm64': 0.21.5
'@esbuild/darwin-x64': 0.21.5
'@esbuild/freebsd-arm64': 0.21.5
'@esbuild/freebsd-x64': 0.21.5
'@esbuild/linux-arm': 0.21.5
'@esbuild/linux-arm64': 0.21.5
'@esbuild/linux-ia32': 0.21.5
'@esbuild/linux-loong64': 0.21.5
'@esbuild/linux-mips64el': 0.21.5
'@esbuild/linux-ppc64': 0.21.5
'@esbuild/linux-riscv64': 0.21.5
'@esbuild/linux-s390x': 0.21.5
'@esbuild/linux-x64': 0.21.5
'@esbuild/netbsd-x64': 0.21.5
'@esbuild/openbsd-x64': 0.21.5
'@esbuild/sunos-x64': 0.21.5
'@esbuild/win32-arm64': 0.21.5
'@esbuild/win32-ia32': 0.21.5
'@esbuild/win32-x64': 0.21.5
eventemitter3@5.0.1: {}
fsevents@2.3.3:
optional: true
has-flag@4.0.0: {}
immutable@4.3.7: {}
loadjs@4.3.0: {}
nanoid@3.3.7: {}
p-queue@8.0.1:
dependencies:
eventemitter3: 5.0.1
p-timeout: 6.1.3
p-timeout@6.1.3: {}
picocolors@1.1.1: {}
plyr@3.7.8:
dependencies:
core-js: 3.39.0
custom-event-polyfill: 1.0.7
loadjs: 4.3.0
rangetouch: 2.0.1
url-polyfill: 1.1.12
postcss@8.4.49:
dependencies:
nanoid: 3.3.7
picocolors: 1.1.1
source-map-js: 1.2.1
rangetouch@2.0.1: {}
rollup@4.25.0:
dependencies:
'@types/estree': 1.0.6
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.25.0
'@rollup/rollup-android-arm64': 4.25.0
'@rollup/rollup-darwin-arm64': 4.25.0
'@rollup/rollup-darwin-x64': 4.25.0
'@rollup/rollup-freebsd-arm64': 4.25.0
'@rollup/rollup-freebsd-x64': 4.25.0
'@rollup/rollup-linux-arm-gnueabihf': 4.25.0
'@rollup/rollup-linux-arm-musleabihf': 4.25.0
'@rollup/rollup-linux-arm64-gnu': 4.25.0
'@rollup/rollup-linux-arm64-musl': 4.25.0
'@rollup/rollup-linux-powerpc64le-gnu': 4.25.0
'@rollup/rollup-linux-riscv64-gnu': 4.25.0
'@rollup/rollup-linux-s390x-gnu': 4.25.0
'@rollup/rollup-linux-x64-gnu': 4.25.0
'@rollup/rollup-linux-x64-musl': 4.25.0
'@rollup/rollup-win32-arm64-msvc': 4.25.0
'@rollup/rollup-win32-ia32-msvc': 4.25.0
'@rollup/rollup-win32-x64-msvc': 4.25.0
fsevents: 2.3.3
rxjs@7.8.1:
dependencies:
tslib: 2.8.1
sass-embedded-android-arm64@1.80.6:
optional: true
sass-embedded-android-arm@1.80.6:
optional: true
sass-embedded-android-ia32@1.80.6:
optional: true
sass-embedded-android-riscv64@1.80.6:
optional: true
sass-embedded-android-x64@1.80.6:
optional: true
sass-embedded-darwin-arm64@1.80.6:
optional: true
sass-embedded-darwin-x64@1.80.6:
optional: true
sass-embedded-linux-arm64@1.80.6:
optional: true
sass-embedded-linux-arm@1.80.6:
optional: true
sass-embedded-linux-ia32@1.80.6:
optional: true
sass-embedded-linux-musl-arm64@1.80.6:
optional: true
sass-embedded-linux-musl-arm@1.80.6:
optional: true
sass-embedded-linux-musl-ia32@1.80.6:
optional: true
sass-embedded-linux-musl-riscv64@1.80.6:
optional: true
sass-embedded-linux-musl-x64@1.80.6:
optional: true
sass-embedded-linux-riscv64@1.80.6:
optional: true
sass-embedded-linux-x64@1.80.6:
optional: true
sass-embedded-win32-arm64@1.80.6:
optional: true
sass-embedded-win32-ia32@1.80.6:
optional: true
sass-embedded-win32-x64@1.80.6:
optional: true
sass-embedded@1.80.6:
dependencies:
'@bufbuild/protobuf': 2.2.2
buffer-builder: 0.2.0
colorjs.io: 0.5.2
immutable: 4.3.7
rxjs: 7.8.1
supports-color: 8.1.1
varint: 6.0.0
optionalDependencies:
sass-embedded-android-arm: 1.80.6
sass-embedded-android-arm64: 1.80.6
sass-embedded-android-ia32: 1.80.6
sass-embedded-android-riscv64: 1.80.6
sass-embedded-android-x64: 1.80.6
sass-embedded-darwin-arm64: 1.80.6
sass-embedded-darwin-x64: 1.80.6
sass-embedded-linux-arm: 1.80.6
sass-embedded-linux-arm64: 1.80.6
sass-embedded-linux-ia32: 1.80.6
sass-embedded-linux-musl-arm: 1.80.6
sass-embedded-linux-musl-arm64: 1.80.6
sass-embedded-linux-musl-ia32: 1.80.6
sass-embedded-linux-musl-riscv64: 1.80.6
sass-embedded-linux-musl-x64: 1.80.6
sass-embedded-linux-riscv64: 1.80.6
sass-embedded-linux-x64: 1.80.6
sass-embedded-win32-arm64: 1.80.6
sass-embedded-win32-ia32: 1.80.6
sass-embedded-win32-x64: 1.80.6
source-map-js@1.2.1: {}
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
tslib@2.8.1: {}
url-polyfill@1.1.12: {}
uuid@10.0.0: {}
vanjs-core@1.5.2: {}
vanjs-router@2.1.0:
dependencies:
vanjs-core: 1.5.2
varint@6.0.0: {}
vite@5.4.11(sass-embedded@1.80.6):
dependencies:
esbuild: 0.21.5
postcss: 8.4.49
rollup: 4.25.0
optionalDependencies:
fsevents: 2.3.3
sass-embedded: 1.80.6
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+22
View File
@@ -0,0 +1,22 @@
import van from 'vanjs-core'
import { Route } from 'vanjs-router'
import { GLOBAL_ERROR_MESSAGE, GLOBAL_HIDE_PAGE } from '../mixin'
const { button, div } = van.tags
export default () => Route({
rule: 'error',
Loader() {
return div({ class: 'py-5 px-4 container' },
div({ class: 'py-5 px-3 border rounded-4 vstack align-items-center gap-4' },
div({ class: 'fs-2 fw-bold' }, '错误提示'),
div({ class: 'text-danger fs-4' }, () => GLOBAL_ERROR_MESSAGE.val || '请刷新页面重试'),
button({
class: 'btn btn-warning', onclick() {
location.href = location.pathname
}
}, '刷新页面')
)
)
},
})
+27
View File
@@ -0,0 +1,27 @@
import van from 'vanjs-core'
import { now } from 'vanjs-router'
import { GLOBAL_HAS_LOGIN } from './mixin'
const { a, div } = van.tags
export default () => {
const classStr = (name: string) => van.derive(() => `text-nowrap nav-link ${now.val.split('/')[0] == name ? 'active' : ''}`)
return div({ class: 'hstack gap-4' },
div({ class: 'fs-4 fw-bold text-nowrap' }, 'Bilidown'),
div({ class: 'nav nav-underline flex-nowrap overflow-auto' },
div({ class: 'nav-item', hidden: () => !GLOBAL_HAS_LOGIN.val },
a({ class: classStr('work'), href: '#/work' }, '视频解析')
),
div({ class: 'nav-item', hidden: () => !GLOBAL_HAS_LOGIN.val },
a({ class: classStr('task'), href: '#/task' }, '任务列表')
),
div({ class: 'nav-item', hidden: () => !GLOBAL_HAS_LOGIN.val },
a({ class: classStr('setting'), href: '#/setting' }, '设置中心')
),
div({ class: 'nav-item', hidden: GLOBAL_HAS_LOGIN },
a({ class: classStr('login'), href: '#/login' }, '扫码登录')
),
)
)
}
+28
View File
@@ -0,0 +1,28 @@
/// <reference types="vite/client" />
import van from 'vanjs-core'
import Header from './header'
import Work from './work'
import Task from './task'
import Login from './login'
import Setting from './setting'
import _Error from './error'
import { redirect } from 'vanjs-router'
import { GLOBAL_HIDE_PAGE } from './mixin'
import 'bootstrap/dist/css/bootstrap.min.css'
import './scss/index.scss'
import { PlayerModalComp } from './task/playerModal'
const { div } = van.tags
redirect('home', 'work')
van.add(document.body,
div({ class: 'container py-4 vstack gap-4', hidden: GLOBAL_HIDE_PAGE },
Header(),
Work(),
Task(),
Login(),
Setting(),
),
_Error()
)
+13
View File
@@ -0,0 +1,13 @@
import { ResJSON } from '../mixin'
/** 获取新的二维码信息,包含二维码 Base64 数据和二维码 Key */
export const getQRInfo = async () => {
const res = await fetch('/api/getQRInfo').then(res => res.json()) as ResJSON<{ image: string, key: string }>
if (!res.success) throw new Error(res.message)
return res.data
}
/** 根据二维码 Key 获取二维码状态 */
export const getQRStatus = async (key: string) => {
return await fetch('/api/getQRStatus?key=' + key).then(res => res.json()) as ResJSON
}
+111
View File
@@ -0,0 +1,111 @@
import van from 'vanjs-core'
import { Route, goto, nowHash } from 'vanjs-router'
import { checkLogin, GLOBAL_HAS_LOGIN } from '../mixin'
import { getQRInfo, getQRStatus } from './data'
const { div, img } = van.tags
export default () => {
const qrSrc = van.state('')
const errorMessage = van.state('')
const qrStatusMessage = van.state('')
return Route({
rule: 'login',
Loader() {
return div(
div({ class: 'card card-body rounded-4' },
div({ class: 'row' },
div({ class: 'col-xl-3 col-lg-4 col-md-5 col-sm-6' },
div({ class: 'ratio ratio-1x1' },
img({
src: qrSrc,
class: 'w-100',
hidden: true,
onload(this: HTMLImageElement) {
this.hidden = false
},
ondragstart: event => event.preventDefault(),
})
)
),
div({ class: 'col-xl-9 col-lg-8 col-md-7 col-sm-6' },
div({ class: 'vstack gap-3 h-100 px-3 justify-content-center align-items-center align-items-sm-start pb-4 pb-sm-0' },
div({ class: 'fs-1' }, '扫码登录'),
div({ class: 'fs-4' }, '使用哔哩哔哩 APP 扫码登录'),
div({ class: 'text-danger fw-bold', hidden: () => !errorMessage.val }, errorMessage),
div({ class: 'text-primary fw-bold', hidden: errorMessage },
() => qrStatusMessage.val.replace('未扫码', '').replace('二维码已扫码未确认', '已扫码,请点击确认')
)
)
)
)
)
)
},
async onFirst() {
if (await checkLogin()) return goto('work')
},
async onLoad() {
// 检查登录标识,如果已经登录过了,则重定向到工作页
if (GLOBAL_HAS_LOGIN.val) return goto('work')
// 当前活动的二维码标识
let qrKey = ''
/** 刷新二维码 */
const refreshQR = async () => {
try {
const qrInfo = await getQRInfo()
// 更新二维码组件内容
qrSrc.val = qrInfo.image
// 更新当前活动的二维码标识
qrKey = qrInfo.key
} catch (error) {
// 加载二维码时失败
errorMessage.val = `加载二维码失败,请刷新页面重试`
clearTimeout(timer)
clearTimeout(statusTimer)
}
}
/** 检查二维码状态 */
const checkQRStatus = async () => {
try {
const status = await getQRStatus(qrKey)
if (status.success) {
clearTimeout(statusTimer)
GLOBAL_HAS_LOGIN.val = true
const url = new URL(window.location.href)
url.hash = ''
window.location.href = url.toString()
} else {
qrStatusMessage.val = status.message
}
} catch (error) {
// 出错了,显示错误信息
errorMessage.val = `获取二维码状态失败,请刷新页面重试`
clearTimeout(statusTimer)
clearTimeout(timer)
}
}
// 载入初始二维码
refreshQR()
/** 用于定时刷新二维码的定时器 */
const timer = setInterval(async () => {
if (nowHash().split('/')[0] != 'login')
return clearInterval(timer)
refreshQR()
}, 120000)
/** 用于轮询检查二维码状态的定时器 */
const statusTimer = setInterval(async () => {
if (nowHash().split('/')[0] != 'login')
return clearInterval(statusTimer)
checkQRStatus()
}, 1000)
},
})
}
+69
View File
@@ -0,0 +1,69 @@
import van from 'vanjs-core'
import { goto } from 'vanjs-router'
export type ResJSON<Data = null> = {
success: boolean
data: Data
message: string
}
/** 创建请求超时控制器 */
export const timeoutController = (ms = 15000): {
signal: AbortSignal
timer: number
} => {
const controller = new AbortController()
const timer = setTimeout(() => {
controller.abort(new Error('请求超时'))
}, ms)
return { signal: controller.signal, timer }
}
/** 全局登录状态 */
export const GLOBAL_HAS_LOGIN = van.state(false)
/** 设置页面整体隐藏,该状态值用于设置根 DOM 的 `hidden` 属性 */
export const GLOBAL_HIDE_PAGE = van.state(true)
/** 全局错误信息,用于在统一的错误提示页面中展示 */
export const GLOBAL_ERROR_MESSAGE = van.state('')
/** 跳转到统一的错误提示页面 */
export const showErrorPage = (message: string) => {
GLOBAL_HIDE_PAGE.val = true
GLOBAL_ERROR_MESSAGE.val = message
goto('error')
}
/** 检查后端是否登录,如果未登录,则跳转到登录页
*
* 登录成功或失败,都将更新 `GLOBAL_HAS_LOGIN` 的值,并返回 `boolean`,并将 `GLOBAL_HIDE_PAGE` 设置为 `false`
*
* 用法注意:每个路由 `onFirst` 和 `onLoad` 只能其中一个使用该函数,否则会导致执行两次请求
* 一般在 `onFirst` 中执行本方法,在 `onLoad` 中执行 `if (!GLOBAL_HAS_LOGIN.val) return goto('login')`
*/
export const checkLogin = async (): Promise<boolean> => {
if (GLOBAL_HAS_LOGIN.val) return GLOBAL_HIDE_PAGE.val = false, true
const res = await fetch('/api/checkLogin').then(res => res.json()) as ResJSON
GLOBAL_HAS_LOGIN.val = res.success
GLOBAL_HIDE_PAGE.val = false
if (!res.success) return goto('login'), false
return true
}
export interface VanComponent {
element: HTMLElement
}
export const formatSeconds = (seconds: number) => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
let str = ''
if (hours > 0) str += `${hours}`
if (minutes > 0) str += `${minutes}`
if (secs > 0) str += `${secs}`
return str
}
+51
View File
@@ -0,0 +1,51 @@
@media (max-width: 768px) {
.position-relative-sm-down {
position: relative !important;
}
}
.max-height-description {
max-height: 300px;
}
.video-item-btn {
cursor: pointer;
transition: all 0.2s;
border-color: transparent;
outline: none;
.text-muted {
transition: all 0.2s;
}
&:hover:not(.active),
&:focus-visible:not(.active) {
--bs-bg-opacity: 0.2;
border-width: 1px !important;
border-style: solid !important;
border-color: var(--bs-success) !important;
}
&.active {
--bs-bg-opacity: 0.8;
color: white !important;
.text-muted {
color: white !important;
}
}
}
.hover-btn svg {
cursor: pointer;
&:hover {
fill: red;
}
}
* {
word-wrap: break-word;
word-break: break-all;
}
+20
View File
@@ -0,0 +1,20 @@
import { Fields } from '.'
import { ResJSON } from '../mixin'
export const getFields = async (): Promise<Fields> => {
const res = await fetch('/api/getFields').then(res => res.json()) as ResJSON<Fields>
if (!res.success) throw new Error(res.message)
return res.data
}
export const saveFields = async (fields: [string, string][]) => {
const res = await fetch('/api/saveFields', {
method: 'POST',
body: JSON.stringify(fields),
headers: {
'Content-Type': 'application/json'
}
}).then(res => res.json()) as ResJSON
if (!res.success) throw new Error(res.message)
return res.message
}
+79
View File
@@ -0,0 +1,79 @@
import van from 'vanjs-core'
import { Route, goto } from 'vanjs-router'
import { checkLogin, GLOBAL_HAS_LOGIN, VanComponent } from '../mixin'
import { SaveFolderSetting } from './view'
import { getFields } from './data'
import { LoadingBox } from '../view'
const { button, div } = van.tags
export type Fields = Record<keyof SettingRoute['fields'], string>
export class SettingRoute implements VanComponent {
element: HTMLElement
loading = van.state(true)
fields = {
download_folder: van.state('')
}
constructor() {
this.element = this.Root()
}
Root() {
const _that = this
return Route({
rule: 'setting',
Loader() {
return div(
() => _that.loading.val ? LoadingBox() : '',
() => _that.loading.val ? '' : div({ class: 'vstack gap-4' },
SaveFolderSetting(_that),
div({ class: 'hstack gap-3' },
button({
class: 'btn btn-outline-secondary', onclick() {
if (!confirm('确定要关闭软件吗?')) return
fetch('/api/quit').then(res => res.json()).then(res => {
if (!res.success) alert(res.message)
else document.write(`<h2 style="text-align: center; padding: 30px 20px;">软件已关闭</h2>`)
})
}
}, '关闭软件'),
button({
class: 'btn btn-outline-danger', onclick() {
if (!confirm('确定要退出登录吗?')) return
fetch('/api/logout').then(res => res.json()).then(res => {
if (!res.success) alert(res.message)
else location.reload()
})
}
}, '退出登录'),
)
)
)
},
async onFirst() {
if (!await checkLogin()) return
_that.loading.val = true
getFields().then(fields => {
for (const key in fields) {
_that.fields[key as keyof Fields].val = fields[key as keyof Fields]
}
setTimeout(() => {
_that.loading.val = false
}, 200)
})
},
onLoad() {
if (!GLOBAL_HAS_LOGIN.val) return goto('login')
},
})
}
}
export default () => new SettingRoute().element
+34
View File
@@ -0,0 +1,34 @@
import van from 'vanjs-core'
import { SettingRoute } from '.'
import { saveFields } from './data'
const { a, button, div, input } = van.tags
export const SaveFolderSetting = (route: SettingRoute) => {
const saveFolder = route.fields.download_folder
const folderPickerDisabled = van.state(false)
const buttonText = '保存'
return div({ class: 'input-group' },
div({ class: 'input-group-text' }, '下载目录'),
input({
class: 'form-control',
value: saveFolder,
oninput: event => saveFolder.val = event.target.value,
}),
button({
class: 'btn btn-success', onclick() {
folderPickerDisabled.val = true
saveFields([
['download_folder', saveFolder.val]
]).then(message => {
alert(message)
}).catch(error => {
if (error instanceof Error) alert(error.message)
}).finally(() => {
folderPickerDisabled.val = false
})
}, disabled: folderPickerDisabled
}, buttonText)
)
}
+74
View File
@@ -0,0 +1,74 @@
import { ResJSON } from "../mixin"
import { TaskInDB, TaskStatus, VideoFormat } from "../work/type"
let getActiveTaskController: AbortController | undefined
export const getActiveTask = async (): Promise<ActiveTask[] | null> => {
getActiveTaskController?.abort()
getActiveTaskController = new AbortController()
try {
const res = await fetch('/api/getActiveTask', {
signal: getActiveTaskController.signal
}).then(res => res.json()) as ResJSON<ActiveTask[]>
if (!res.success) throw new Error(res.message)
return res.data
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return null
throw error
}
}
let getTaskListController: AbortController | undefined
export const getTaskList = async (page: number, pageSize: number): Promise<TaskInDB[] | null> => {
getTaskListController?.abort()
getTaskListController = new AbortController()
try {
const res = await fetch(`/api/getTaskList?page=${page}&pageSize=${pageSize}`, {
signal: getTaskListController.signal
}).then(res => res.json()) as ResJSON<TaskInDB[]>
if (!res.success) throw new Error(res.message)
return res.data
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return null
throw error
}
}
export const showFile = async (path: string) => {
const res = await fetch(`/api/showFile?filePath=${encodeURIComponent(path)}`).then(res => res.json()) as ResJSON
if (!res.success) throw new Error(res.message)
}
/** 用于刷新任务实时进度 */
type ActiveTask = {
bvid: string
cid: number
/** 分辨率代码 */
format: VideoFormat
/** 视频标题 */
title: string
/** 视频发布者 */
owner: string
/** 视频封面 */
cover: string
/** 任务进度 */
status: TaskStatus
/** 文件保存到的目录 */
folder: string
/** 任务 ID */
id: number
/** 音频文件下载进度 */
audioProgress: number
/** 视频文件下载进度 */
videoProgress: number
/** 音视频合并进度 */
mergeProgress: number
/** 视频时长,秒 */
duration: number
}
export const deleteTask = async (id: number) => {
const res = await fetch(`/api/deleteTask?id=${id}`).then(res => res.json()) as ResJSON
if (!res.success) throw new Error(res.message)
}
+236
View File
@@ -0,0 +1,236 @@
import van, { State } from 'vanjs-core'
import { Route, goto, now } from 'vanjs-router'
import { checkLogin, GLOBAL_HAS_LOGIN, GLOBAL_HIDE_PAGE, ResJSON, VanComponent } from '../mixin'
import { deleteTask, getActiveTask, getTaskList, showFile } from './data'
import { TaskInDB, TaskStatus } from '../work/type'
import { LoadingBox } from '../view'
import { PlayerModalComp } from './playerModal'
const { div, span } = van.tags
const { svg, path } = van.tags('http://www.w3.org/2000/svg')
export class TaskRoute implements VanComponent {
element: HTMLElement
/** 包含视频播放器的模态框 */
playerModalComp = new PlayerModalComp()
loading = van.state(false)
taskList: State<(TaskInDB & {
/** 音频下载进度百分比 */
audioProgress: State<number>
/** 视频下载进度百分比 */
videoProgress: State<number>
/** 合并进度百分比 */
mergeProgress: State<number>
/** 任务状态 */
statusState: State<TaskStatus>
/** 是否正在打开 */
opening: State<boolean>
/** 是否正在删除 */
deleting: State<boolean>
})[]> = van.state([])
constructor() {
this.element = this.Root()
}
Root() {
const _that = this
return Route({
rule: 'task',
Loader() {
return div(
() => _that.loading.val ? LoadingBox() : '',
() => div({ class: 'list-group', hidden: _that.loading.val },
_that.taskList.val.map(task => {
const ext = task.downloadType === 'audio' ? '.m4a' : '.mp4'
const filename = `${task.title} ${btoa(task.id.toString()).replace(/=/g, '')}${ext}`
return div({
class: () => `list-group-item p-0 hstack user-select-none ${task.statusState.val != 'done' && task.statusState.val != 'error' || task.opening.val ? 'disabled' : ''}`,
hidden: task.deleting,
},
div({
class: 'vstack gap-2 py-2 px-3',
style: `cursor: pointer;`,
onclick() {
const src = `/api/downloadVideo?path=${encodeURIComponent(
`${task.folder}\\${filename}`
)}`
if (task.statusState.val != 'done') return
_that.playerModalComp.open(src, task.title, task.downloadType === 'audio' ? 'audio' : 'video')
}
},
div({
class: () => `
${task.statusState.val == 'error' ? 'text-danger' : ''}
${task.statusState.val == 'waiting' || task.statusState.val == 'running'
? 'text-primary' : ''}`
},
() => {
if (task.opening.val) return '正在打开文件位置...'
return div(
span({
class: `me-2 badge ${task.downloadType === 'audio' ? 'bg-success' : 'bg-primary'}`,
title: task.downloadType === 'audio' ? '音频' : '视频'
}, task.downloadType === 'audio' ? 'A' : 'V'),
span({}, filename),
)
}),
div({ class: 'text-secondary small' },
() => {
if (task.statusState.val == 'waiting') return '等待下载'
if (task.statusState.val == 'error') return '下载失败'
if (task.statusState.val == 'done') return task.folder
if (task.videoProgress.val == 0) {
return `正在下载音频 (${(task.audioProgress.val * 100).toFixed(2)}%)`
} else if (task.mergeProgress.val == 0) {
return `正在下载视频 (${(task.videoProgress.val * 100).toFixed(2)}%)`
} else if (task.statusState.val == 'running') {
return `正在合并音视频 (${(task.mergeProgress.val * 100).toFixed(2)}%)`
} else {
return task.folder
}
}
),
div({
class: `progress`,
style: `height: 5px`,
hidden: () => task.statusState.val == 'done' || task.statusState.val == 'error'
},
div({
class: () => `progress-bar progress-bar-striped progress-bar-animated bg-${(() => {
if (task.videoProgress.val == 0) return 'primary'
if (task.mergeProgress.val == 0) return 'success'
else return 'info'
})()}`,
style: () => {
let width = 0
if (task.videoProgress.val == 0) width = task.audioProgress.val * 100
else if (task.mergeProgress.val == 0) width = task.videoProgress.val * 100
else width = task.mergeProgress.val * 100
return `width: ${width}%`
}
}),
)
),
div({
class: 'me-4',
hidden: task.statusState.val != 'done'
|| task.opening.val // 正在打开文件位置时,不应该显示删除按钮
|| task.deleting.val // 正在删除时,不应该显示删除按钮
},
div({
class: 'hover-btn', title: '打开文件位置',
onclick() {
showFile(`${task.folder}\\${filename}`)
task.opening.val = true
setTimeout(() => {
task.opening.val = false
}, 3000)
}
},
_that.FolderSVG()
)
),
div({
class: 'me-4',
hidden: task.statusState.val != 'done'
&& task.statusState.val != 'error'
|| task.opening.val // 正在打开文件位置时,不应该显示删除按钮
|| task.deleting.val // 正在删除时,不应该显示删除按钮
},
div({
class: 'hover-btn', title: '删除视频',
onclick() {
task.deleting.val = true
deleteTask(task.id).then(() => {
_that.taskList.val = _that.taskList.val.filter(taskInDB => taskInDB.id != task.id)
}).catch(error => {
alert(error.message)
})
}
},
_that.DeleteSVG()
)
),
)
})
)
)
},
async onFirst() {
if (!await checkLogin()) return
},
async onLoad() {
if (!GLOBAL_HAS_LOGIN.val) return goto('login')
_that.loading.val = true
getTaskList(0, 360).then(taskList => {
if (!taskList) return
_that.taskList.val = taskList.map(task => ({
...task,
audioProgress: van.state(1),
videoProgress: van.state(1),
mergeProgress: van.state(1),
statusState: van.state(task.status),
opening: van.state(false),
deleting: van.state(false)
}))
const refresh = async () => {
const activeTaskList = await getActiveTask()
if (!activeTaskList) return false
setTimeout(() => {
_that.loading.val = false
}, 200)
_that.taskList.val.forEach(taskInDB => {
activeTaskList.forEach(task => {
if (taskInDB.id == task.id) {
taskInDB.audioProgress.val = task.audioProgress
taskInDB.videoProgress.val = task.videoProgress
taskInDB.mergeProgress.val = task.mergeProgress
taskInDB.statusState.val = task.status
}
})
})
if (activeTaskList.filter(task => task.status == 'running').length == 0) {
clearInterval(timer)
clearInterval(helper)
}
return true
}
refresh()
let timer = setInterval(() => {
refresh()
}, 1000)
let helper = setInterval(() => {
if (now.val.split('/')[0] != 'task') {
clearInterval(helper)
clearInterval(timer)
}
})
})
},
})
}
DeleteSVG() {
return svg({ style: `width: 1em; height: 1em`, fill: "currentColor", class: "bi bi-trash3", viewBox: "0 0 16 16" },
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" }),
)
}
FolderSVG() {
return svg({ style: `width: 1em; height: 1em`, fill: "currentColor", class: "bi bi-folder2", viewBox: "0 0 16 16" },
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" }),
)
}
}
export default () => new TaskRoute().element
+143
View File
@@ -0,0 +1,143 @@
import { VanComponent } from '../mixin'
import van from 'vanjs-core'
import { Modal } from 'bootstrap'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
const { a, button, div, video, audio } = van.tags
export class PlayerComp implements VanComponent {
element: HTMLElement
/** 使用该对象前,请先将元素加入文档树后调用 `initPlayer` 方法 */
player!: Plyr
videoElement: HTMLVideoElement
audioElement: HTMLAudioElement
videoPlayer!: Plyr
audioPlayer!: Plyr
type = van.state<'video' | 'audio'>('video')
src = van.state('')
filename = van.state('')
constructor() {
this.videoElement = video()
this.audioElement = audio()
this.element = this.Root()
}
Root(): HTMLElement {
const that = this
return div({ class: 'player-container' },
div({ class: 'video-wrapper', hidden: () => that.type.val !== 'video' }, this.videoElement),
div({ class: 'audio-wrapper', hidden: () => that.type.val !== 'audio' }, this.audioElement)
)
}
initPlayer() {
if (!this.element.parentNode) throw new Error('请将播放器元素加入 DOM 树')
this.videoPlayer = new Plyr(this.videoElement, {})
this.audioPlayer = new Plyr(this.audioElement, {})
// 设置当前活动的player
this.updateActivePlayer()
}
private updateActivePlayer() {
this.player = this.type.val === 'video' ? this.videoPlayer : this.audioPlayer
}
setType(type: 'video' | 'audio') {
this.type.val = type
this.updateActivePlayer()
// 清除非活动元素的src
if (type === 'video') {
this.audioElement.src = ''
} else {
this.videoElement.src = ''
}
}
setSrc(src: string) {
this.src.val = src
if (this.type.val === 'video') {
this.videoElement.src = src
this.audioElement.src = ''
} else {
this.audioElement.src = src
this.videoElement.src = ''
}
}
}
export class PlayerModalComp implements VanComponent {
element: HTMLElement
playerComp: PlayerComp
modal: Modal
type = van.state<'video' | 'audio'>('video')
title = van.state('视频播放器')
fileExtension = van.state('.mp4')
constructor() {
this.playerComp = new PlayerComp()
this.element = this.Root()
this.initModalEvent()
van.add(document.body, this.element)
this.playerComp.initPlayer()
this.modal = new Modal(this.element)
}
initModalEvent() {
this.element.addEventListener('shown.bs.modal', () => {
this.playerComp.player.play()
})
this.element.addEventListener('hide.bs.modal', () => {
if (!this.playerComp.player.stopped) {
this.playerComp.player.stop()
this.playerComp.setSrc('')
}
})
}
open(src: string, filename: string, type: 'video' | 'audio') {
this.type.val = type
this.title.val = type === 'video' ? '视频播放器' : '音频播放器'
this.fileExtension.val = type === 'video' ? '.mp4' : '.m4a'
this.playerComp.setType(type)
this.playerComp.setSrc(src)
this.playerComp.filename.val = filename
this.modal.show()
}
Root() {
const that = this
return div({ class: `modal fade`, tabIndex: -1 },
div({ class: 'modal-dialog modal-xl modal-fullscreen-xl-down' },
div({ class: `modal-content` },
div({ class: `modal-header` },
div({ class: `h5 modal-title` }, () => this.title.val),
button({ class: `btn-close`, 'data-bs-dismiss': `modal` })
),
div({ class: 'modal-body p-0' },
div({ class: () => that.type.val === 'video' ? 'ratio ratio-16x9' : '' }, this.playerComp.element),
div({ class: 'vstack p-3 gap-3' },
div({}, that.playerComp.filename)
)
),
div({ class: 'modal-footer' },
button({ class: 'btn btn-secondary', 'data-bs-dismiss': `modal` }, '关闭'),
button({
class: 'btn btn-primary', onclick() {
const link = a({
download: () => that.playerComp.filename.val + that.fileExtension.val,
href: that.playerComp.src
})
that.modal.hide()
link.click()
}
}, '下载')
)
)
)
)
}
}
+11
View File
@@ -0,0 +1,11 @@
import van from 'vanjs-core'
const { div } = van.tags
export const LoadingBox = (color = 'primary') => div({
class: 'py-4 hstack justify-content-center',
},
div({
class: `spinner-border text-${color}`,
}, div({ class: 'visually-hidden' }, 'Loading...'))
)
+88
View File
@@ -0,0 +1,88 @@
import { ResJSON, timeoutController } from '../mixin'
import { FavList, PlayInfo, SeasonInfo, TaskInitData, VideoInfo } from './type'
/**
* 获取视频信息
*
* @throws {Error}
*/
export const getVideoInfo = async (bvid: string): Promise<VideoInfo> => {
const { signal, timer } = timeoutController()
const res = await fetch(`/api/getVideoInfo?bvid=${bvid}`, {
signal
}).then(res => res.json()) as ResJSON<VideoInfo>
if (!res.success) throw new Error(res.message)
clearTimeout(timer)
return res.data
}
/**
* 获取剧集信息
* @param epid EP 号
* @param ssid SS 号
* @throws {Error}
*/
export const getSeasonInfo = async (epid: number, ssid: number): Promise<SeasonInfo> => {
const { signal, timer } = timeoutController()
const res = await fetch(`/api/getSeasonInfo?epid=${epid}&ssid=${ssid}`, {
signal
}).then(res => res.json()) as ResJSON<SeasonInfo>
if (!res.success) throw new Error(res.message)
clearTimeout(timer)
return res.data
}
export const getPlayInfo = async (bvid: string, cid: number, controller: AbortController): Promise<PlayInfo> => {
const res = await fetch(`/api/getPlayInfo?bvid=${bvid}&cid=${cid}`, {
signal: controller.signal
}).then(res => res.json()) as ResJSON<PlayInfo>
if (!res.success) throw new Error(res.message)
return res.data
}
export const createTask = async (tasks: TaskInitData[]): Promise<ResJSON> => {
const { signal, timer } = timeoutController()
const res = await fetch('/api/createTask', {
method: 'POST',
signal,
body: JSON.stringify(tasks),
headers: {
'Content-Type': 'application/json'
}
}).then(res => res.json()) as ResJSON
clearTimeout(timer)
if (!res.success) throw new Error(res.message)
return res
}
export const getPopularVideoBvIds = async (): Promise<string[]> => {
const res = await fetch('/api/getPopularVideos').then(res => res.json()) as ResJSON<string[]>
if (!res.success) throw new Error(res.message)
return res.data
}
export const getRedirectedLocation = async (url: string): Promise<string> => {
return fetch(`/api/getRedirectedLocation?url=${encodeURIComponent(url)}`)
.then(res => res.json())
.then((data: ResJSON<string>) => {
if (!data.success) throw new Error(data.message)
return data.data
})
}
/** 获取收藏夹内视频列表 */
export const getFavList = async (mediaId: number): Promise<FavList> => {
return fetch(`/api/getFavList?mediaId=${mediaId}`)
.then(res => res.json())
.then((body: ResJSON<FavList>) => {
if (!body.success) throw new Error(body.message)
return body.data
})
}
+126
View File
@@ -0,0 +1,126 @@
import van, { State } from 'vanjs-core'
import { Route, goto } from 'vanjs-router'
import VideoInfoCard from './view/videoInfoCard'
import { checkLogin, GLOBAL_HAS_LOGIN, showErrorPage } from '../mixin'
import { VideoParseResult, VideoInfoCardMode, SectionItem } from './type'
import { IDType, start } from './mixin'
import { ParseModalComp } from './view/parseModal'
import InputBox from './view/inputBox'
import { Modal } from 'bootstrap'
import { LoadingBox } from '../view'
import { getPopularVideoBvIds } from './data'
const { div } = van.tags
export class WorkRoute {
element: HTMLDivElement
/** 输入框内容是否标识为异常 */
urlInvalid = van.state(false)
/** 仅作为类名字符串 */
urlInvalidClass = van.derive(() => this.urlInvalid.val ? 'is-invalid' : '')
urlValue = van.state('')
videoInfoCardData = van.state<VideoParseResult>({
title: '', description: '', cover: '', publishData: '', duration: 0,
pages: [], owner: { face: '', mid: 0, name: '' },
dimension: { width: 0, height: 0, rotate: 0 },
staff: [], status: '', areas: [], styles: [], targetURL: '',
section: []
})
/** 标识视频信息卡片应该显示普通视频还是剧集,值为 `hide` 时隐藏卡片 */
videoInfoCardMode: VideoInfoCardMode = van.state('hide')
ownerFaceHide = van.derive(() => this.videoInfoCardData.val.owner.face == '')
/** 全部选项卡和列表数据 */
allSection
/** 当前选项卡的按钮列表 */
sectionPages
/** 当前选中的按钮列表 */
selectedPages
/** 视频列表批量解析模态框 */
parseModal: Modal
parseModalComp: ParseModalComp
/** 按钮是否处于 `loading` 状态,如果是则按钮设置为 `disabled` */
btnLoading = van.state(false)
/** 页面初始加载的 loading 状态 */
initLoading = van.state(true)
/** 是否是初始的热门推荐结果 */
isInitPopular = van.state(false)
sectionTabsActiveIndex = van.state(0)
constructor() {
const _that = this
this.allSection = van.derive(() => {
const list = (
this.videoInfoCardMode.val == 'season'
|| this.videoInfoCardMode.val == 'video' && this.videoInfoCardData.val.section.length == 0
? [{ title: '正片', pages: this.videoInfoCardData.val.pages }] : []
).concat(this.videoInfoCardData.val.section)
return list
})
this.sectionPages = van.derive(() => {
return this.allSection.val[this.sectionTabsActiveIndex.val]?.pages || []
})
this.selectedPages = van.derive(() => this.sectionPages.val.filter(page => page.selected.val))
this.parseModalComp = new ParseModalComp({ workRoute: this })
van.add(document.body, this.parseModalComp.element)
this.parseModal = new Modal(this.parseModalComp.element)
this.element = Route({
rule: 'work',
Loader() {
return div(
() => _that.initLoading.val ? LoadingBox() : '',
div({ class: 'vstack gap-3', hidden: _that.initLoading },
InputBox(_that),
div({ hidden: () => _that.videoInfoCardMode.val == 'hide' || _that.btnLoading.val },
VideoInfoCard(_that),
),
)
)
},
async onFirst() {
if (!await checkLogin()) return
let idType = this.args[0] as IDType
let value = this.args[1]
// if (!value) return goto('work'), _that.initLoading.val = false
if (!value) {
_that.isInitPopular.val = true
const popularBvidList = await getPopularVideoBvIds()
idType = 'bv'
value = popularBvidList[Math.floor(Math.random() * popularBvidList.length)]
}
if (idType == 'bv' && !value.match(/^BV1[a-zA-Z0-9]+$/)) return goto('work')
if ((idType == 'ep' || idType == 'ss' || idType == 'fav')
&& !value.match(/^\d+$/)) return goto('work')
if (idType == 'bv' && !_that.isInitPopular.val) _that.urlValue.val = value
else if (idType == 'ep' || idType == 'ss') _that.urlValue.val = `${idType}${value}`
start(_that, {
idType,
value,
from: 'onFirst'
}).catch(error => {
const errorMessage = `获取视频信息失败:${error.message}`
showErrorPage(errorMessage)
_that.videoInfoCardMode.val = 'hide'
}).finally(() => {
_that.btnLoading.val = false
_that.isInitPopular.val = false
setTimeout(() => {
_that.initLoading.val = false
}, 200)
})
},
async onLoad() {
if (!GLOBAL_HAS_LOGIN.val) return goto('login')
}
})
}
}
export default () => new WorkRoute().element
+196
View File
@@ -0,0 +1,196 @@
import { getFavList, getRedirectedLocation, getSeasonInfo, getVideoInfo } from './data'
import { WorkRoute } from '.'
import van from 'vanjs-core'
import { Episode, PageInParseResult, VideoParseResult } from './type'
import { ResJSON } from '../mixin'
/** 点击按钮开始解析 */
export const start = async (
workRoute: WorkRoute,
option: {
/** 标识字段类型 */
idType: IDType
/** 标识字段值 */
value: string | number
/** 调用来源 */
from: 'click' | 'onFirst'
}) => {
// 如果初始载入路由时,路由参数不带有视频信息,则会随机选择一个热门视频
// 这种情况展示的解析结果,不应该同步修改路由参数,用户直接刷新网页,可以继续随机推荐不同的视频
// 而如果是单击按钮或者路由参数带有视频信息,那么实现的解析结果需要同步修改路由参数
if (!workRoute.isInitPopular.val)
history.replaceState(null, '', `#/work/${option.idType}/${option.value}`)
workRoute.sectionTabsActiveIndex.val = 0
if (option.idType === 'bv') {
const bvid = option.value as string
await getVideoInfo(bvid).then(info => {
workRoute.videoInfoCardData.val = {
section: (info.ugc_season.sections || []).map(i => {
let index = 0
return {
title: (info.ugc_season.sections || []).length == 1 ? info.ugc_season.title : i.title,
pages: i.episodes.flatMap(j => j.pages.map(k => {
index++
return {
bvid: j.bvid,
cid: k.cid,
dimension: k.dimension,
duration: k.duration,
page: index,
part: j.title,
badge: index.toString(),
selected: van.state(bvid == j.bvid)
}
}))
}
}),
targetURL: `https://www.bilibili.com/video/${bvid}`,
areas: [],
styles: [],
status: '',
cover: info.pic,
title: info.title,
description: info.desc,
publishData: new Date(info.pubdate * 1000).toLocaleString(),
duration: info.duration,
pages: info.pages.map((page, index) => ({
...page,
bvid,
badge: (index + 1).toString(),
selected: van.state(info.pages.length == 1)
})),
dimension: info.dimension,
owner: info.owner,
staff: info.staff?.map(i => `${i.name}[${i.title}]`) || []
}
workRoute.videoInfoCardMode.val = 'video'
})
} else if (option.idType === 'ep' || option.idType === 'ss') {
const epid = option.idType === 'ep' ? option.value as number : 0
const ssid = option.idType === 'ss' ? option.value as number : 0
await getSeasonInfo(epid, ssid).then(info => {
workRoute.videoInfoCardData.val = {
section: (info.section || []).map(i => ({
pages: i.episodes.map(episodeToPage),
title: i.title
})),
targetURL: `https://www.bilibili.com/bangumi/play/${option.idType}${option.value}`,
areas: info.areas.map(i => i.name),
styles: info.styles,
duration: 0,
cover: info.cover,
description: info.evaluate,
owner: { name: info.actors, face: '', mid: 0 },
pages: info.episodes.map(episodeToPage),
status: info.new_ep.desc,
publishData: new Date().toLocaleDateString(),
staff: info.actors.split('\n'),
dimension: { height: 0, rotate: 0, width: 0 },
title: info.title
}
workRoute.videoInfoCardMode.val = 'season'
})
} else if (option.idType == 'fav') {
const mediaId = option.value as number
await getFavList(mediaId).then(favList => {
workRoute.videoInfoCardData.val = {
areas: [],
cover: favList[0].cover,
description: favList[0].intro,
dimension: { height: 0, width: 0, rotate: 0 },
duration: favList[0].duration,
owner: favList[0].upper,
pages: favList.map((info, index) => ({
badge: (index + 1).toString(),
selected: van.state(index == 0),
bvid: info.bvid,
cid: info.ugc.first_cid,
dimension: { height: 0, width: 0, rotate: 0 },
duration: info.duration,
page: index,
part: info.title,
})),
publishData: new Date(favList[0].pubtime * 1000).toLocaleString(),
section: [],
staff: [],
status: '',
styles: [],
targetURL: `https://www.bilibili.com/video/${favList[0].bvid}`,
title: favList[0].title
}
workRoute.videoInfoCardMode.val = 'video'
})
}
}
const episodeToPage = (episode: Episode, index: number): PageInParseResult => {
return {
bvid: episode.bvid,
cid: episode.cid,
dimension: episode.dimension,
duration: episode.duration,
page: index + 1,
part: episode.long_title || (episode.title.match(/^\d+$/) ? `${episode.title}` : episode.title),
badge: episode.title,
selected: van.state(false)
}
}
export type IDType = 'bv' | 'ep' | 'ss' | 'fav'
/**
* 校验用户输入的待解析的视频链接
* @param url 待解析的视频链接
* @returns 如果校验成功,则返回 BV 号,否则返回 `false`
*/
export const checkURL = (url: string): {
type: IDType
value: string | number
} => {
const matchBvid = url.match(/^(?:https?:\/\/www\.bilibili\.com\/video\/)?(BV1[a-zA-Z0-9]+)/)
if (matchBvid) return { type: 'bv', value: matchBvid[1] }
const matchSeason = url.match(/^(?:https?:\/\/www\.bilibili\.com\/bangumi\/play\/)?(ep|ss)(\d+)/)
if (matchSeason) return { type: matchSeason[1] as 'ep' | 'ss', value: parseInt(matchSeason[2]) }
try {
const _url = new URL(url)
const mediaId = parseInt(_url.searchParams.get('fid') || '')
if (_url.hostname == 'space.bilibili.com' && _url.pathname.match(/^\/\d+\/favlist$/) && !isNaN(mediaId)) {
return { type: 'fav', value: mediaId }
}
const mlMatch = url.match(/^https:\/\/www.bilibili.com\/medialist\/detail\/ml(\d+)/)
if (mlMatch) return { type: 'fav', value: mlMatch[1] }
} catch { }
throw new Error('您输入的视频链接格式错误')
}
/** 将秒数转换为 `mm:ss` */
export const secondToTime = (second: number) => {
return `${Math.floor(second / 60)}:${(second % 60).toString().padStart(2, '0')}`
}
/** 如果是 B23 地址,则返回重定向后的地址,否则返回 `false` */
export const handleB23 = async (url: string): Promise<string | false> => {
if (!url.match(/^https:\/\/b23.tv\//)) return false
const epMatch = url.match(/^https:\/\/b23.tv\/(ep|ss)(\d+)/)
if (epMatch) return `https://www.bilibili.com/bangumi/play/${epMatch[1]}${epMatch[2]}`
const location = await getRedirectedLocation(url)
return location
}
export const handleSeasonsArchivesList = async (url: string): Promise<string | false> => {
try { new URL(url) } catch { return false }
const _url = new URL(url)
const mid = _url.pathname.match(/^\/(\d+)\/channel\/collectiondetail$/)?.[1]
const seasonId = parseInt(_url.searchParams.get('sid') || '')
if (_url.hostname == 'space.bilibili.com' && mid && !isNaN(seasonId)) {
return fetch(`/api/getSeasonsArchivesListFirstBvid?mid=${mid}&seasonId=${seasonId}`)
.then(res => res.json())
.then((body: ResJSON<string>) => {
if (!body.success) throw new Error(body.message)
return body.data
})
}
return false
}
+277
View File
@@ -0,0 +1,277 @@
import { State } from 'vanjs-core'
/**
* 参考:[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)
*
* | 值 | 含义 |
* | --- | ----------: |
* | 6 | 240P 极速 |
* | 16 | 360P 流畅 |
* | 32 | 480P 清晰 |
* | 64 | 720P 高清 |
* | 74 | 720P60 高帧率 |
* | 80 | 1080P 高清 |
* | 112 | 1080P+ 高码率 |
* | 116 | 1080P60 高帧率 |
* | 120 | 4K 超清 |
* | 125 | HDR 真彩色 |
* | 126 | 杜比视界 |
* | 127 | 8K 超高清 |
*/
export type VideoFormat = 6 | 16 | 32 | 64 | 74 | 80 | 112 | 116 | 120 | 125 | 126 | 127
/** 视频解析结果,数据用于 DOM 渲染,同时兼容 BV、EP、SS */
export type VideoParseResult = {
/** 合集标题 */
title: string
/** 合集描述 */
description: string
/** 合集发布时间 */
publishData: string
/** 合集封面 */
cover: string
/** 合集总时长 */
duration: number
/** 分集列表 */
pages: PageInParseResult[]
section: SectionItem[]
/** 合集作者 */
owner: {
mid: number
name: string
face: string
}
/** 合集分辨率 */
dimension: {
width: number
height: number
rotate: number
}
staff: string[]
/** 更新状态 */
status: string
/** 地区信息 */
areas: string[]
/** 分类标签 */
styles: string[]
/** 播放页面 */
targetURL: string
}
export type SectionItem = {
title: string
pages: PageInParseResult[]
}
export type PageInParseResult = {
/** 分集 CID */
cid: number
/** 分集 BVID */
bvid: string
/** 分集在合集中的序号,从 1 开始 */
page: number
/** 分集标题 */
part: string
/** 分集时长 */
duration: number
/** 分集分辨率 */
dimension: {
width: number
height: number
rotate: number
}
/** 前置胶囊标签内容 */
badge: string
/** 是否选中 */
selected: State<boolean>
}
export type StaffItem = {
mid: number
title: string
name: string
face: string
}
type PageInVideoInfo = {
cid: number
page: number
from: string
part: string
duration: number
dimension: {
width: number
height: number
rotate: number
}
}
/** 接口返回的视频信息 */
export type VideoInfo = {
aid: number
staff: null | StaffItem[]
title: string
desc: string
pubdate: number
pic: string
duration: number
bvid: string
pages: PageInVideoInfo[]
owner: {
mid: number
name: string
face: string
}
dimension: {
width: number
height: number
rotate: number
},
ugc_season: {
sections: {
title: string
episodes: {
title: string
pages: PageInVideoInfo[]
bvid: string
}[]
}[] | null
title: string
}
}
/** 接口返回的剧集信息 */
export type SeasonInfo = {
actors: string
areas: {
id: number
name: string
}[]
cover: string
evaluate: string
publish: {
is_finish: number
pub_time: string
};
season_id: number
season_title: string
stat: {
coins: number
danmakus: number
favorite: number
favorites: number
likes: number
reply: number
share: number
views: number
}
styles: string[]
title: string
total: number
episodes: Episode[]
new_ep: {
desc: string
is_new: number
}
section: {
title: string
episodes: Episode[]
}[] | null
}
export type Episode = {
aid: number
bvid: string
cid: number
cover: string
dimension: {
width: number
height: number
rotate: number
}
duration: number
ep_id: number
long_title: string
pub_time: number
title: string
}
export type VideoInfoCardMode = State<"video" | "season" | "hide">
export type Media<Type = 'video' | 'audio'> = {
id: Type extends 'video' ? VideoFormat : number
baseUrl: string
backupUrl: string[]
bandwidth: number
mimeType: string
codecs: string
width: number
height: number
frameRate: string
codecid: number
}
export type PlayInfo = {
// accept_description: string[]
accept_quality: VideoFormat[]
// support_formats: {
// quality: number
// format: string
// new_description: string
// codecs: string[]
// }[]
dash: {
duration: number
video: Media<'video'>[]
audio: Media<'audio'>[]
flac: {
audio: Media
} | null
}
}
/** 创建任务时的初始数据 */
export type TaskInitData = {
bvid: string
cid: number
format: number
title: string
owner: string
cover: string
audio: string
video: string
duration: number
downloadType: 'audio' | 'video' | 'merge'
}
/** 任务数据库中的数据 */
export type TaskInDB = TaskInitData & {
id: number
folder: string
createAt: string
status: TaskStatus
}
export type TaskStatus = 'done' | 'waiting' | 'running' | 'error'
export type FavList = FavListItem[]
export type FavListItem = {
title: string
cover: string
intro: string
duration: number
upper: {
mid: number
name: string
face: string
}
pubtime: number
bvid: string
ugc: {
first_cid: number
}
}
+85
View File
@@ -0,0 +1,85 @@
import van from 'vanjs-core'
import { goto } from 'vanjs-router'
import { v4 } from 'uuid'
import { checkURL, handleB23, handleSeasonsArchivesList, start } from '../mixin'
import { WorkRoute } from '..'
import { VanComponent } from '../../mixin'
const { button, div, input, label, span } = van.tags
class InputBoxComp implements VanComponent {
element: HTMLElement
btnID = v4()
constructor(public workRoute: WorkRoute) {
this.element = div(
div({ class: () => `hstack gap-3 align-items-stretch ${workRoute.urlInvalidClass.val}` },
div({ class: () => `form-floating flex-fill` },
input({
class: () => `form-control border-3 ${workRoute.urlInvalidClass.val}`,
placeholder: '请输入待解析的视频链接',
value: workRoute.urlValue,
oninput: event => workRoute.urlValue.val = event.target.value,
onkeyup: event => {
if (event.key === 'Enter') document.getElementById(this.btnID)?.click()
}
}),
label({ class: 'w-100' }, '请输入视频链接或 BV/EP/SS 号')
),
ParseButton(this, false, this.btnID),
ParseButton(this, true)
),
div({ class: 'invalid-feedback' }, () => workRoute.urlInvalid.val ? '您输入的视频链接格式错误' : ''),
)
}
}
const ParseButton = (parent: InputBoxComp, large: boolean, id: string = '') => {
const { workRoute } = parent
return button({
class: `btn btn-success text-nowrap ${large ? `btn-lg d-none d-md-block` : 'd-md-none'}`,
async onclick() {
try {
workRoute.btnLoading.val = true
workRoute.urlValue.val = workRoute.urlValue.val.trim()
try {
const handleB23Result = await handleB23(workRoute.urlValue.val)
if (handleB23Result) workRoute.urlValue.val = handleB23Result
} catch (error) {
if (error instanceof Error) return alert(error.message)
}
try {
const handleSeasonsArchivesListResult = await handleSeasonsArchivesList(workRoute.urlValue.val)
if (handleSeasonsArchivesListResult) workRoute.urlValue.val = handleSeasonsArchivesListResult
} catch (error) {
if (error instanceof Error) return alert(error.message)
}
const { type, value } = checkURL(workRoute.urlValue.val)
workRoute.urlInvalid.val = false
await start(workRoute, {
idType: type,
value,
from: 'click'
}).catch(error => {
const errorMessage = `获取视频信息失败:${error.message}`
alert(errorMessage)
goto('work')
workRoute.videoInfoCardMode.val = 'hide'
})
} catch (error) {
workRoute.urlInvalid.val = true
} finally {
setTimeout(() => {
workRoute.btnLoading.val = false
}, 200)
}
},
id,
disabled: workRoute.btnLoading
}, span({ class: 'spinner-border spinner-border-sm me-2', hidden: () => !workRoute.btnLoading.val }),
() => workRoute.btnLoading.val ? '解析中' : '解析视频'
)
}
export default (workRoute: WorkRoute) => new InputBoxComp(workRoute).element
+337
View File
@@ -0,0 +1,337 @@
import van, { State } from 'vanjs-core'
import { VanComponent, formatSeconds } from '../../mixin'
import { PageInParseResult, PlayInfo, VideoFormat } from '../type'
import { WorkRoute } from '..'
import { createTask, getPlayInfo } from '../data'
import PQueue from 'p-queue'
const { a, button, div, input, select, option, label } = van.tags
type Option = {
workRoute: WorkRoute
}
const videoFormatMap: Record<VideoFormat, string> = {
127: "超高清 8K",
126: "杜比视界",
125: "真彩 HDR",
120: "超清 4K",
116: "高清 1080P60",
112: "高清 1080P+",
80: "高清 1080P",
74: "高清 720P60",
64: "高清 720P",
32: "清晰 480P",
16: "流畅 360P",
6: "极速 240P",
}
const codecMap: Record<12 | 7 | 13, string> = {
12: "HEVC (hev1)",
7: "AVC (avc1)",
13: "AV1 (av01)",
}
export class ParseModalComp implements VanComponent {
element: HTMLElement
totalCount: State<number>
finishCount = van.state(0)
abortControllers: AbortController[] = []
currentController?: AbortController
allPlayInfo: State<{
page: PageInParseResult
info: PlayInfo | null
selected: State<boolean>
formatIndex: State<number>
}[]> = van.state([])
/** 该属性用于在点击“开始下载”按钮后使按钮变为禁用状态,防止多次点击 */
downloadBtnDisabled = van.state(false)
/** 下载类型:audio 仅音频,video 仅视频,merge 音视频合并 */
downloadType = van.state<'audio' | 'video' | 'merge'>('merge')
/** 优先视频编码格式:12 hev1, 7 avc1, 13 av01 */
preferredCodec = van.state<12 | 7 | 13>(12)
/** 是否优先使用HiResflac)音频 */
preferHiResAudio = van.state(true)
errorList: State<string[]> = van.state([])
constructor(public option: Option) {
this.totalCount = van.derive(() => option.workRoute.selectedPages.val.length)
const allFinish = van.derive(() => this.totalCount.val == this.finishCount.val)
this.element = div({ class: `modal fade`, tabIndex: -1 },
div({ class: () => `modal-dialog modal-xl modal-fullscreen-xl-down ${(this.totalCount.val + this.errorList.val.length) < 10 ? '' : 'modal-dialog-scrollable'}` },
div({ class: `modal-content` },
div({ class: `modal-header` },
div({ class: `h5 modal-title` }, () => allFinish.val ? '批量下载' : '批量解析'),
button({ class: `btn-close`, 'data-bs-dismiss': `modal` })
),
div({ class: `modal-body vstack gap-3`, tabIndex: -1, style: 'outline: none;' },
this.ParseProgress(),
div({ class: 'vstack gap-2', hidden: () => this.errorList.val.length == 0 || !allFinish.val },
div({ class: 'text-danger' }, () => `以下 ${this.errorList.val.length} 个视频解析失败`),
() => div({ class: 'list-group' },
this.errorList.val.map(error => div({ class: 'list-group-item disabled' }, error))
)
),
this.ListGroup()
),
this.ModalFooter()
)
)
)
this.element.addEventListener('hidden.bs.modal', () => {
this.allPlayInfo.val = []
this.finishCount.val = this.totalCount.val
for (const controller of this.abortControllers) {
controller.abort()
}
this.abortControllers = []
})
this.element.addEventListener('show.bs.modal', () => {
this.start()
})
}
/** 开始解析 */
async start() {
this.finishCount.val = 0
this.errorList.val = []
const queue = new PQueue({ concurrency: 10 })
for (const page of this.option.workRoute.selectedPages.val) {
queue.add(async () => {
if (this.totalCount.val == this.finishCount.val) return
const controller = new AbortController()
this.abortControllers.push(controller)
const playInfo = await getPlayInfo(page.bvid, page.cid, controller)
playInfo.accept_quality = [...new Set(playInfo.dash.video.map(video => video.id))].sort((a, b) => b - a)
this.allPlayInfo.val = this.allPlayInfo.val.concat({
page,
info: playInfo,
selected: van.state(true),
formatIndex: van.state(0),
})
this.finishCount.val++
}).catch(() => {
this.finishCount.val++
const badgeNotNum = !page.badge.match(/^\d+$/)
this.errorList.val = this.errorList.val.concat(`${page.part}${badgeNotNum ? ` - ${page.badge}` : ''}`)
})
}
await queue.onIdle()
}
download() {
const selectedPlayInfos = this.allPlayInfo.val.filter(info => info.selected.val)
const workRoute = this.option.workRoute
this.downloadBtnDisabled.val = true
// 需要传递给服务器,需要创建下载任务的数据列表
createTask(selectedPlayInfos.map(info => {
const badgeNotNum = !info.page.badge.match(/^\d+$/)
const isVideoMode = workRoute.videoInfoCardMode.val == 'video'
const cardTitle = workRoute.videoInfoCardData.val.title
const owner = workRoute.videoInfoCardData.val.staff.length > 0
? workRoute.videoInfoCardData.val.staff[0].split("[")[0].trim()
: workRoute.videoInfoCardData.val.owner.name.trim()
const activeVideoInfo = getActiveFormatVideo(info.info!, info.info!.accept_quality[info.formatIndex.val], this.preferredCodec.val)
const pagesLength = workRoute.videoInfoCardData.val.pages.length
return ({
bvid: info.page.bvid,
cid: info.page.cid,
cover: workRoute.videoInfoCardData.val.cover,
title: (badgeNotNum
? [
info.page.part.trim(),
`[${info.page.badge.trim()}]`,
`[${cardTitle.trim()}]`,
`[${videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]}]`,
`[${formatSeconds(info.info!.dash.duration)}]`
]
: [
pagesLength == 1 ? workRoute.allSection.val[workRoute.sectionTabsActiveIndex.val].title : `[${cardTitle.trim()}]`,
workRoute.sectionPages.val.length == 1 ? '' : `[${info.page.badge.trim()}]`,
info.page.part.trim(),
isVideoMode ? `[${owner}]` : '',
`[${videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]}]`,
`[${formatSeconds(info.info!.dash.duration)}]`
]).filter(p => p).join(' '),
format: info.info!.accept_quality[info.formatIndex.val],
owner,
audio: getAudioURL(info.info!, this.preferHiResAudio.val),
duration: info.info!.dash.duration,
downloadType: this.downloadType.val,
...activeVideoInfo
})
})).then(() => {
workRoute.parseModal.hide()
}).catch(error => {
alert(error.message)
}).finally(() => {
this.downloadBtnDisabled.val = false
})
}
ParseProgress() {
return div({ class: 'vstack gap-3', hidden: () => this.totalCount.val == this.finishCount.val },
div({ class: 'text-center fs-5' }, () => `正在解析,剩余 ${this.totalCount.val - this.finishCount.val}`),
div({ class: 'progress' }, div({
class: 'progress-bar progress-bar-striped progress-bar-animated',
style: () => `width: ${this.finishCount.val / this.totalCount.val * 100}%`
},)),
)
}
ListGroup() {
return () => div({ class: 'list-group', hidden: () => this.totalCount.val != this.finishCount.val },
this.allPlayInfo.val.filter(info => info.info)
.sort((a, b) => a.page.page - b.page.page)
.map(info => {
const badgeNotNum = !info.page.badge.match(/^\d+$/)
return div({
class: () => `list-group-item user-select-none py-0 ${info.info ? '' : 'disabled'}`,
role: 'button',
onclick(event) {
if ((event.target as HTMLElement).getAttribute('class')?.match(/dropdown-?/)) return
info.selected.val = !info.selected.val
}
},
div({ class: 'hstack gap-2' },
div({ class: 'hstack gap-3 flex-fill py-1' },
input({
class: 'form-check-input', type: 'checkbox', checked: info.selected,
}),
div({},
div((badgeNotNum ? '' : `${info.page.badge}. `) + info.page.part),
badgeNotNum ? div({ class: info.page.part ? 'small text-secondary' : '' },
info.page.badge) : ''
),
),
div({ class: 'dropdown' },
div({ class: 'dropdown-toggle py-2 text-primary', 'data-bs-toggle': 'dropdown' },
() => videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]
),
() => {
return div({ class: 'dropdown-menu shadow' },
info.info!.accept_quality.map((formatID, index) => {
return div({
class: () => `dropdown-item ${info.formatIndex.val == index ? 'active' : ''}`,
onclick() {
info.formatIndex.val = index
}
}, videoFormatMap[formatID as VideoFormat])
})
)
}
)
)
)
})
)
}
ModalFooter() {
const _that = this
const selectedCount = van.derive(() => this.allPlayInfo.val.filter(info => info.selected.val).length)
const totalCount = van.derive(() => this.allPlayInfo.val.length)
/** 解析完成列表全部选中 */
const allSelected = van.derive(() => selectedCount.val == totalCount.val)
/** 是否全部解析完成 */
const allFinish = van.derive(() => this.totalCount.val == this.finishCount.val)
return div({ class: `modal-footer` },
div({ class: 'me-auto', hidden: () => !allFinish.val || totalCount.val == 0 },
div({ class: 'hstack gap-3 text-nowrap' },
() => `已选择 (${selectedCount.val}/${totalCount.val}) 项`,
select({
class: 'form-select form-select-sm',
value: _that.downloadType,
oninput: (e) => _that.downloadType.val = (e.target as HTMLSelectElement).value as 'audio' | 'video' | 'merge'
},
option({ value: 'merge' }, '音视频合并'),
option({ value: 'audio' }, '仅音频'),
option({ value: 'video' }, '仅视频')
),
select({
class: 'form-select form-select-sm',
value: _that.preferredCodec,
oninput: (e) => _that.preferredCodec.val = Number((e.target as HTMLSelectElement).value) as 12 | 7 | 13
},
option({ value: '12' }, 'HEVC (hev1)'),
option({ value: '7' }, 'AVC (avc1)'),
option({ value: '13' }, 'AV1 (av01)')
),
div({ class: 'form-check form-check-inline' },
input({
type: 'checkbox',
class: 'form-check-input',
id: 'preferHiResAudio',
checked: _that.preferHiResAudio,
oninput: (e) => _that.preferHiResAudio.val = (e.target as HTMLInputElement).checked
}),
label({ class: 'form-check-label', for: 'preferHiResAudio' }, 'Hi-Res')
)
)
),
button({
class: `btn btn-secondary`,
'data-bs-dismiss': `modal`,
hidden: allFinish
}, '取消解析'),
button({
class: 'btn btn-secondary', hidden: () => !allFinish.val || allSelected.val || totalCount.val == 0,
onclick() {
_that.allPlayInfo.val.forEach(info => info.selected.val = true)
}
}, '全选'),
button({
class: 'btn btn-warning', hidden: () => !allFinish.val || !allSelected.val || totalCount.val == 0,
onclick() {
_that.allPlayInfo.val.forEach(info => info.selected.val = false)
}
}, '全不选'),
button({
class: `btn btn-primary`, onclick() {
_that.download()
},
hidden: () => !allFinish.val,
disabled: () => selectedCount.val <= 0 || _that.downloadBtnDisabled.val
}, '开始下载'),
)
}
}
const getAudioURL = (playInfo: PlayInfo, preferHiRes: boolean = true): string => {
if (preferHiRes && playInfo.dash.flac) {
return playInfo.dash.flac.audio.baseUrl
} else {
return playInfo.dash.audio.sort((a, b) => b.id - a.id)[0].baseUrl
}
}
const getActiveFormatVideo = (playInfo: PlayInfo, format: VideoFormat, preferredCodec: 12 | 7 | 13 = 12): { video: string, width: number, height: number } => {
// 优先级顺序:用户首选编码格式,然后按默认优先级 12 > 7 > 13
const codecOrder = [preferredCodec, 12, 7, 13].filter((value, index, self) => self.indexOf(value) === index)
for (const code of codecOrder) {
for (const item of playInfo.dash.video) {
if (item.id == format && item.codecid == code) {
return {
video: item.baseUrl,
width: item.width,
height: item.height
}
}
}
}
throw new Error('未找到对应视频分辨率格式')
}
+198
View File
@@ -0,0 +1,198 @@
import van, { State, Val } from 'vanjs-core'
import { VideoParseResult, VideoInfoCardMode } from '../type'
import { secondToTime } from '../mixin'
import { VanComponent } from '../../mixin'
import VideoItemList from './videoItemList'
import { WorkRoute } from '..'
const { a, div, img } = van.tags
class VideoInfoCardComp implements VanComponent {
element: HTMLElement
constructor(
public workRoute: WorkRoute,
) {
const {
videoInfoCardData: data,
videoInfoCardMode: mode,
ownerFaceHide
} = workRoute
this.element = div({ class: 'card border-2 shadow-sm' },
div({ class: 'card-header' },
a({
class: 'link-dark text-decoration-none fw-bold focus-ring', href: () => data.val.targetURL,
target: '_blank',
},
() => data.val.title,
)
),
div({ class: 'card-body vstack gap-4' },
div({ class: 'vstack gap-3' },
div({ class: 'row gx-3 gy-3' },
// 封面
div({
class: () => mode.val == 'video'
? 'col-md-5 col-xl-4'
: 'col-8 col-sm-6 mx-auto col-md-5 col-lg-3 col-xl-2'
},
div({ class: 'position-relative shadow-sm rounded overflow-hidden' },
a({
href: () => data.val.targetURL,
title: () => `打开视频播放页面`,
target: '_blank',
},
img({
src: () => data.val.cover,
class: 'w-100',
ondragstart: event => event.preventDefault(),
referrerPolicy: 'no-referrer',
})
),
a({
href: () => `https://space.bilibili.com/${data.val.owner.mid}`,
title: () => `查看用户主页:${data.val.owner.name}`,
target: '_blank',
tabIndex: () => mode.val == 'video' ? 0 : -1,
},
img({
src: () => data.val.owner.face,
hidden: ownerFaceHide,
referrerPolicy: 'no-referrer',
ondragstart: event => event.preventDefault(),
style: `right: 1rem; bottom: 1rem;`,
class: 'rounded-3 border shadow position-absolute w-25'
})
),
),
),
// 字段信息
div({
class: () => mode.val == 'video'
? 'col-md-7 col-xl-8 vstack gap-2'
: 'col-md-7 col-lg-9 col-xl-10 vstack gap-2'
},
div({ class: 'position-relative h-100' },
div({ class: 'position-absolute top-0 bottom-0 position-relative-sm-down' },
Right(this)
)
),
),
),
DescriptionGroup(this, true),
),
VideoItemList(workRoute)
)
)
}
}
const Right = (parent: VideoInfoCardComp) => {
const mode = parent.workRoute.videoInfoCardMode
const data = parent.workRoute.videoInfoCardData
return div({ class: 'vstack gap-2 h-100' },
div({ class: 'row gx-2 gy-2' },
div({ class: 'col-xl-7 col-xxl-8' },
InputGroup(
van.derive(() => mode.val == 'video'
? (data.val.staff.length > 0 ? '制作信息' : '发布者')
: '参演人员'),
van.derive(() => {
if (data.val.staff.length > 0)
return data.val.staff.map(i => i.trim()).join(', ')
return data.val.owner.name
}), { disabled: true }
),
),
div({ class: 'col-xl-5 col-xxl-4' },
InputGroup('发布时间',
van.derive(() => data.val.publishData), { disabled: true }
)
),
div({ class: 'col-sm col-md-12 col-lg-4', hidden: () => mode.val != 'video' },
InputGroup('分辨率',
van.derive(() => `${data.val.dimension.width}x${data.val.dimension.height}`),
{ disabled: true }
)
),
div({ class: 'col col-lg-4', hidden: () => mode.val != 'video' },
InputGroup('时长',
van.derive(() => `${secondToTime(data.val.duration)}`),
{ disabled: true }
)
),
div({ class: 'col col-lg-4', hidden: () => mode.val != 'video' },
InputGroup('集数',
van.derive(() => data.val.pages.length.toString()),
{ disabled: true }
)
),
div({ class: 'col-md-12 col-lg-4', hidden: () => mode.val != 'season' },
InputGroup('状态',
van.derive(() => data.val.status),
{ disabled: true }
)
),
div({ class: 'col-sm col-lg-4', hidden: () => mode.val != 'season' },
InputGroup('地区',
van.derive(() => data.val.areas.map(i => i.trim()).join(', ')),
{ disabled: true }
)
),
div({ class: 'col-sm col-lg-4', hidden: () => mode.val != 'season' },
InputGroup('标签',
van.derive(() => data.val.styles.join(', ')),
{ disabled: true }
)
),
),
DescriptionGroup(parent),
)
}
/** 用于显示 `description` 字段的 `.input-group`
*
* @param parent 父组件
* @param bottom 是否在底部
*/
const DescriptionGroup = (parent: VideoInfoCardComp, bottom = false) => {
const mode = parent.workRoute.videoInfoCardMode
const data = parent.workRoute.videoInfoCardData
const _class = van.derive(() => mode.val == 'video' ? 'd-md-flex' : '')
const size = van.derive(() => mode.val == 'video' ? 'lg' : 'lg')
return div({
class: () => `shadow-sm input-group input-group-sm ${bottom
? `d-none d-lg-none ${_class.val}`
: `overflow-hidden flex-fill ${mode.val == 'video' ? 'd-md-none d-lg-flex ' : ''}`
}`,
},
div({ class: 'input-group-text align-items-start' }, '描述'),
() => {
const lines = (data.val.description.match(/^(\s*|.)$/) ? '暂无描述' : data.val.description).split('\n')
return div({ class: `form-control overflow-auto ${bottom ? `max-height-description` : `h-100`}` },
lines.map(line => div(line))
)
}
)
}
const InputGroup = (title: Val<string>, value: State<string>, option?: {
disabled?: Val<boolean>
elementType?: 'input' | 'textarea'
}) => {
return div({ class: 'input-group input-group-sm shadow-sm rounded' },
div({ class: 'input-group-text' }, title),
van.tags[option?.elementType || 'input']({
class: 'form-control bg-white',
disabled: option?.disabled || false,
style: 'cursor: text;',
value
})
)
}
export default (
workRoute: WorkRoute
) => new VideoInfoCardComp(workRoute).element
+102
View File
@@ -0,0 +1,102 @@
import van, { State } from 'vanjs-core'
import { VideoParseResult, VideoInfoCardMode, PageInParseResult, SectionItem } from '../type'
import { VanComponent } from '../../mixin'
import { WorkRoute } from '..'
const { button, div, span } = van.tags
class VideoItemListComp implements VanComponent {
element: HTMLElement
constructor(
public workRoute: WorkRoute
) {
const { videoInfoCardData: data } = workRoute
this.element = div({
hidden: () => false && data.val.pages.length <= 1,
class: 'vstack gap-4'
},
div({ class: 'vstack gap-4' },
div({ hidden: () => workRoute.allSection.val.length == 1 && workRoute.allSection.val[0].title == '正片' }, SectionTabs(this, workRoute.allSection)),
ButtonGroup(workRoute),
ListBox(workRoute.sectionPages),
)
)
}
}
const SectionTabs = (parent: VideoItemListComp, allSection: State<SectionItem[]>) => {
return () => div({ class: 'nav nav-underline' },
allSection.val.map((item, index) => div({ class: 'nav-item', role: 'button' },
div({
tabIndex: 0,
class: `nav-link ${parent.workRoute.sectionTabsActiveIndex.val == index ? 'active' : ''}`,
onclick() {
parent.workRoute.sectionTabsActiveIndex.val = index
},
onkeyup(e) {
if (e.key == 'Enter') {
e.target.click()
}
}
}, () => item.title)
))
)
}
const ButtonGroup = (workRoute: WorkRoute) => {
const pages = workRoute.sectionPages
const selectedCount = van.derive(() => pages.val.filter(page => page.selected.val).length)
const totalCount = van.derive(() => pages.val.length)
return div({ class: 'hstack gap-3' },
button({
class: 'btn btn-secondary',
onclick() {
pages.val.forEach(page => page.selected.val = selectedCount.val < totalCount.val)
}
}, () => `${selectedCount.val < totalCount.val ? '全选' : '取消全选'} (${selectedCount.val}/${totalCount.val})`),
button({
class: 'btn btn-primary',
disabled: () => selectedCount.val <= 0,
async onclick() {
workRoute.parseModal.show()
}
}, '解析选中项目')
)
}
const ListBox = (pages: State<PageInParseResult[]>) => {
return () => div({ class: 'row gy-3 gx-3' },
pages.val.map(page => {
const badgeNotNum = !page.badge.match(/^\d+$/)
const active = page.selected
return div({ class: 'col-xxl-3 col-lg-4 col-md-6' },
div({
tabIndex: 0,
class: () => `${badgeNotNum
? `vstack gap-2 justify-content-center`
: `hstack gap-3`
} shadow-sm h-100 text-break user-select-none card card-body video-item-btn bg-success bg-opacity-10 ${active.val ? 'active' : ''}`,
onclick() {
active.val = !active.val
},
onkeyup(e) {
if (e.key == 'Enter') {
active.val = !active.val
}
}
},
span({ class: 'badge text-bg-success bg-opacity-75 border', hidden: badgeNotNum }, page.badge),
div(page.part),
div({ class: `${page.part ? 'small text-muted' : ''}`, hidden: !badgeNotNum }, page.badge),
)
)
}),
)
}
export default (
workRoute: WorkRoute
) => new VideoItemListComp(workRoute).element
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ESNext"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
export default defineConfig({
clearScreen: false,
server: {
proxy: {
'/api': 'http://127.0.0.1:8098'
},
host: '0.0.0.0'
},
build: {
outDir: '../server/static',
emptyOutDir: true,
},
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler'
}
}
}
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

+3
View File
@@ -0,0 +1,3 @@
[build]
exclude_dir = ["download", "bin"]
include_ext = ["go"]
+71
View File
@@ -0,0 +1,71 @@
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
# The lines below are called `modelines`. See `:help modeline`
# Feel free to remove those if you don't want/need to use them.
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
# you may remove this if you don't need go generate
- go generate ./...
builds:
- id: bilidown_windows
env:
- CGO_ENABLED=1
- >-
{{- if eq .Arch "amd64" }}CC=x86_64-w64-mingw32-gcc{{ end }}
{{- if eq .Arch "386" }}CC=i686-w64-mingw32-gcc{{ end }}
goos:
- windows
ldflags:
- -s -w -H windowsgui
- id: bilidown_darwin
env:
- CGO_ENABLED=1
- CC=o64-clang
goos:
- darwin
ldflags:
- -s -w
- id: bilidown_linux
env:
- CGO_ENABLED=1
- >-
{{- if eq .Arch "arm64" }}CC=aarch64-linux-gnu-gcc{{ end }}
goos:
- linux
goarch:
- amd64
ldflags:
- -s -w
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
format: zip
files:
- static/**
- >-
{{- if eq .Os "windows" }}bin/ffmpeg.exe{{- end }}
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
+146
View File
@@ -0,0 +1,146 @@
package bilibili_test
import (
"encoding/json"
"fmt"
"testing"
"time"
"bilidown/bilibili"
"bilidown/util"
"github.com/skip2/go-qrcode"
_ "modernc.org/sqlite"
)
const TEST_SESSDATA = ""
const TEST_BVID = "BV1KX4y1V7sA" // 普通合集
const TEST_CID = 305542578 // 普通分集
const TEST_BVID_HDR = "BV1rp4y1e745" // HDR 合集
const TEST_CID_HDR = 244954665 // HDR 分集
const TEST_EPID = 835909 // 剧集
const TEST_SSID = 48744 // 番剧
func TestBiliClient(t *testing.T) {
client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA}
check, err := client.CheckLogin()
if err != nil {
t.Error(err)
}
if check {
fmt.Println("SESSDATA 有效")
} else {
fmt.Println("SESSDATA 无效")
}
}
func TestNewQRInfo(t *testing.T) {
client := bilibili.BiliClient{}
info, err := client.NewQRInfo()
if err != nil {
t.Error(err)
}
fmt.Printf("二维码信息:%+v", info)
}
func TestGetQRStatus(t *testing.T) {
client := bilibili.BiliClient{}
qrInfo, err := client.NewQRInfo()
if err != nil {
t.Error(err)
}
qr, err := qrcode.New(qrInfo.URL, qrcode.Low)
if err != nil {
t.Error(err)
}
fmt.Println(qr.ToSmallString(false))
for {
qrStatus, sessdata, err := client.GetQRStatus(qrInfo.QrcodeKey)
fmt.Println(qrStatus.Message)
if err != nil {
t.Error(err)
}
if qrStatus.Code == bilibili.QR_SUCCESS {
fmt.Println("登录成功")
fmt.Println(sessdata)
break
} else if qrStatus.Code == bilibili.QR_EXPIRES {
break
}
time.Sleep(time.Second)
}
}
func TestGetBVInfo(t *testing.T) {
client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA}
videoInfo, err := client.GetVideoInfo(TEST_BVID)
if err != nil {
t.Error(err)
}
fmt.Printf("%+v", videoInfo)
}
func TestGetSeasonInfo(t *testing.T) {
client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA}
seasonInfo, err := client.GetSeasonInfo(TEST_EPID, TEST_SSID)
if err != nil {
t.Error(err)
}
fmt.Printf("%+v", seasonInfo)
}
func TestGetPlayInfo(t *testing.T) {
client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA}
playInfo, err := client.GetPlayInfo(TEST_BVID_HDR, TEST_CID_HDR)
if err != nil {
t.Error(err)
}
a, _ := json.Marshal(playInfo.AcceptQuality)
b, _ := json.Marshal(playInfo.AcceptDescription)
c, _ := json.Marshal(playInfo.SupportFormats)
fmt.Println(string(a))
fmt.Println(string(b))
fmt.Println(string(c))
}
func TestSaveSessdata(t *testing.T) {
db := util.MustGetDB("../data.db")
defer db.Close()
err := bilibili.SaveSessdata(db, TEST_SESSDATA)
if err != nil {
t.Error(err)
} else {
fmt.Println("SESSDATA 保存成功")
}
sessdata, err := bilibili.GetSessdata(db)
if err != nil {
t.Error(err)
}
if sessdata != TEST_SESSDATA {
t.Error("SESSDATA 读写不一致")
} else {
fmt.Println("SESSDATA 读取成功")
}
}
func TestGetSessdata(t *testing.T) {
db := util.MustGetDB("../data.db")
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil {
t.Error(err)
}
fmt.Println(sessdata)
}
func TestGetPopularVideos(t *testing.T) {
client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA}
videos, err := client.GetPopularVideos()
if err != nil {
t.Error(err)
}
fmt.Printf("%+v", videos)
}
+177
View File
@@ -0,0 +1,177 @@
package bilibili
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/url"
"regexp"
"bilidown/util"
)
type BiliClient struct {
SESSDATA string
}
// SimpleGET 简单的 GET 请求
func (client *BiliClient) SimpleGET(_url string, params map[string]string) (*http.Response, error) {
values := url.Values{}
for k, v := range params {
values.Set(k, v)
}
_client := http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(nil),
},
}
request, err := http.NewRequest("GET", _url+"?"+values.Encode(), nil)
if err != nil {
return nil, err
}
request.Header = client.MakeHeader()
return _client.Do(request)
}
// MakeHeader 生成请求头
func (client *BiliClient) MakeHeader() http.Header {
header := http.Header{}
header.Set("Cookie", "SESSDATA="+client.SESSDATA)
header.Set("User-Agent", "Mozilla/5.0")
header.Set("Referer", "https://www.bilibili.com")
return header
}
// CheckLogin 检查是否已经登录
func (client *BiliClient) CheckLogin() (bool, error) {
response, err := client.SimpleGET("https://api.bilibili.com/x/space/myinfo", nil)
if err != nil {
return false, err
}
defer response.Body.Close()
body := BaseResV2{}
err = json.NewDecoder(response.Body).Decode(&body)
if err != nil {
return false, err
}
if body.Code != 0 {
return false, errors.New(body.Message)
}
return body.Success(), nil
}
// NewQRInfo 获取登录二维码信息
func (client *BiliClient) NewQRInfo() (*QRInfo, error) {
response, err := client.SimpleGET("https://passport.bilibili.com/x/passport-login/web/qrcode/generate", nil)
if err != nil {
return nil, err
}
defer response.Body.Close()
body := BaseResV2{}
err = json.NewDecoder(response.Body).Decode(&body)
if err != nil {
return nil, err
}
if body.Code != 0 {
return nil, errors.New(body.Message)
}
qrInfo := QRInfo{}
err = json.Unmarshal(body.Data, &qrInfo)
if err != nil {
return nil, err
}
return &qrInfo, nil
}
func (client *BiliClient) getWbiKeyRemote() (wbiKey string, err error) {
if client.SESSDATA == "" {
return "", errors.New("SESSDATA 不能为空")
}
response, err := client.SimpleGET("https://api.bilibili.com/x/web-interface/nav", nil)
if err != nil {
return "", err
}
defer response.Body.Close()
body := BaseResV2{}
if err = json.NewDecoder(response.Body).Decode(&body); err != nil {
return "", err
}
var data struct {
WbiImg struct {
ImgURL string `json:"img_url"`
SubURL string `json:"sub_url"`
} `json:"wbi_img"`
}
if err = json.Unmarshal(body.Data, &data); err != nil {
return "", err
}
match := regexp.MustCompile(`/bfs/wbi/([a-z0-9]+)\.`)
imgKey := match.FindStringSubmatch(data.WbiImg.ImgURL)[1]
subKey := match.FindStringSubmatch(data.WbiImg.SubURL)[1]
if imgKey == "" || subKey == "" {
return "", errors.New("regexp.MustCompile(`/bfs/wbi/([a-z0-9])\\.`)")
}
return imgKey + subKey, nil
}
// GetQRStatus 获取二维码状态
func (client *BiliClient) GetQRStatus(qrKey string) (qrStatus *QRStatus, sessdata string, err error) {
params := map[string]string{
"qrcode_key": qrKey,
}
response, err := client.SimpleGET("https://passport.bilibili.com/x/passport-login/web/qrcode/poll", params)
if err != nil {
return nil, "", err
}
defer response.Body.Close()
body := BaseResV2{}
err = json.NewDecoder(response.Body).Decode(&body)
if err != nil {
return nil, "", err
}
if body.Code != 0 {
return nil, "", errors.New(body.Message)
}
qrStatus = &QRStatus{}
err = json.Unmarshal(body.Data, &qrStatus)
if err != nil {
return nil, "", err
}
if qrStatus.Code != 0 {
return qrStatus, "", nil
}
sessdata, err = GetCookieValue(response.Cookies(), "SESSDATA")
if err != nil {
return nil, "", err
}
return qrStatus, sessdata, nil
}
// GetCookieValue 获取指定 Name 的 Cookie 值
func GetCookieValue(cookies []*http.Cookie, name string) (string, error) {
for _, cookie := range cookies {
if cookie.Name == name {
return cookie.Value, nil
}
}
return "", errors.New("cookie with name " + name + " not found")
}
// SaveSessdata 保存 SESSDATA
func SaveSessdata(db *sql.DB, sessdata string) error {
util.SqliteLock.Lock()
_, err := db.Exec(`INSERT OR REPLACE INTO "field" ("name", "value") VALUES ("sessdata", ?)`, sessdata)
util.SqliteLock.Unlock()
return err
}
// GetSessdata 获取 SESSDATA
func GetSessdata(db *sql.DB) (string, error) {
util.SqliteLock.Lock()
row := db.QueryRow(`SELECT "value" FROM "field" WHERE "name" = "sessdata"`)
util.SqliteLock.Unlock()
var sessdata string
err := row.Scan(&sessdata)
return sessdata, err
}
+253
View File
@@ -0,0 +1,253 @@
package bilibili
import (
"encoding/json"
"bilidown/common"
)
// BaseRes 来自 Bilibili 的接口响应,Message 字段为 msg
type BaseRes struct {
Code int `json:"code"`
Message string `json:"msg"`
Data json.RawMessage `json:"data"`
}
// BaseRes2 来自 Bilibili 的接口响应,Message 字段为 message 而不是 msg
type BaseResV2 struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
// BaseResV3 来自 Bilibili 的接口响应,Message 字段为 message 而不是 msgData 字段为 result
type BaseResV3 struct {
Code int `json:"code"`
Message string `json:"message"`
Result json.RawMessage `json:"result"`
}
func (b *BaseRes) Success() bool {
return b.Code == 0
}
func (b *BaseResV2) Success() bool {
return b.Code == 0
}
func (b *BaseResV3) Success() bool {
return b.Code == 0
}
type QRInfo struct {
URL string `json:"url"`
QrcodeKey string `json:"qrcode_key"`
}
type QRStatus struct {
URL string `json:"string"`
RefreshToken string `json:"refresh_token"`
Code int `json:"code"`
Message string `json:"message"`
}
const (
QR_NO_SCAN = 86101 // 未扫码
QR_NO_CLICK = 86090 // 已扫码未确认
QR_EXPIRES = 86038 // 已过期
QR_SUCCESS = 0 // 已确认登录
)
// Dimension 视频分辨率
type Dimension struct {
Width int `json:"width"`
Height int `json:"height"`
Rotate int `json:"rotate"`
}
// StaffItem 视频人员
type StaffItem struct {
Mid int `json:"mid"`
Title string `json:"title"`
Name string `json:"name"`
Face string `json:"face"`
}
// Page 分集信息
type Page struct {
// 配合 Bvid 用于获取播放地址
Cid int `json:"cid"`
// 当前分集在合集中的序号,从 1 开始
Page int `json:"page"`
// 分集标题
Part string `json:"part"`
// 分集时长
Duration int `json:"duration"`
// 分集分辨率
Dimension `json:"dimension"`
}
// 通过 BVID 获取的视频信息
type VideoInfo struct {
Bvid string `json:"bvid"`
Aid int `json:"aid"`
Pic string `json:"pic"`
Title string `json:"title"`
Pubdate int `json:"pubdate"`
Desc string `json:"desc"`
Owner struct {
Mid int `json:"mid"`
Name string `json:"name"`
Face string `json:"face"`
} `json:"owner"`
Dimension `json:"dimension"`
Staff []StaffItem `json:"staff"`
Pages []Page `json:"pages"`
Duration int `json:"duration"`
Stat struct {
Aid int `json:"aid"`
View int `json:"view"` // 播放数量
Danmaku int `json:"danmaku"` // 弹幕数量
Reply int `json:"reply"` // 评论数量
Favorite int `json:"favorite"` // 收藏数量
Coin int `json:"coin"` // 投币数量
Share int `json:"share"` // 转发数量
NowRank int `json:"now_rank"` // 当前排名
HisRank int `json:"his_rank"` // 历史最高排名
Like int `json:"like"` // 点赞数量
Dislike int `json:"dislike"` // 不喜欢
} `json:"stat"`
UgcSeason struct {
Sections []struct {
Title string `json:"title"`
Episodes []struct {
Title string `json:"title"`
Pages []Page `json:"pages"`
Bvid string `json:"bvid"`
} `json:"episodes"`
} `json:"sections"`
Title string `json:"title"`
} `json:"ugc_season"`
}
// EpisodeInBV 通过 BV 获取的视频信息中的合集信息
type EpisodeInBV struct {
Aid int `json:"aid"`
Bvid string `json:"bvid"`
Cid int `json:"cid"`
Pic string `json:"pic"` // 封面
Dimension `json:"dimension"` // 分辨率
Duration int `json:"duration"` // 时长
EPID int `json:"ep_id"`
LongTitle string `json:"long_title"` // 分集完整标题,比如【法外狂徒张三现身!】
PubTime int `json:"pub_time"` // 发布时间
Title string `json:"title"` // 分集简略标题,比如【1】
}
// Episode 剧集分集信息
type Episode struct {
Aid int `json:"aid"`
Bvid string `json:"bvid"`
Cid int `json:"cid"`
Cover string `json:"cover"` // 封面
Dimension `json:"dimension"` // 分辨率
Duration int `json:"duration"` // 时长
EPID int `json:"ep_id"`
LongTitle string `json:"long_title"` // 分集完整标题,比如【法外狂徒张三现身!】
PubTime int `json:"pub_time"` // 发布时间
Title string `json:"title"` // 分集简略标题,比如【1】
}
// 通过 EPID 获取的视频信息
type SeasonInfo struct {
Actors string `json:"actors"` // 演员名单
Areas []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"areas"` // 地区列表
Cover string `json:"cover"` // 封面
Evaluate string `json:"evaluate"` // 简介
Publish struct {
IsFinish int `json:"is_finish"` // 是否完结
PubTime string `json:"pub_time"` // 发布时间
} `json:"publish"`
SeasonID int `json:"season_id"` // 剧集编号
SeasonTitle string `json:"season_title"` // 剧集标题
Stat struct {
Coins int `json:"coins"` // 投币数量
Danmakus int `json:"danmakus"` // 弹幕数量
Favorite int `json:"favorite"` // 收藏数量
Favorites int `json:"favorites"` // 追剧数量
Likes int `json:"likes"` // 点赞数量
Reply int `json:"reply"` // 评论数量
Share int `json:"share"` // 分享数量
Views int `json:"views"` // 播放数量
} `json:"stat"`
Styles []string `json:"styles"` // 剧集内容类型,例如 [ "短剧", "奇幻", "搞笑" ]
Title string `json:"title"` // 剧集标题
Total int `json:"total"` // 总集数
Episodes []Episode `json:"episodes"` // 分集信息列表
NewEp struct {
Desc string `json:"desc"` // 更新状态文本
IsNew int `json:"is_new"` // 是否是连载,0 为完结,1 为连载
} `json:"new_ep"`
Section []struct {
Title string `json:"title"`
Episodes []Episode `json:"episodes"`
} `json:"section"`
}
type PlayInfo struct {
AcceptDescription []string `json:"accept_description"`
AcceptQuality []common.MediaFormat `json:"accept_quality"`
SupportFormats []struct {
Quality common.MediaFormat `json:"quality"`
Format string `json:"format"`
NewDescription string `json:"new_description"`
Codecs []string `json:"codecs"`
} `json:"support_formats"`
Dash *Dash `json:"dash"`
}
type Dash struct {
// 视频时长(秒)
Duration int `json:"duration"`
Video []Media `json:"video"`
Audio []Media `json:"audio"`
Flac *struct {
Audio Media `json:"audio"`
} `json:"flac"`
}
type Media struct {
ID common.MediaFormat `json:"id"`
BaseURL string `json:"baseUrl"`
BackupURL []string `json:"backupUrl"`
Bandwidth int `json:"bandwidth"`
MimeType string `json:"mimeType"`
Codecs string `json:"codecs"`
Width int `json:"width"`
Height int `json:"height"`
FrameRate string `json:"frameRate"`
Codecid int `json:"codecid"`
}
type FavList []struct {
Title string `json:"title"`
Cover string `json:"cover"`
Intro string `json:"intro"`
Duration int `json:"duration"`
Upper struct {
Mid int `json:"mid"`
Name string `json:"name"`
Face string `json:"face"`
} `json:"upper"`
PubTime int `json:"pubtime"`
Bvid string `json:"bvid"`
Ugc struct {
FirstCid int `json:"first_cid"`
} `json:"ugc"`
}
+228
View File
@@ -0,0 +1,228 @@
package bilibili
import (
"encoding/json"
"errors"
"fmt"
"math/rand"
"strconv"
"strings"
)
// GetBVInfo 根据 BVID 获取视频信息
func (client *BiliClient) GetVideoInfo(bvid string) (*VideoInfo, error) {
if client.SESSDATA == "" {
return nil, errors.New("SESSDATA 不能为空")
}
params := map[string]string{"bvid": bvid}
response, err := client.SimpleGET("https://api.bilibili.com/x/web-interface/wbi/view", params)
if err != nil {
return nil, err
}
body := BaseResV2{}
err = json.NewDecoder(response.Body).Decode(&body)
if err != nil {
return nil, err
}
if body.Code != 0 {
return nil, errors.New(body.Message)
}
bvInfo := VideoInfo{}
err = json.Unmarshal(body.Data, &bvInfo)
if err != nil {
return nil, err
}
return &bvInfo, nil
}
// GetSeasonInfo 根据 EPID 或 SSID 获取剧集信息
func (client *BiliClient) GetSeasonInfo(epid int, ssid int) (*SeasonInfo, error) {
if client.SESSDATA == "" {
return nil, errors.New("SESSDATA 不能为空")
}
params := map[string]string{
"ep_id": strconv.Itoa(epid),
"season_id": strconv.Itoa(ssid),
}
response, err := client.SimpleGET("https://api.bilibili.com/pgc/view/web/season", params)
if err != nil {
return nil, err
}
body := BaseResV3{}
err = json.NewDecoder(response.Body).Decode(&body)
if err != nil {
return nil, err
}
if body.Code != 0 {
return nil, errors.New(body.Message)
}
seasonInfo := SeasonInfo{}
err = json.Unmarshal(body.Result, &seasonInfo)
if err != nil {
return nil, err
}
return &seasonInfo, nil
}
// GetPlayInfo 根据 BVID 和 CID 获取视频播放信息
func (client *BiliClient) GetPlayInfo(bvid string, cid int) (*PlayInfo, error) {
if client.SESSDATA == "" {
return nil, errors.New("SESSDATA 不能为空")
}
params := map[string]string{
"bvid": bvid,
"cid": strconv.Itoa(cid),
"fnval": "4048",
"fnver": "0",
"fourk": "1",
}
response, err := client.SimpleGET("https://api.bilibili.com/x/player/playurl", params)
if err != nil {
return nil, err
}
body := BaseResV2{}
err = json.NewDecoder(response.Body).Decode(&body)
if err != nil {
return nil, err
}
if body.Code != 0 {
return nil, errors.New(body.Message)
}
playInfo := PlayInfo{}
err = json.Unmarshal(body.Data, &playInfo)
if err != nil {
return nil, err
}
return &playInfo, nil
}
func (client *BiliClient) GetPopularVideos() ([]VideoInfo, error) {
if client.SESSDATA == "" {
return nil, errors.New("SESSDATA 不能为空")
}
urls := []string{
"https://api.bilibili.com/x/web-interface/popular",
"https://api.bilibili.com/x/web-interface/popular/precious",
"https://api.bilibili.com/x/web-interface/ranking/v2",
}
response, err := client.SimpleGET(urls[rand.Intn(len(urls))], nil)
if err != nil {
return nil, err
}
defer response.Body.Close()
body := BaseResV2{}
err = json.NewDecoder(response.Body).Decode(&body)
if err != nil {
return nil, err
}
if body.Code != 0 {
return nil, errors.New(body.Message)
}
data := struct {
List []VideoInfo `json:"list"`
}{}
err = json.Unmarshal(body.Data, &data)
if err != nil {
return nil, err
}
return data.List, nil
}
// GetSeasonsArchivesList 获取合集中的第一个视频的 BVID
func (client *BiliClient) GetSeasonsArchivesListFirstBvid(mid int, seasonId int) (string, error) {
url := "https://api.bilibili.com/x/polymer/web-space/seasons_archives_list"
params := map[string]string{
"mid": strconv.Itoa(mid),
"season_id": strconv.Itoa(seasonId),
"page_num": "1",
"page_size": "1",
}
response, err := client.SimpleGET(url, params)
if err != nil {
return "", err
}
defer response.Body.Close()
body := BaseResV2{}
if err = json.NewDecoder(response.Body).Decode(&body); err != nil {
return "", err
}
if body.Code != 0 {
return "", errors.New(body.Message)
}
var data struct {
Archives []struct {
Bvid string `json:"bvid"`
} `json:"archives"`
}
if err = json.Unmarshal(body.Data, &data); err != nil {
return "", nil
}
if len(data.Archives) == 0 {
return "", errors.New("视频列表为空")
}
return data.Archives[0].Bvid, nil
}
func (client *BiliClient) GetFavlist(mediaId int) (*FavList, error) {
if client.SESSDATA == "" {
return nil, errors.New("SESSDATA 不能为空")
}
page := 0
retry := 0
allFavList := FavList{}
for {
favList, hasMore, err := client.GetFavlistByPage(mediaId, page, 40)
if err != nil {
fmt.Println(err.Error())
if retry == 5 || strings.HasPrefix(err.Error(), "body.Code not 0") {
return nil, err
}
retry++
continue
}
allFavList = append(allFavList, *favList...)
if !hasMore {
break
}
page++
}
return &allFavList, nil
}
func (client *BiliClient) GetFavlistByPage(mediaId int, page int, pageSize int) (favlist *FavList, hasMore bool, err error) {
if client.SESSDATA == "" {
return nil, false, errors.New("SESSDATA 不能为空")
}
response, err := client.SimpleGET("https://api.bilibili.com/x/v3/fav/resource/list", map[string]string{
"media_id": strconv.Itoa(mediaId),
"pn": strconv.Itoa(page + 1),
"ps": strconv.Itoa(pageSize),
"order": "mtime",
"type": "0",
"tid": "0",
"platform": "web",
})
if err != nil {
return nil, false, err
}
defer response.Body.Close()
body := BaseResV2{}
if err = json.NewDecoder(response.Body).Decode(&body); err != nil {
return nil, false, err
}
if body.Code != 0 {
return nil, false, fmt.Errorf("body.Code not 0, %s", body.Message)
}
data := struct {
Medias FavList `json:"medias"`
HasMore bool `json:"has_more"`
}{}
if err = json.Unmarshal(body.Data, &data); err != nil {
return nil, false, err
}
return &data.Medias, data.HasMore, nil
}
+87
View File
@@ -0,0 +1,87 @@
package bilibili
import (
"bilidown/util"
"database/sql"
"net/url"
"strconv"
"strings"
"time"
)
var MIXIN_KEY_ENC_TAB = []int{
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
}
// GetWebKey 获取最新可用的 WbiKey,自动刷新缓存
func (client *BiliClient) getWbiKey(db *sql.DB) (wbiKey string, err error) {
wbiKey, err = getWbiKeyFromDB(db)
if err != nil {
return "", err
}
if wbiKey == "" {
// 更新数据库中的 WebKey
wbiKey, err = client.getWbiKeyRemote()
if err != nil {
return "", err
}
if err = saveWbiKey(db, wbiKey); err != nil {
return "", err
}
}
return wbiKey, nil
}
func (client *BiliClient) GetMixinKey(db *sql.DB) (string, error) {
wbiKey, err := client.getWbiKey(db)
if err != nil {
return "", err
}
var result string
for _, index := range MIXIN_KEY_ENC_TAB {
result += string(wbiKey[index])
}
return result[:32], nil
}
func WbiSign(params map[string]string, mixinKey string) url.Values {
values := url.Values{}
for key, value := range params {
values.Set(key, value)
}
wts := strconv.FormatInt(time.Now().Unix(), 10)
values.Set("wts", wts)
encodeStr := strings.ReplaceAll(values.Encode(), "+", "%20") + mixinKey
w_rid := util.MD5Hash(encodeStr)
values.Set("w_rid", w_rid)
return values
}
// GetWbiKey 从数据库中获取 wbiKey,如果数据库中不存在记录或记录过期,则返回空字符串但不返回错误
func getWbiKeyFromDB(db *sql.DB) (wbiKey string, err error) {
fields, err := util.GetFields(db, "wbi_key", "wbi_key_update_at")
if err != nil {
return "", err
}
// 获取上次更新时间的时间戳,单位是秒
updateAt, err := strconv.ParseInt(fields["wbi_key_update_at"], 10, 64)
if err != nil {
return "", nil
}
// 注意 key_update_at 单位是秒,判断上次刷新时间是否超过 1 天
if time.Now().Unix()-updateAt > 24*60*60 {
return "", nil
}
return fields["wbi_key"], nil
}
// SaveWbiKey 保存 imgKey 和 subKey 到数据库
func saveWbiKey(db *sql.DB, wbiKey string) error {
return util.SaveFields(db, [][2]string{
{"wbi_key", wbiKey},
{"wbi_key_update_at", strconv.FormatInt(time.Now().Unix(), 10)},
})
}
+71
View File
@@ -0,0 +1,71 @@
package bilibili
import (
"bilidown/util"
"fmt"
"net/url"
"strings"
"testing"
"time"
)
func TestGetWbiKey(t *testing.T) {
db := util.MustGetDB("../data.db")
defer db.Close()
sessdata, err := GetSessdata(db)
if err != nil {
t.Error(err)
}
client := BiliClient{SESSDATA: sessdata}
mixinKey, err := client.GetMixinKey(db)
if err != nil {
t.Error(err)
}
fmt.Println(mixinKey)
}
func TestTime(t *testing.T) {
fmt.Println(time.Now().Unix())
}
func TestURLEncode(t *testing.T) {
str := url.Values{
"foo": {"one one four"},
"bar": {"五一四"},
"baz": {"1919810"},
}.Encode()
str = strings.ReplaceAll(str, "+", "%20")
fmt.Println(str)
}
func TestWbiSign(t *testing.T) {
db := util.MustGetDB("../data.db")
defer db.Close()
sessdata, err := GetSessdata(db)
if err != nil {
t.Error(err)
}
client := BiliClient{SESSDATA: sessdata}
mixinKey, err := client.GetMixinKey(db)
if err != nil {
t.Error(err)
}
newParams := WbiSign(map[string]string{
"dm_cover_img_str": "QU5HTEUgKEludGVsLCBJbnRlbChSKSBJcmlzKFIpIFhlIEdyYXBoaWNzICgweDAwMDA5QTQ5KSBEaXJlY3QzRDExIHZzXzVfMCBwc181XzAsIEQzRDExKUdvb2dsZSBJbmMuIChJbnRlbC",
"dm_img_inter": `{"ds":[{"t":10,"c":"YmUtcGFnZXItaXRlbSBiZS1wYWdlci1pdGVtLWFjdGl2ZQ","p":[330,110,110],"s":[179,179,358]}],"wh":[5762,6704,10],"of":[318,636,318]}`,
"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}]`,
"dm_img_str": "V2ViR0wgMS4wIChPcGVuR0wgRVMgMi4wIENocm9taXVtKQ",
"keyword": "",
"mid": "596866446",
"order": "pubdate",
"order_avoided": "true",
"platform": "web",
"pn": "3",
"ps": "30",
"tid": "0",
"w_webid": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzcG1faWQiOiIwLjAiLCJidXZpZCI6IjBGRTlGRDMxLTZGQTQtRjU2RC1GQjEwLUI4OTg2ODUyRUZDNzUwMDUyaW5mb2MiLCJ1c2VyX2FnZW50IjoiTW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NCkgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzEzMS4wLjAuMCBTYWZhcmkvNTM3LjM2IEVkZy8xMzEuMC4wLjAiLCJidXZpZF9mcCI6IjY3YzZkNzg2NTgyYzQ2OGYwNjRiZTE5MTk2OWU4ZWE4IiwiYmlsaV90aWNrZXQiOiI4ODA0MDgzYmYzZGQ1NGNkNzI4OWZjZDE0MmIwN2U4ZCIsImNyZWF0ZWRfYXQiOjE3MzI0NzE3MTIsInR0bCI6ODY0MDAsInVybCI6Ii81OTY4NjY0NDYvdmlkZW8_dGlkPTBcdTAwMjZwbj0yXHUwMDI2a2V5d29yZD1cdTAwMjZvcmRlcj1wdWJkYXRlIiwicmVzdWx0Ijoibm9ybWFsIiwiaXNzIjoiZ2FpYSIsImlhdCI6MTczMjQ3MTcxMn0.pTVyhdRcB2VFcXqeYoi3TjnlEFLAXMpNNJK-dW9Gq3vqBL4YldGgZBuGBXXF7Ldwg_vW6TQg6pQCQ7vz357ws2Z9g2-kLIuNmR3j8oMg2zAXAND1q5oJNw0jNnevhLlB8_vOcip0eIJSHRjqbPbNShKLOcSnSfLaiI64EHwjRFAOEPYVw3evLXKB4TFnxSRi1WDSI684TfNrXp0_2yTJvuPheHQmQC1NcUP_P9tqTuRiDy3YfkuR8PlRcQxmKHVs-byObL5WEPqMMQa8b8zidRtzEkbGV7ra8gTgv1HwgpSDi_Y1VNsX2WtNcBPTSwURWc7zREf-RJqruzgcf1ErpA",
"web_location": "1550101",
}, mixinKey)
fmt.Println(newParams.Encode())
}
+14
View File
@@ -0,0 +1,14 @@
package common
import (
"crypto/rand"
"fmt"
)
func RandomString(length int) string {
randomBytes := make([]byte, length)
rand.Read(randomBytes)
return fmt.Sprintf("%x", randomBytes)[:length]
}
type MediaFormat int
+41
View File
@@ -0,0 +1,41 @@
module bilidown
go 1.23.0
require (
github.com/getlantern/systray v1.2.2
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
modernc.org/sqlite v1.33.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
github.com/getlantern/errors v1.0.4 // indirect
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc // indirect
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
go.opentelemetry.io/otel v1.31.0 // indirect
go.opentelemetry.io/otel/metric v1.31.0 // indirect
go.opentelemetry.io/otel/trace v1.31.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect
golang.org/x/sys v0.26.0 // indirect
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect
modernc.org/libc v1.61.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
+164
View File
@@ -0,0 +1,164 @@
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA=
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201/go.mod h1:Y9WZUHEb+mpra02CbQ/QczLUe6f0Dezxaw5DCJlJQGo=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/errors v1.0.1/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/errors v1.0.4 h1:i2iR1M9GKj4WuingpNqJ+XQEw6i6dnAgKAmLj6ZB3X0=
github.com/getlantern/errors v1.0.4/go.mod h1:/Foq8jtSDGP8GOXzAjeslsC4Ar/3kB+UiQH+WyV4pzY=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 h1:NlQedYmPI3pRAXJb+hLVVDGqfvvXGRPV8vp7XOjKAZ0=
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65/go.mod h1:+ZU1h+iOVqWReBpky6d5Y2WL0sF2Llxu+QcxJFs2+OU=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc h1:sue+aeVx7JF5v36H1HfvcGFImLpSD5goj8d+MitovDU=
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc/go.mod h1:D9RWpXy/EFPYxiKUURo2TB8UBosbqkiLhttRrZYtvqM=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 h1:cSrD9ryDfTV2yaur9Qk3rHYD414j3Q1rl7+L0AylxrE=
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770/go.mod h1:GOQsoDnEHl6ZmNIL+5uVo+JWRFWozMEp18Izcb++H+A=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/ops v0.0.0-20220713155959-1315d978fff7/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag=
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534/go.mod h1:ZsLfOY6gKQOTyEcPYNA9ws5/XHZQFroxqCOhHjGcs9Y=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo=
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo=
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY=
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+226
View File
@@ -0,0 +1,226 @@
package main
import (
"database/sql"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"runtime"
"time"
"bilidown/router"
"bilidown/util"
"github.com/getlantern/systray"
_ "modernc.org/sqlite"
)
const (
HTTP_PORT = 8098 // 限定 HTTP 服务器端口
HTTP_HOST = "" // 限定 HTTP 服务器主机
VERSION = "v2.1.1" // 软件版本号,将影响托盘标题显示
)
var urlLocal = fmt.Sprintf("http://127.0.0.1:%d", HTTP_PORT)
var urlLocalUnix = fmt.Sprintf("%s?___%d", urlLocal, time.Now().UnixMilli())
func main() {
checkFFmpeg()
// 启动托盘程序
systray.Run(onReady, nil)
}
func onReady() {
// 设置托盘图标
setIcon()
// 设置托盘标题
setTitle()
// 设置托盘菜单
setMenuItem()
// 初始化数据表
mustInitTables()
// 配置和启动 HTTP 服务器
mustRunServer()
// 调用默认浏览器访问端口
time.Sleep(time.Millisecond * 1000)
openBrowser(urlLocalUnix)
// 保持运行
select {}
}
// checkFFmpeg 检测 ffmpeg 的安装情况,如果未安装则打印提示信息。
func checkFFmpeg() {
if _, err := util.GetFFmpegPath(); err != nil {
fmt.Println("🚨 FFmpeg is missing. Install it from https://www.ffmpeg.org/download.html or place it in ./bin, then restart the application.")
select {}
}
}
// 配置和启动 HTTP 服务器
func mustRunServer() {
// 前端打包文件
http.Handle("/", http.FileServer(http.Dir("static")))
// 后端接口服务
http.Handle("/api/", http.StripPrefix("/api", router.API()))
// 启动 HTTP 服务器
go func() {
err := http.ListenAndServe(fmt.Sprintf("%s:%d", HTTP_HOST, HTTP_PORT), nil)
if err != nil {
log.Fatal("http.ListenAndServe:", err)
}
}()
}
// openBrowser 调用系统默认浏览器打开指定 URL
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
case "darwin":
cmd = exec.Command("open", url)
case "linux":
cmd = exec.Command("xdg-open", url)
default:
log.Printf("openBrowser: %v.", errors.New("unsupported operating system"))
}
if err := cmd.Start(); err != nil {
log.Printf("openBrowser: %v.", err)
}
fmt.Printf("Opened in default browser: %s.\n", url)
}
// setIcon 设置托盘图标
func setIcon() {
var path string
if runtime.GOOS == "windows" {
path = "static/favicon.ico"
} else {
path = "static/favicon-32x32.png"
}
systray.SetIcon(mustReadFile(path))
}
// mustReadFile 返回文件字节内容
func mustReadFile(path string) []byte {
data, err := os.ReadFile(path)
if err != nil {
log.Fatalln("os.ReadFile:", err)
}
return data
}
// setTitle 设置托盘标题和工具提示
func setTitle() {
title := "Bilidown"
tooltip := fmt.Sprintf("%s 视频解析器 %s (port:%d)", title, VERSION, HTTP_PORT)
// only available on Mac and Windows.
systray.SetTooltip(tooltip)
}
// setMenuItem 设置托盘菜单
func setMenuItem() {
openBrowserItemText := fmt.Sprintf("打开主界面 (port:%d)", HTTP_PORT)
openBrowserItem := systray.AddMenuItem(openBrowserItemText, openBrowserItemText)
go func() {
for {
<-openBrowserItem.ClickedCh
openBrowser(urlLocalUnix)
}
}()
aboutItemText := "Github 项目主页"
aboutItem := systray.AddMenuItem(aboutItemText, aboutItemText)
go func() {
for {
<-aboutItem.ClickedCh
openBrowser("https://github.com/iuroc/bilidown")
}
}()
exitItemText := "退出应用"
exitItem := systray.AddMenuItem(exitItemText, exitItemText)
go func() {
<-exitItem.ClickedCh
log.Printf("Bilidown has exited.")
systray.Quit()
}()
}
// mustInitTables 初始化数据表
func mustInitTables() {
db := util.MustGetDB()
defer db.Close()
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS "field" (
"name" TEXT PRIMARY KEY NOT NULL,
"value" TEXT
)`); err != nil {
log.Fatalln("create table field:", err)
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS "log" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"content" TEXT NOT NULL,
"create_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
)`); err != nil {
log.Fatalln("create table log:", err)
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS "task" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"bvid" text NOT NULL,
"cid" integer NOT NULL,
"format" integer NOT NULL,
"title" text NOT NULL,
"owner" text NOT NULL,
"cover" text NOT NULL,
"status" text NOT NULL,
"folder" text NOT NULL,
"duration" integer NOT NULL,
"download_type" text NOT NULL DEFAULT 'merge',
"create_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
)`); err != nil {
log.Fatalln("create table task:", err)
}
if _, err := util.GetCurrentFolder(db); err != nil {
log.Fatalln("util.GetCurrentFolder:", err)
}
if err := initHistoryTask(db); err != nil {
log.Fatalln("initHistoryTask:", err)
}
// 添加可能缺失的列(用于数据库迁移)
if err := addMissingColumns(db); err != nil {
log.Fatalln("addMissingColumns:", err)
}
}
// addMissingColumns 添加可能缺失的列(用于数据库迁移)
func addMissingColumns(db *sql.DB) error {
// 检查download_type列是否存在,如果不存在则添加
// SQLite没有直接的方法检查列是否存在,我们尝试添加列并忽略错误
// 使用事务确保操作原子性
util.SqliteLock.Lock()
_, _ = db.Exec(`ALTER TABLE "task" ADD COLUMN "download_type" TEXT DEFAULT 'merge'`)
// 将现有记录中的NULL值更新为默认值'merge'
_, _ = db.Exec(`UPDATE "task" SET "download_type" = 'merge' WHERE "download_type" IS NULL`)
util.SqliteLock.Unlock()
// 忽略错误,因为列可能已经存在
// SQLite错误码为1表示列已存在
return nil
}
// initHistoryTask 将上一次程序运行时未完成的任务进度全部变为 error
func initHistoryTask(db *sql.DB) error {
util.SqliteLock.Lock()
_, err := db.Exec(`UPDATE "task" SET "status" = 'error' WHERE "status" IN ('waiting', 'running')`)
util.SqliteLock.Unlock()
return err
}
+102
View File
@@ -0,0 +1,102 @@
package router
import (
"encoding/base64"
"net/http"
"bilidown/bilibili"
"bilidown/util"
"github.com/skip2/go-qrcode"
)
func getQRInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
client := bilibili.BiliClient{}
qrInfo, err := client.NewQRInfo()
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
imageData, err := qrcode.Encode(qrInfo.URL, qrcode.Medium, 256)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
base64Str := base64.StdEncoding.EncodeToString(imageData)
util.Res{
Success: true,
Message: "获取成功",
Data: struct {
Key string `json:"key"`
Image string `json:"image"`
}{
Key: qrInfo.QrcodeKey,
Image: "data:image/png;base64," + base64Str,
}}.Write(w)
}
func checkLogin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
db := util.MustGetDB()
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil || sessdata == "" {
util.Res{Success: false, Message: "未登录"}.Write(w)
return
}
client := bilibili.BiliClient{SESSDATA: sessdata}
check, err := client.CheckLogin()
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
if check {
util.Res{Success: true, Message: "登录成功"}.Write(w)
} else {
util.Res{Success: false, Message: "登录失败"}.Write(w)
}
}
// getQRStatus 获取二维码状态
func getQRStatus(w http.ResponseWriter, r *http.Request) {
if r.ParseForm() != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
key := r.FormValue("key")
if key == "" {
util.Res{Success: false, Message: "key 不能为空"}.Write(w)
return
}
client := bilibili.BiliClient{}
qrStatus, sessdata, err := client.GetQRStatus(key)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
if qrStatus.Code != bilibili.QR_SUCCESS {
util.Res{Success: false, Message: qrStatus.Message}.Write(w)
return
}
db := util.MustGetDB()
defer db.Close()
err = bilibili.SaveSessdata(db, sessdata)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Message: "登录成功"}.Write(w)
}
func logout(w http.ResponseWriter, r *http.Request) {
db := util.MustGetDB()
defer db.Close()
err := bilibili.SaveSessdata(db, "")
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Message: "退出成功"}.Write(w)
}
+120
View File
@@ -0,0 +1,120 @@
package router
import (
"encoding/json"
"fmt"
"net/http"
"os"
"bilidown/util"
"bilidown/util/res_error"
)
func API() *http.ServeMux {
router := http.NewServeMux()
router.HandleFunc("/getVideoInfo", getVideoInfo)
router.HandleFunc("/getSeasonInfo", getSeasonInfo)
router.HandleFunc("/getQRInfo", getQRInfo)
router.HandleFunc("/getQRStatus", getQRStatus)
router.HandleFunc("/checkLogin", checkLogin)
router.HandleFunc("/getPlayInfo", getPlayInfo)
router.HandleFunc("/createTask", createTask)
router.HandleFunc("/getActiveTask", getActiveTask)
router.HandleFunc("/getTaskList", getTaskList)
router.HandleFunc("/showFile", showFile)
router.HandleFunc("/getFields", getFields)
router.HandleFunc("/saveFields", saveFields)
router.HandleFunc("/logout", logout)
router.HandleFunc("/quit", quit)
router.HandleFunc("/getPopularVideos", getPopularVideos)
router.HandleFunc("/deleteTask", deleteTask)
router.HandleFunc("/getRedirectedLocation", getRedirectedLocation)
router.HandleFunc("/downloadVideo", downloadVideo)
router.HandleFunc("/getSeasonsArchivesListFirstBvid", getSeasonsArchivesListFirstBvid)
router.HandleFunc("/getFavList", getFavList)
return router
}
func getRedirectedLocation(w http.ResponseWriter, r *http.Request) {
if r.ParseForm() != nil {
res_error.Send(w, res_error.ParamError)
return
}
url := r.FormValue("url")
if !util.IsValidURL(url) {
res_error.Send(w, res_error.URLFormatError)
return
}
if location, err := util.GetRedirectedLocation(url); err != nil {
res_error.Send(w, res_error.NoLocationError)
return
} else {
util.Res{Success: true, Message: "获取成功", Data: location}.Write(w)
return
}
}
func quit(w http.ResponseWriter, r *http.Request) {
util.Res{Success: true, Message: "退出成功"}.Write(w)
go func() {
os.Exit(0)
}()
}
func getFields(w http.ResponseWriter, r *http.Request) {
db := util.MustGetDB()
defer db.Close()
fields, err := util.GetFields(db, util.FieldUtil{}.AllowSelect()...)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Data: fields}.Write(w)
}
func saveFields(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
util.Res{Success: false, Message: "不支持的请求方法"}.Write(w)
return
}
defer r.Body.Close()
var body [][2]string
err := json.NewDecoder(r.Body).Decode(&body)
if err != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
db := util.MustGetDB()
defer db.Close()
fu := util.FieldUtil{}
for _, d := range body {
if !fu.IsAllowUpdate(d[0]) {
util.Res{Success: false, Message: fmt.Sprintf("字段 %s 不允许修改", d[0])}.Write(w)
return
}
if d[0] == "download_folder" {
if _, err := os.Stat(d[1]); os.IsNotExist(err) {
if err := os.MkdirAll(d[1], os.ModePerm); err != nil {
util.Res{Success: false, Message: fmt.Sprintf("目录创建失败:%s", d[1])}.Write(w)
return
}
} else if err != nil {
util.Res{Success: false, Message: fmt.Sprintf("路径设置失败:%v", err)}.Write(w)
return
}
}
}
err = util.SaveFields(db, body)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Message: "保存成功"}.Write(w)
}
+167
View File
@@ -0,0 +1,167 @@
package router
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"runtime"
"strconv"
"bilidown/task"
"bilidown/util"
)
func createTask(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
if r.Method != http.MethodPost {
util.Res{Success: false, Message: "不支持的请求方法"}.Write(w)
return
}
var body []task.TaskInDB
err := json.NewDecoder(r.Body).Decode(&body)
if err != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
db := util.MustGetDB()
defer db.Close()
for _, item := range body {
if !util.CheckBvidFormat(item.Bvid) {
util.Res{Success: false, Message: "bvid 格式错误"}.Write(w)
return
}
if item.Cover == "" || item.Title == "" || item.Owner == "" {
util.Res{Success: false, Message: "参数错误"}.Write(w)
}
if !util.IsValidURL(item.Cover) {
util.Res{Success: false, Message: "封面链接格式错误"}.Write(w)
return
}
if !util.IsValidURL(item.Audio) {
util.Res{Success: false, Message: "音频链接格式错误"}.Write(w)
return
}
if !util.IsValidURL(item.Video) {
util.Res{Success: false, Message: "视频链接格式错误"}.Write(w)
return
}
if !util.IsValidFormatCode(item.Format) {
util.Res{Success: false, Message: "清晰度代码错误"}.Write(w)
return
}
item.Folder, err = util.GetCurrentFolder(db)
item.Status = "waiting"
if err != nil {
util.Res{Success: false, Message: fmt.Sprintf("util.GetCurrentFolder: %v.", err)}.Write(w)
return
}
_task := task.Task{TaskInDB: item}
_task.Title = util.FilterFileName(_task.Title)
err = _task.Create(db)
if err != nil {
util.Res{Success: false, Message: fmt.Sprintf("_task.Create: %v.", err)}.Write(w)
return
}
go _task.Start()
}
util.Res{Success: true, Message: "创建成功"}.Write(w)
}
func getActiveTask(w http.ResponseWriter, r *http.Request) {
util.Res{Success: true, Data: task.GlobalTaskList}.Write(w)
}
func getTaskList(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
db := util.MustGetDB()
defer db.Close()
page, err := strconv.Atoi(r.FormValue("page"))
if err != nil {
page = 0
}
pageSize, err := strconv.Atoi(r.FormValue("pageSize"))
if err != nil {
pageSize = 360
}
tasks, err := task.GetTaskList(db, page, pageSize)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Message: "获取成功", Data: tasks}.Write(w)
}
// showFile 调用 Explorer 查看文件位置
func showFile(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
filePath := r.FormValue("filePath")
var cmd *exec.Cmd
// 根据操作系统选择命令
switch runtime.GOOS {
case "windows":
// Windows 使用 explorer
cmd = exec.Command("explorer", "/select,", filePath)
case "darwin":
// macOS 使用 open
cmd = exec.Command("open", "-R", filePath)
case "linux":
// Linux 使用 xdg-open
cmd = exec.Command("xdg-open", filePath)
default:
util.Res{Success: false, Message: "不支持的操作系统"}.Write(w)
return
}
err := cmd.Start()
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Message: "操作成功"}.Write(w)
}
func deleteTask(w http.ResponseWriter, r *http.Request) {
taskIDStr := r.FormValue("id")
taskID, err := strconv.Atoi(taskIDStr)
if err != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
db := util.MustGetDB()
defer db.Close()
_task, err := task.GetTask(db, taskID)
if err == sql.ErrNoRows {
util.Res{Success: true, Message: "数据库中没有该条记录,所以本次操作被忽略,可以算作成功。"}.Write(w)
return
}
if err != nil {
util.Res{Success: false, Message: fmt.Sprintf("task.GetTask: %v", err)}.Write(w)
return
}
filePath := _task.FilePath()
err = os.Remove(filePath)
if err != nil && !os.IsNotExist(err) {
util.Res{Success: false, Message: fmt.Sprintf("文件删除失败 os.Remove: %v", err)}.Write(w)
return
}
err = task.DeleteTask(db, taskID)
if err != nil {
util.Res{Success: false, Message: fmt.Sprintf("task.DeleteTask: %v", err)}.Write(w)
return
}
util.Res{Success: true, Message: "删除成功"}.Write(w)
}
+182
View File
@@ -0,0 +1,182 @@
package router
import (
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"bilidown/bilibili"
"bilidown/util"
"bilidown/util/res_error"
)
// getVideoInfo 通过 BV 号获取视频信息
func getVideoInfo(w http.ResponseWriter, r *http.Request) {
if r.ParseForm() != nil {
res_error.Send(w, res_error.ParamError)
return
}
bvid := r.FormValue("bvid")
if !util.CheckBvidFormat(bvid) {
res_error.Send(w, res_error.BvidFormatError)
return
}
db := util.MustGetDB()
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil || sessdata == "" {
res_error.Send(w, res_error.NotLogin)
return
}
client := bilibili.BiliClient{SESSDATA: sessdata}
videoInfo, err := client.GetVideoInfo(bvid)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Message: "获取成功", Data: videoInfo}.Write(w)
}
// getSeasonInfo 通过 EP 号或 SS 号获取视频信息
func getSeasonInfo(w http.ResponseWriter, r *http.Request) {
if r.ParseForm() != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
var epid int
epid, err := strconv.Atoi(r.FormValue("epid"))
if r.FormValue("epid") != "" && err != nil {
util.Res{Success: false, Message: "epid 格式错误"}.Write(w)
return
}
var ssid int
if epid == 0 {
ssid, err = strconv.Atoi(r.FormValue("ssid"))
if r.FormValue("ssid") != "" && err != nil {
util.Res{Success: false, Message: "ssid 格式错误"}.Write(w)
return
}
}
db := util.MustGetDB()
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil || sessdata == "" {
res_error.Send(w, res_error.NotLogin)
return
}
client := bilibili.BiliClient{SESSDATA: sessdata}
seasonInfo, err := client.GetSeasonInfo(epid, ssid)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Message: "获取成功", Data: seasonInfo}.Write(w)
}
// getPlayInfo 通过 BVID 和 CID 获取视频播放信息
func getPlayInfo(w http.ResponseWriter, r *http.Request) {
if r.ParseForm() != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
bvid := r.FormValue("bvid")
if !util.CheckBvidFormat(bvid) {
util.Res{Success: false, Message: "bvid 格式错误"}.Write(w)
return
}
cid, err := strconv.Atoi(r.FormValue("cid"))
if err != nil {
util.Res{Success: false, Message: "cid 格式错误"}.Write(w)
return
}
db := util.MustGetDB()
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil || sessdata == "" {
res_error.Send(w, res_error.NotLogin)
return
}
client := bilibili.BiliClient{SESSDATA: sessdata}
playInfo, err := client.GetPlayInfo(bvid, cid)
if err != nil {
util.Res{Success: false, Message: fmt.Sprintf("client.GetPlayInfo: %v", err)}.Write(w)
return
}
util.Res{Success: true, Message: "获取成功", Data: playInfo}.Write(w)
}
func getPopularVideos(w http.ResponseWriter, r *http.Request) {
db := util.MustGetDB()
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil || sessdata == "" {
res_error.Send(w, res_error.NotLogin)
return
}
client := bilibili.BiliClient{SESSDATA: sessdata}
videos, err := client.GetPopularVideos()
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
bvidList := make([]string, 0)
for _, v := range videos {
bvidList = append(bvidList, v.Bvid)
}
util.Res{Success: true, Message: "获取成功", Data: bvidList}.Write(w)
}
var downloadVideo = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Query().Get("path")
safePath := filepath.Clean(path)
safePath = strings.ReplaceAll(safePath, "\\", "/")
http.ServeFile(w, r, safePath)
})
var getSeasonsArchivesListFirstBvid = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var mid int
var seasonId int
var err error
if mid, err = strconv.Atoi(r.URL.Query().Get("mid")); err != nil {
res_error.Send(w, res_error.MidFormatError)
return
}
if seasonId, err = strconv.Atoi(r.URL.Query().Get("seasonId")); err != nil {
res_error.Send(w, res_error.SeasonIdFormatError)
return
}
client := bilibili.BiliClient{}
bvid, err := client.GetSeasonsArchivesListFirstBvid(mid, seasonId)
if err != nil {
res_error.Send(w, fmt.Sprintf("client.GetSeasonsArchivesList: %v", err))
return
}
util.Res{Success: true, Message: "获取成功", Data: bvid}.Write(w)
})
var getFavList = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mediaId, err := strconv.Atoi(r.URL.Query().Get("mediaId"))
if err != nil {
res_error.Send(w, res_error.ParamError)
return
}
db := util.MustGetDB()
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil || sessdata == "" {
res_error.Send(w, res_error.NotLogin)
return
}
client := bilibili.BiliClient{SESSDATA: sessdata}
favList, err := client.GetFavlist(mediaId)
if err != nil {
res_error.Send(w, err.Error())
return
}
util.Res{Success: true, Message: "获取成功", Data: favList}.Write(w)
})
+501
View File
@@ -0,0 +1,501 @@
package task
import (
"bufio"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"bilidown/bilibili"
"bilidown/common"
"bilidown/util"
)
// TaskInitOption 创建任务时需要从 POST 请求获取的参数
type TaskInitOption struct {
Bvid string `json:"bvid"`
Cid int `json:"cid"`
Format common.MediaFormat `json:"format"`
Title string `json:"title"`
Owner string `json:"owner"`
Cover string `json:"cover"`
Status TaskStatus `json:"status"`
Folder string `json:"folder"`
Audio string `json:"audio"`
Video string `json:"video"`
Duration int `json:"duration"`
DownloadType string `json:"downloadType"`
}
// TaskInDB 任务数据库中的数据
type TaskInDB struct {
TaskInitOption
ID int64 `json:"id"`
CreateAt time.Time `json:"createAt"`
}
func (task *TaskInDB) FilePath() string {
ext := ".mp4"
if task.DownloadType == "audio" {
ext = ".m4a"
}
return filepath.Join(task.Folder,
fmt.Sprintf("%s %s%s", task.Title,
strings.Replace(base64.StdEncoding.EncodeToString([]byte(strconv.FormatInt(task.ID, 10))), "=", "", -1),
ext,
),
)
}
// done | waiting | running | error
type TaskStatus string
type Task struct {
TaskInDB
AudioProgress float64 `json:"audioProgress"`
VideoProgress float64 `json:"videoProgress"`
MergeProgress float64 `json:"mergeProgress"`
}
var GlobalTaskList = []*Task{}
var GlobalTaskMux = &sync.Mutex{}
var GlobalDownloadSem = util.NewSemaphore(3)
var GlobalMergeSem = util.NewSemaphore(3)
func (task *Task) Create(db *sql.DB) error {
util.SqliteLock.Lock()
result, err := db.Exec(`INSERT INTO "task" ("bvid", "cid", "format", "title", "owner", "cover", "status", "folder", "duration", "download_type")
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.Bvid,
task.Cid,
task.Format,
task.Title,
task.Owner,
task.Cover,
task.Status,
task.Folder,
task.Duration,
task.DownloadType,
)
util.SqliteLock.Unlock()
if err != nil {
return err
}
task.ID, err = result.LastInsertId()
task.CreateAt = time.Now()
return err
}
// Create 创建任务,并将任务加入全局任务列表
func (task *Task) Start() {
if task.DownloadType == "" {
task.DownloadType = "merge"
}
GlobalTaskMux.Lock()
GlobalTaskList = append(GlobalTaskList, task)
GlobalTaskMux.Unlock()
db := util.MustGetDB()
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil {
task.UpdateStatus(db, "error", fmt.Errorf("bilibili.GetSessdata: %v", err))
return
}
client := &bilibili.BiliClient{SESSDATA: sessdata}
GlobalDownloadSem.Acquire()
task.UpdateStatus(db, "running")
if task.DownloadType == "audio" {
// 仅音频模式:只下载音频,重命名音频文件为输出文件
err = DownloadMedia(client, task.Audio, task, "audio")
if err != nil {
GlobalDownloadSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err))
return
}
GlobalDownloadSem.Release()
outputPath := task.TaskInDB.FilePath()
audioPath := filepath.Join(task.Folder, strconv.FormatInt(task.ID, 10)+".audio")
err = os.Rename(audioPath, outputPath)
if err != nil {
task.UpdateStatus(db, "error", fmt.Errorf("os.Rename: %v", err))
return
}
// 添加元数据
if err := task.addMetadata(outputPath); err != nil {
log.Printf("添加元数据失败 (任务ID: %d): %v", task.ID, err)
}
task.UpdateStatus(db, "done")
return
} else if task.DownloadType == "video" {
// 仅视频模式:只下载视频,重命名视频文件为输出文件
err = DownloadMedia(client, task.Video, task, "video")
if err != nil {
GlobalDownloadSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err))
return
}
GlobalDownloadSem.Release()
outputPath := task.TaskInDB.FilePath()
videoPath := filepath.Join(task.Folder, strconv.FormatInt(task.ID, 10)+".video")
err = os.Rename(videoPath, outputPath)
if err != nil {
task.UpdateStatus(db, "error", fmt.Errorf("os.Rename: %v", err))
return
}
// 添加元数据
if err := task.addMetadata(outputPath); err != nil {
log.Printf("添加元数据失败 (任务ID: %d): %v", task.ID, err)
}
task.UpdateStatus(db, "done")
return
} else {
// 合并模式:下载音频和视频,然后合并
err = DownloadMedia(client, task.Audio, task, "audio")
if err != nil {
GlobalDownloadSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err))
return
}
err = DownloadMedia(client, task.Video, task, "video")
if err != nil {
GlobalDownloadSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err))
return
}
GlobalDownloadSem.Release()
outputPath := task.TaskInDB.FilePath()
videoPath := filepath.Join(task.Folder, strconv.FormatInt(task.ID, 10)+".video")
audioPath := filepath.Join(task.Folder, strconv.FormatInt(task.ID, 10)+".audio")
GlobalMergeSem.Acquire()
err = task.MergeMedia(outputPath, videoPath, audioPath)
if err != nil {
GlobalMergeSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("task.MergeMedia: %v", err))
return
}
err = os.Remove(videoPath)
if err != nil {
GlobalMergeSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("os.Remove: %v", err))
return
}
err = os.Remove(audioPath)
if err != nil {
GlobalMergeSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("os.Remove: %v", err))
return
}
GlobalMergeSem.Release()
// 添加元数据
if err := task.addMetadata(outputPath); err != nil {
log.Printf("添加元数据失败 (任务ID: %d): %v", task.ID, err)
}
task.UpdateStatus(db, "done")
}
}
// 合并音视频
func (task *Task) MergeMedia(outputPath string, inputPaths ...string) error {
inputs := []string{}
for _, path := range inputPaths {
inputs = append(inputs, "-i", path)
}
ffmpegPath, err := util.GetFFmpegPath()
if err != nil {
return err
}
cmd := exec.Command(ffmpegPath, append(inputs, "-c:v", "copy", "-c:a", "copy", "-progress", "pipe:1", "-strict", "-2", outputPath)...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
scanner := bufio.NewScanner(stdout)
progress := newProgressBar(int64(task.Duration))
outTimeRegex := regexp.MustCompile(`out_time_ms=(\d+)`) // 毫秒
for scanner.Scan() {
line := scanner.Text()
match := outTimeRegex.FindStringSubmatch(line)
if len(match) == 2 {
outTime, err := strconv.ParseInt(match[1], 10, 64)
if err != nil {
return err
}
progress.current = outTime / 1000000
task.MergeProgress = progress.percent()
}
}
if err := scanner.Err(); err != nil {
return err
}
if err := cmd.Wait(); err != nil {
return err
}
task.MergeProgress = 1
return nil
}
func GetVideoURL(medias []bilibili.Media, format common.MediaFormat) (string, error) {
for _, code := range []int{12, 7, 13} {
for _, item := range medias {
if item.ID == format && item.Codecid == code {
return item.BaseURL, nil
}
}
}
return "", errors.New("未找到对应视频分辨率格式")
}
func GetAudioURL(dash *bilibili.Dash) string {
if dash.Flac != nil {
return dash.Flac.Audio.BaseURL
}
var maxAudioID common.MediaFormat
var audioURL string
for _, item := range dash.Audio {
if item.ID > maxAudioID {
maxAudioID = item.ID
audioURL = item.BaseURL
}
}
return audioURL
}
func (task *Task) UpdateStatus(db *sql.DB, status TaskStatus, errs ...error) error {
util.SqliteLock.Lock()
_, err := db.Exec(`UPDATE "task" SET "status" = ? WHERE "id" = ?`, status, task.ID)
util.SqliteLock.Unlock()
if err != nil {
return nil
}
for _, err := range errs {
if err != nil {
err = util.CreateLog(db, fmt.Sprintf("Task-%d-Error: %v", task.ID, err))
if err != nil {
log.Fatalln("CreateLog:", err)
}
}
}
task.Status = status
return err
}
func DownloadMedia(client *bilibili.BiliClient, _url string, task *Task, mediaType string) error {
var resp *http.Response
var err error
for i := 0; i < 5; i++ {
resp, err = client.SimpleGET(_url, nil)
if err == nil {
break
}
}
if err != nil {
return err
}
filename := strconv.FormatInt(task.ID, 10) + "." + mediaType
filepath := filepath.Join(task.Folder, filename)
progress := newProgressBar(resp.ContentLength)
file, err := os.Create(filepath)
if err != nil {
return err
}
defer file.Close()
reader := io.TeeReader(resp.Body, file)
buf := make([]byte, 1024)
for {
n, err := reader.Read(buf)
if err != nil && err != io.EOF {
return err
}
if n == 0 {
break
}
progress.add(n)
GlobalTaskMux.Lock()
if mediaType == "video" {
task.VideoProgress = progress.percent()
} else {
task.AudioProgress = progress.percent()
}
GlobalTaskMux.Unlock()
}
return nil
}
type progressBar struct {
total int64
current int64
}
func (p *progressBar) add(n int) {
p.current += int64(n)
}
func (p *progressBar) percent() float64 {
return float64(p.current) / float64(p.total)
}
func newProgressBar(total int64) *progressBar {
return &progressBar{
total: total,
}
}
func GetTaskList(db *sql.DB, page int, pageSize int) ([]TaskInDB, error) {
tasks := []TaskInDB{}
util.SqliteLock.Lock()
rows, err := db.Query(`SELECT
"id", "bvid", "cid", "format", "title",
"owner", "cover", "status", "folder", "duration", "download_type", "create_at"
FROM "task" ORDER BY "id" DESC LIMIT ?, ?`,
page*pageSize, pageSize,
)
util.SqliteLock.Unlock()
if err != nil {
return nil, err
}
createAt := ""
for rows.Next() {
task := TaskInDB{}
err = rows.Scan(
&task.ID,
&task.Bvid,
&task.Cid,
&task.Format,
&task.Title,
&task.Owner,
&task.Cover,
&task.Status,
&task.Folder,
&task.Duration,
&task.DownloadType,
&createAt,
)
if err != nil {
return nil, err
}
task.CreateAt, err = time.Parse("2006-01-02 15:04:05", createAt)
if err != nil {
return nil, err
}
tasks = append(tasks, task)
}
return tasks, nil
}
func DeleteTask(db *sql.DB, taskID int) error {
util.SqliteLock.Lock()
_, err := db.Exec(`DELETE FROM "task" WHERE "id" = ?`, taskID)
util.SqliteLock.Unlock()
return err
}
func GetTask(db *sql.DB, taskID int) (*TaskInDB, error) {
task := TaskInDB{}
createAt := ""
util.SqliteLock.Lock()
err := db.QueryRow(`SELECT
"id", "bvid", "cid", "format", "title",
"owner", "cover", "status", "folder", "duration", "download_type", "create_at"
FROM "task" WHERE "id" = ?`,
taskID,
).Scan(
&task.ID,
&task.Bvid,
&task.Cid,
&task.Format,
&task.Title,
&task.Owner,
&task.Cover,
&task.Status,
&task.Folder,
&task.Duration,
&task.DownloadType,
&createAt,
)
util.SqliteLock.Unlock()
if err != nil {
return nil, err
}
task.CreateAt, err = time.Parse("2006-01-02 15:04:05", createAt)
if err != nil {
return nil, err
}
return &task, nil
}
// addMetadata 使用 ffmpeg 给输出文件添加元数据(description 和 artist
func (task *Task) addMetadata(filePath string) error {
ffmpegPath, err := util.GetFFmpegPath()
if err != nil {
return err
}
desc := task.Bvid
if desc == "" {
desc = ""
}
author := task.Owner
// 临时文件加上 .mp4 扩展名
tempPath := filePath + ".tmp.mp4"
// 使用双引号包裹文件路径,避免特殊字符
cmd := exec.Command(ffmpegPath,
"-i", filePath,
"-metadata", "description="+desc,
"-metadata", "artist="+author,
"-codec", "copy",
"-y",
tempPath,
)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg添加元数据失败: %v, 输出: %s", err, string(output))
}
if err := os.Remove(filePath); err != nil {
return fmt.Errorf("删除原文件失败: %v", err)
}
if err := os.Rename(tempPath, filePath); err != nil {
return fmt.Errorf("重命名临时文件失败: %v", err)
}
return nil
}
+39
View File
@@ -0,0 +1,39 @@
package task_test
import (
"bufio"
"fmt"
"os/exec"
"testing"
)
func TestFFMPEG(t *testing.T) {
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`)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
fmt.Println(">>>", scanner.Text())
}
if err := scanner.Err(); err != nil {
t.Fatal(err)
}
if err := cmd.Wait(); err != nil {
t.Fatal(err)
}
t.Log("done")
}
func TestNum(t *testing.T) {
t.Log(int64(100999) / 1000)
}
+151
View File
@@ -0,0 +1,151 @@
package util
import (
"database/sql"
"errors"
"fmt"
"log"
"os"
"strings"
"sync"
)
func CreateLog(db *sql.DB, content string) error {
SqliteLock.Lock()
_, err := db.Exec(`INSERT INTO "log" ("content") VALUES (?)`, content)
SqliteLock.Unlock()
return err
}
func GetFields(db *sql.DB, names ...string) (map[string]string, error) {
if len(names) == 0 {
return nil, nil
}
placeholders := make([]string, len(names))
for i := 0; i < len(names); i++ {
placeholders[i] = "?"
}
query := fmt.Sprintf(`SELECT "name", "value" FROM "field" WHERE "name" IN (%s)`, strings.Join(placeholders, ","))
values := make([]interface{}, len(names))
for i := 0; i < len(names); i++ {
values[i] = names[i]
}
SqliteLock.Lock()
row, err := db.Query(query, values...)
SqliteLock.Unlock()
if err != nil {
return nil, err
}
defer row.Close()
var name, value string
fields := make(map[string]string)
for row.Next() {
if err := row.Scan(&name, &value); err != nil {
return nil, err
}
fields[name] = value
}
return fields, nil
}
func SaveFields(db *sql.DB, data [][2]string) error {
if len(data) == 0 {
return nil
}
tx, err := db.Begin()
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
stmt, err := tx.Prepare(`INSERT OR REPLACE INTO "field" ("name", "value") VALUES (?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, d := range data {
SqliteLock.Lock()
_, err = stmt.Exec(d[0], d[1])
SqliteLock.Unlock()
if err != nil {
return err
}
}
return nil
}
// GetCurrentFolder 获取数据库中的下载保存路径,如果不存在则将默认路径保存到数据库
func GetCurrentFolder(db *sql.DB) (string, error) {
var folder string
SqliteLock.Lock()
err := db.QueryRow(`SELECT "value" FROM "field" WHERE "name" = 'download_folder'`).Scan(&folder)
SqliteLock.Unlock()
if err != nil && err == sql.ErrNoRows {
folder, err = GetDefaultDownloadFolder()
if err != nil {
return "", err
}
err = os.MkdirAll(folder, os.ModePerm)
if err != nil {
return "", err
}
err = SaveDownloadFolder(db, folder)
if err != nil {
return "", err
}
return folder, nil
}
err = os.MkdirAll(folder, os.ModePerm)
if err != nil {
return "", err
}
return folder, nil
}
// SaveDownloadFolder 保存下载路径,不存在则自动创建
func SaveDownloadFolder(db *sql.DB, downloadFolder string) error {
_, err := os.Stat(downloadFolder)
if err != nil {
if os.IsNotExist(err) {
err = os.MkdirAll(downloadFolder, os.ModePerm)
if err != nil {
return err
}
}
return err
}
SqliteLock.Lock()
_, err = db.Exec(`INSERT OR REPLACE INTO "field" ("name", "value") VALUES ('download_folder', ?)`, downloadFolder)
SqliteLock.Unlock()
return err
}
var SqliteLock sync.Mutex
func MustGetDB(path ...string) *sql.DB {
pathStr := ""
if len(path) == 0 {
pathStr = "./data.db"
} else if len(path) > 1 {
log.Fatalln(errors.New("len(path) <= 1"))
} else {
pathStr = path[0]
}
db, err := sql.Open("sqlite", pathStr)
if err != nil {
log.Fatalln("sql.Open:", err)
}
return db
}
+36
View File
@@ -0,0 +1,36 @@
package util
type FieldUtil struct{}
func (f FieldUtil) AllowSelect() []string {
return []string{
"download_folder",
}
}
func (f FieldUtil) AllowUpdate() []string {
return []string{
"download_folder",
}
}
func (f FieldUtil) IsAllow(allFields []string, names ...string) bool {
allowedFields := make(map[string]struct{})
for _, field := range allFields {
allowedFields[field] = struct{}{}
}
for _, name := range names {
if _, exists := allowedFields[name]; !exists {
return false
}
}
return true
}
func (f FieldUtil) IsAllowSelect(names ...string) bool {
return f.IsAllow(f.AllowSelect(), names...)
}
func (f FieldUtil) IsAllowUpdate(names ...string) bool {
return f.IsAllow(f.AllowUpdate(), names...)
}
+26
View File
@@ -0,0 +1,26 @@
package res_error
import (
"net/http"
"bilidown/util"
)
// Send 发送异常响应
func Send(w http.ResponseWriter, message string) {
util.Res{Message: message, Success: false}.Write(w)
}
const (
BvidFormatError = "错误的 Bvid 格式"
URLFormatError = "错误的 URL 格式"
MidFormatError = "错误的 Mid 格式"
SeasonIdFormatError = "错误的 SeasonId 格式"
ParamError = "参数错误"
MethodNotAllowError = "不允许的请求方式"
NoLocationError = "无重定向目标地址"
FileNotFountError = "文件不存在"
FileTypeNotAllowError = "不允许的文件类型"
SystemError = "系统错误"
NotLogin = "未登录"
)
+23
View File
@@ -0,0 +1,23 @@
package util
import (
"encoding/json"
"net/http"
)
// 统一的 JSON 响应结构
type Res struct {
Success bool `json:"success"`
Message string `json:"message"`
Data any `json:"data"`
}
// 发送响应
func (r Res) Write(w http.ResponseWriter) {
bs, err := json.Marshal(r)
if err != nil {
w.Write([]byte(`{"success":false,"message":"系统错误","data":null}`))
}
w.Header().Set("Content-Type", "application/json")
w.Write(bs)
}
+28
View File
@@ -0,0 +1,28 @@
package util
import "sync"
type Semaphore struct {
ch chan struct{}
wg sync.WaitGroup
}
func NewSemaphore(concurrency int) *Semaphore {
return &Semaphore{
ch: make(chan struct{}, concurrency),
}
}
func (s *Semaphore) Acquire() {
s.ch <- struct{}{}
s.wg.Add(1)
}
func (s *Semaphore) Release() {
<-s.ch
s.wg.Done()
}
func (s *Semaphore) Wait() {
s.wg.Wait()
}
+92
View File
@@ -0,0 +1,92 @@
package util
import (
"crypto/md5"
"encoding/hex"
"errors"
"net/http"
"net/url"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"bilidown/common"
)
func CheckBvidFormat(bvid string) bool {
return regexp.MustCompile("^BV1[a-zA-Z0-9]+").MatchString(bvid)
}
// GetDefaultDownloadFolder 获取默认下载路径
func GetDefaultDownloadFolder() (string, error) {
return filepath.Abs("./download")
}
func IsNumber(str string) bool {
_, err := strconv.Atoi(str)
return err == nil
}
// IsValidURL 判断字符串是否为合法的URL
func IsValidURL(u string) bool {
_, err := url.ParseRequestURI(u)
return err == nil
}
// IsValidFormatCode 判断格式码是否合法
func IsValidFormatCode(format common.MediaFormat) bool {
allowed := []common.MediaFormat{6, 16, 32, 64, 74, 80, 112, 116, 120, 125, 126, 127}
for _, v := range allowed {
if v == format {
return true
}
}
return false
}
// FilterFileName 过滤字符串中的特殊字符,使其允许作为文件名。
func FilterFileName(fileName string) string {
return regexp.MustCompile(`[\\/:*?"<>|\n]`).ReplaceAllString(fileName, "")
}
// GetFFmpegPath 获取可用的 FFmpeg 执行路径。
func GetFFmpegPath() (string, error) {
if err := exec.Command("ffmpeg", "-version").Run(); err == nil {
return "ffmpeg", nil
}
if err := exec.Command("bin/ffmpeg", "-version").Run(); err == nil {
return "bin/ffmpeg", nil
}
return "", errors.New("ffmpeg not found")
}
// GetRedirectedLocation 获取响应头中的 Location,不会自动跟随重定向。
func GetRedirectedLocation(url string) (string, error) {
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
request, err := http.NewRequest("HEAD", url, nil)
if err != nil {
return "", err
}
response, err := client.Do(request)
if err != nil {
return "", err
}
if locationURL, err := response.Location(); err != nil {
return "", err
} else {
return locationURL.String(), nil
}
}
func MD5Hash(str string) string {
hasher := md5.New()
hasher.Write([]byte(str))
hash := hasher.Sum(nil)
hashString := hex.EncodeToString(hash)
return hashString
}
+29
View File
@@ -0,0 +1,29 @@
package util_test
import (
"fmt"
"os"
"testing"
"bilidown/common"
"bilidown/util"
)
func TestRandomString(t *testing.T) {
for i := 4; i < 10; i++ {
for j := 0; j < 3; j++ {
str := common.RandomString(i)
t.Log(str)
}
t.Log("\n")
}
}
func TestGetRedirectedLocation(t *testing.T) {
os.Setenv("https_proxy", "http://192.168.1.5:9000")
if location, err := util.GetRedirectedLocation("https://b23.tv/Ga6sbzT"); err != nil {
t.Error(err)
} else {
fmt.Println(location)
}
}