commit 4facf829565387abe89d026cc3c194f9d5f179e6 Author: yw1573 Date: Thu Apr 2 15:16:34 2026 +0800 init: git clone https://github.com/iuroc/bilidown.git diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4d6531 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +tmp/ +static/ +*.db +*.exe +download/ +build/ +.VSCodeCounter/ +.claude/ +server/bilidown.exe~ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ba38e9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..286f5e7 --- /dev/null +++ b/README.md @@ -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) diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..60bdd41 --- /dev/null +++ b/client/index.html @@ -0,0 +1,15 @@ + + + + + + + Bilidown + + + +
+ + + + \ No newline at end of file diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..8b151d8 --- /dev/null +++ b/client/package.json @@ -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" +} diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml new file mode 100644 index 0000000..aa3ec2a --- /dev/null +++ b/client/pnpm-lock.yaml @@ -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 diff --git a/client/public/favicon-32x32.png b/client/public/favicon-32x32.png new file mode 100644 index 0000000..e1bd590 Binary files /dev/null and b/client/public/favicon-32x32.png differ diff --git a/client/public/favicon.ico b/client/public/favicon.ico new file mode 100644 index 0000000..8218108 Binary files /dev/null and b/client/public/favicon.ico differ diff --git a/client/src/error/index.ts b/client/src/error/index.ts new file mode 100644 index 0000000..692def5 --- /dev/null +++ b/client/src/error/index.ts @@ -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 + } + }, '刷新页面') + ) + ) + }, +}) \ No newline at end of file diff --git a/client/src/header.ts b/client/src/header.ts new file mode 100644 index 0000000..c046715 --- /dev/null +++ b/client/src/header.ts @@ -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' }, '扫码登录') + ), + ) + ) +} \ No newline at end of file diff --git a/client/src/index.ts b/client/src/index.ts new file mode 100644 index 0000000..ad7208b --- /dev/null +++ b/client/src/index.ts @@ -0,0 +1,28 @@ +/// +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() +) diff --git a/client/src/login/data.ts b/client/src/login/data.ts new file mode 100644 index 0000000..e9b944b --- /dev/null +++ b/client/src/login/data.ts @@ -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 +} \ No newline at end of file diff --git a/client/src/login/index.ts b/client/src/login/index.ts new file mode 100644 index 0000000..a46347c --- /dev/null +++ b/client/src/login/index.ts @@ -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) + }, + }) +} \ No newline at end of file diff --git a/client/src/mixin.ts b/client/src/mixin.ts new file mode 100644 index 0000000..ab1b286 --- /dev/null +++ b/client/src/mixin.ts @@ -0,0 +1,69 @@ +import van from 'vanjs-core' +import { goto } from 'vanjs-router' + +export type ResJSON = { + 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 => { + 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 +} \ No newline at end of file diff --git a/client/src/scss/index.scss b/client/src/scss/index.scss new file mode 100644 index 0000000..703ec73 --- /dev/null +++ b/client/src/scss/index.scss @@ -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; +} \ No newline at end of file diff --git a/client/src/setting/data.ts b/client/src/setting/data.ts new file mode 100644 index 0000000..a9fa07b --- /dev/null +++ b/client/src/setting/data.ts @@ -0,0 +1,20 @@ +import { Fields } from '.' +import { ResJSON } from '../mixin' + +export const getFields = async (): Promise => { + const res = await fetch('/api/getFields').then(res => res.json()) as ResJSON + 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 +} \ No newline at end of file diff --git a/client/src/setting/index.ts b/client/src/setting/index.ts new file mode 100644 index 0000000..c6c051b --- /dev/null +++ b/client/src/setting/index.ts @@ -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 + +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(`

软件已关闭

`) + }) + } + }, '关闭软件'), + 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 \ No newline at end of file diff --git a/client/src/setting/view.ts b/client/src/setting/view.ts new file mode 100644 index 0000000..3b8ec68 --- /dev/null +++ b/client/src/setting/view.ts @@ -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) + ) +} \ No newline at end of file diff --git a/client/src/task/data.ts b/client/src/task/data.ts new file mode 100644 index 0000000..a99a252 --- /dev/null +++ b/client/src/task/data.ts @@ -0,0 +1,74 @@ +import { ResJSON } from "../mixin" +import { TaskInDB, TaskStatus, VideoFormat } from "../work/type" + +let getActiveTaskController: AbortController | undefined + +export const getActiveTask = async (): Promise => { + getActiveTaskController?.abort() + getActiveTaskController = new AbortController() + try { + const res = await fetch('/api/getActiveTask', { + signal: getActiveTaskController.signal + }).then(res => res.json()) as ResJSON + 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 => { + 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 + 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) +} \ No newline at end of file diff --git a/client/src/task/index.ts b/client/src/task/index.ts new file mode 100644 index 0000000..e68a897 --- /dev/null +++ b/client/src/task/index.ts @@ -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 + /** 视频下载进度百分比 */ + videoProgress: State + /** 合并进度百分比 */ + mergeProgress: State + /** 任务状态 */ + statusState: State + /** 是否正在打开 */ + opening: State + /** 是否正在删除 */ + deleting: State + })[]> = 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 \ No newline at end of file diff --git a/client/src/task/playerModal.ts b/client/src/task/playerModal.ts new file mode 100644 index 0000000..460b5ef --- /dev/null +++ b/client/src/task/playerModal.ts @@ -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() + } + }, '下载') + ) + ) + ) + ) + } +} \ No newline at end of file diff --git a/client/src/view.ts b/client/src/view.ts new file mode 100644 index 0000000..2b5e783 --- /dev/null +++ b/client/src/view.ts @@ -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...')) +) \ No newline at end of file diff --git a/client/src/work/data.ts b/client/src/work/data.ts new file mode 100644 index 0000000..1308d90 --- /dev/null +++ b/client/src/work/data.ts @@ -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 => { + const { signal, timer } = timeoutController() + + const res = await fetch(`/api/getVideoInfo?bvid=${bvid}`, { + signal + }).then(res => res.json()) as ResJSON + 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 => { + const { signal, timer } = timeoutController() + + const res = await fetch(`/api/getSeasonInfo?epid=${epid}&ssid=${ssid}`, { + signal + }).then(res => res.json()) as ResJSON + + if (!res.success) throw new Error(res.message) + clearTimeout(timer) + return res.data +} + + +export const getPlayInfo = async (bvid: string, cid: number, controller: AbortController): Promise => { + const res = await fetch(`/api/getPlayInfo?bvid=${bvid}&cid=${cid}`, { + signal: controller.signal + }).then(res => res.json()) as ResJSON + if (!res.success) throw new Error(res.message) + return res.data +} + +export const createTask = async (tasks: TaskInitData[]): Promise => { + 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 => { + const res = await fetch('/api/getPopularVideos').then(res => res.json()) as ResJSON + if (!res.success) throw new Error(res.message) + return res.data +} + +export const getRedirectedLocation = async (url: string): Promise => { + return fetch(`/api/getRedirectedLocation?url=${encodeURIComponent(url)}`) + .then(res => res.json()) + .then((data: ResJSON) => { + if (!data.success) throw new Error(data.message) + return data.data + }) +} + +/** 获取收藏夹内视频列表 */ +export const getFavList = async (mediaId: number): Promise => { + return fetch(`/api/getFavList?mediaId=${mediaId}`) + .then(res => res.json()) + .then((body: ResJSON) => { + if (!body.success) throw new Error(body.message) + return body.data + }) +} \ No newline at end of file diff --git a/client/src/work/index.ts b/client/src/work/index.ts new file mode 100644 index 0000000..b42fdad --- /dev/null +++ b/client/src/work/index.ts @@ -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({ + 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 \ No newline at end of file diff --git a/client/src/work/mixin.ts b/client/src/work/mixin.ts new file mode 100644 index 0000000..9e0c88d --- /dev/null +++ b/client/src/work/mixin.ts @@ -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 => { + 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 => { + 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) => { + if (!body.success) throw new Error(body.message) + return body.data + }) + } + return false +} \ No newline at end of file diff --git a/client/src/work/type.ts b/client/src/work/type.ts new file mode 100644 index 0000000..2ee88d0 --- /dev/null +++ b/client/src/work/type.ts @@ -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 +} + +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 = { + 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 + } +} \ No newline at end of file diff --git a/client/src/work/view/inputBox.ts b/client/src/work/view/inputBox.ts new file mode 100644 index 0000000..3bcb4a3 --- /dev/null +++ b/client/src/work/view/inputBox.ts @@ -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 \ No newline at end of file diff --git a/client/src/work/view/parseModal.ts b/client/src/work/view/parseModal.ts new file mode 100644 index 0000000..723f852 --- /dev/null +++ b/client/src/work/view/parseModal.ts @@ -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 = { + 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 + finishCount = van.state(0) + + abortControllers: AbortController[] = [] + + currentController?: AbortController + + allPlayInfo: State<{ + page: PageInParseResult + info: PlayInfo | null + selected: State + formatIndex: State + }[]> = 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) + /** 是否优先使用HiRes(flac)音频 */ + preferHiResAudio = van.state(true) + + errorList: State = 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('未找到对应视频分辨率格式') +} diff --git a/client/src/work/view/videoInfoCard.ts b/client/src/work/view/videoInfoCard.ts new file mode 100644 index 0000000..acc401c --- /dev/null +++ b/client/src/work/view/videoInfoCard.ts @@ -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, value: State, option?: { + disabled?: Val + 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 \ No newline at end of file diff --git a/client/src/work/view/videoItemList.ts b/client/src/work/view/videoItemList.ts new file mode 100644 index 0000000..99fcdbb --- /dev/null +++ b/client/src/work/view/videoItemList.ts @@ -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) => { + 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) => { + 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 \ No newline at end of file diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..fddd3c5 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "strict": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ESNext" + } +} \ No newline at end of file diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 0000000..8c160c7 --- /dev/null +++ b/client/vite.config.ts @@ -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' + } + } + } +}) \ No newline at end of file diff --git a/docs/2024-11-05_090604.png b/docs/2024-11-05_090604.png new file mode 100644 index 0000000..202184b Binary files /dev/null and b/docs/2024-11-05_090604.png differ diff --git a/server/.air.toml b/server/.air.toml new file mode 100644 index 0000000..efac046 --- /dev/null +++ b/server/.air.toml @@ -0,0 +1,3 @@ +[build] +exclude_dir = ["download", "bin"] +include_ext = ["go"] \ No newline at end of file diff --git a/server/.goreleaser.yaml b/server/.goreleaser.yaml new file mode 100644 index 0000000..31b9617 --- /dev/null +++ b/server/.goreleaser.yaml @@ -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:" diff --git a/server/bilibili/bilibili_test.go b/server/bilibili/bilibili_test.go new file mode 100644 index 0000000..181c3cd --- /dev/null +++ b/server/bilibili/bilibili_test.go @@ -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) +} diff --git a/server/bilibili/client.go b/server/bilibili/client.go new file mode 100644 index 0000000..fa4621f --- /dev/null +++ b/server/bilibili/client.go @@ -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 +} diff --git a/server/bilibili/type.go b/server/bilibili/type.go new file mode 100644 index 0000000..b0bd78b --- /dev/null +++ b/server/bilibili/type.go @@ -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 而不是 msg,Data 字段为 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"` +} diff --git a/server/bilibili/video.go b/server/bilibili/video.go new file mode 100644 index 0000000..f12c54b --- /dev/null +++ b/server/bilibili/video.go @@ -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 +} diff --git a/server/bilibili/wbi.go b/server/bilibili/wbi.go new file mode 100644 index 0000000..2420899 --- /dev/null +++ b/server/bilibili/wbi.go @@ -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)}, + }) +} diff --git a/server/bilibili/wbi_test.go b/server/bilibili/wbi_test.go new file mode 100644 index 0000000..220a7a9 --- /dev/null +++ b/server/bilibili/wbi_test.go @@ -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()) +} diff --git a/server/common/common.go b/server/common/common.go new file mode 100644 index 0000000..07c6f2a --- /dev/null +++ b/server/common/common.go @@ -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 diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..ae7fc76 --- /dev/null +++ b/server/go.mod @@ -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 +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..c2a63ac --- /dev/null +++ b/server/go.sum @@ -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= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..e3aaf2e --- /dev/null +++ b/server/main.go @@ -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 +} diff --git a/server/router/login.go b/server/router/login.go new file mode 100644 index 0000000..d819998 --- /dev/null +++ b/server/router/login.go @@ -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) +} diff --git a/server/router/router.go b/server/router/router.go new file mode 100644 index 0000000..c9f74bc --- /dev/null +++ b/server/router/router.go @@ -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) +} diff --git a/server/router/task.go b/server/router/task.go new file mode 100644 index 0000000..9c8d767 --- /dev/null +++ b/server/router/task.go @@ -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) +} diff --git a/server/router/video.go b/server/router/video.go new file mode 100644 index 0000000..1a6a556 --- /dev/null +++ b/server/router/video.go @@ -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) +}) diff --git a/server/task/task.go b/server/task/task.go new file mode 100644 index 0000000..be0743e --- /dev/null +++ b/server/task/task.go @@ -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 +} \ No newline at end of file diff --git a/server/task/task_test.go b/server/task/task_test.go new file mode 100644 index 0000000..01bb6fc --- /dev/null +++ b/server/task/task_test.go @@ -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) +} diff --git a/server/util/db.go b/server/util/db.go new file mode 100644 index 0000000..71a6516 --- /dev/null +++ b/server/util/db.go @@ -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 +} diff --git a/server/util/field.go b/server/util/field.go new file mode 100644 index 0000000..e11b9d0 --- /dev/null +++ b/server/util/field.go @@ -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...) +} diff --git a/server/util/res_error/res_error.go b/server/util/res_error/res_error.go new file mode 100644 index 0000000..305c481 --- /dev/null +++ b/server/util/res_error/res_error.go @@ -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 = "未登录" +) diff --git a/server/util/response.go b/server/util/response.go new file mode 100644 index 0000000..fa0352f --- /dev/null +++ b/server/util/response.go @@ -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) +} diff --git a/server/util/semaphore.go b/server/util/semaphore.go new file mode 100644 index 0000000..2b92b3c --- /dev/null +++ b/server/util/semaphore.go @@ -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() +} diff --git a/server/util/util.go b/server/util/util.go new file mode 100644 index 0000000..8e1329a --- /dev/null +++ b/server/util/util.go @@ -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 +} diff --git a/server/util/util_test.go b/server/util/util_test.go new file mode 100644 index 0000000..84c9798 --- /dev/null +++ b/server/util/util_test.go @@ -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) + } +}