Compare commits

...

157 Commits

Author SHA1 Message Date
xiaoyaofenfen d05d19acd8 Merge pull request #548 from qinweixian/patch-1
修复后台上传脚本bug
2026-09-07 09:29:06 +08:00
秦伟先 5179f914ed 修复后台上传脚本bug 2026-09-03 10:42:52 +08:00
tickstep 2feaa3cf82 update readme file 2026-07-05 09:42:32 +08:00
tickstep studio team cedd43d402 update version to v0.4.0 2026-06-29 10:42:16 +08:00
tickstep studio team e62bdbb9f1 refractor OSS url parser 2026-06-29 10:27:00 +08:00
tickstep studio team acc408f0a0 fix download url expired calc error issue #544 #543 #542 2026-06-29 09:29:05 +08:00
tickstep studio team db28ea6732 update version to v0.3.9 2026-04-20 19:41:14 +08:00
tickstep studio team a9bc1076a5 update aliyunpan-api to v0.2.9 2026-04-20 19:12:16 +08:00
tickstep e20ea479c4 fix update config file error 2026-04-12 08:32:26 +08:00
tickstep studio team 42f044ba38 update readme file 2026-03-02 11:17:31 +08:00
tickstep studio team 88f1ff2688 update manual file 2026-03-02 11:05:41 +08:00
feny ca09039e48 fix the issue that -ui print output flickering 2026-02-18 08:24:10 +08:00
tickstep studio team cf38ad51fd add -ui option for upload 2026-02-13 17:27:50 +08:00
tickstep studio team 30cbced50c rename download_dashboard to dashboard_panel 2026-02-13 17:23:09 +08:00
tickstep studio team 0946939325 update manual file 2026-02-12 11:03:58 +08:00
tickstep studio team da13b046b8 update readme file 2026-02-12 10:56:37 +08:00
tickstep studio team 268e4a30d1 update readme file 2026-02-12 10:51:48 +08:00
tickstep studio team a0ca0f8089 update readme file 2026-02-12 10:44:34 +08:00
tickstep studio team 2d99a39606 Merge branch 'main' of github.com:tickstep/aliyunpan 2026-02-12 10:35:25 +08:00
tickstep 2965179ae8 update version to v0.3.8 2026-02-11 20:23:40 +08:00
tickstep 0565482121 update golang.org/x/sys to v0.15.0 2026-02-11 20:21:14 +08:00
tickstep 1822d3c2b2 Merge branch 'main' of github.com:tickstep/aliyunpan 2026-02-11 19:33:53 +08:00
tickstep 6672aa4cd4 update aliyunpan-api to v0.2.8 2026-02-11 19:33:37 +08:00
tickstep studio team c69a27c9b1 add verbose log for upload 2026-02-10 15:34:07 +08:00
tickstep studio team 4feacbcdbf fix login get ip error issue 2026-02-10 15:33:49 +08:00
tickstep studio team 1916dda8f8 fix update issue 2026-02-10 14:52:34 +08:00
tickstep studio team 8b5eb14eb6 add more log to track download url expired issue #501 2026-02-05 20:47:19 +08:00
tickstep studio team d3d9690d55 beautify download ui dashboard 2026-02-05 20:14:56 +08:00
tickstep studio team 89a6ef326c beautify download ui dashboard 2026-02-05 17:31:21 +08:00
tickstep studio team 35dfb35429 handle file already closed error for upload #509 #512 2026-02-04 17:46:11 +08:00
tickstep studio team 3c7ebb5f62 optimize rm command, faster remove file without wildcard #530 2026-02-04 11:00:38 +08:00
tickstep f796f40664 optimize update logic 2026-02-03 21:25:21 +08:00
tickstep studio team 0d9700c84c fix the error in the total download size statistics 2026-02-03 16:41:40 +08:00
tickstep studio team 09ee162ba4 add slice parallel option for downoad #529 2026-02-03 16:30:36 +08:00
tickstep studio team ff0e3dbfa9 optimize downoad ui dashboard 2026-02-03 15:52:06 +08:00
tickstep studio team 4c5f45e5ab delete useless code 2026-02-03 15:02:20 +08:00
tickstep studio team 02326b0bd7 fix terminal abnormalities 2026-02-03 14:40:04 +08:00
tickstep 350fec4275 add -ui option for download 2026-02-03 11:23:57 +08:00
xiaoyaofenfen 70a14dd1a6 Merge pull request #525 from SpookySandwich/main
【功能建议】改善下载界面
2026-02-02 18:30:37 +08:00
CharliePu 23dad72914 improve download tui 2026-01-06 17:50:41 +08:00
xiaoyaofenfen 9fa547ddc8 Merge pull request #520 from sikaisky/main
fix uploadEntity.PartInfoList len 0 error
2025-11-25 14:54:25 +08:00
sikai 2898037ec5 fix uploadEntity.PartInfoList len 0 error 2025-11-09 10:26:19 +08:00
tickstep 70c14fd66f open file with readonly mode 2025-08-11 11:17:20 +08:00
tickstep 8913597716 optimize login routine logic 2025-08-07 17:43:14 +08:00
tickstep 9b276f2caf optimize token refresh task 2025-08-06 15:34:36 +08:00
tickstep e8bda159fa handle api common error 2025-08-05 23:08:24 +08:00
tickstep e1ac6a95b9 fix share url error 2025-04-15 21:47:00 +08:00
tickstep cef9c6c7c2 update version to v0.3.7 2025-03-23 20:51:06 +08:00
tickstep 17e8e182ee fix word spell error 2025-03-23 20:33:03 +08:00
tickstep 4b9ded5b9e add resource dirive id for JS plugin context 2025-03-23 20:22:37 +08:00
tickstep f939f515f4 add download JS plugin sample #40 2025-03-23 20:03:57 +08:00
tickstep 0e0486914c sort by file name for download file list 2025-03-23 14:18:04 +08:00
tickstep a86a464e67 add downloadActionId for download JS plugin 2025-03-22 15:59:47 +08:00
tickstep c932e3a231 add md5 hash function for JS plugin 2025-03-22 15:32:10 +08:00
tickstep f79645bfa7 add friendly prompt for not found file path 2025-03-22 15:17:47 +08:00
tickstep 7786dc6c40 add file path auto complete for local command 2025-03-21 21:33:10 +08:00
tickstep d7b4d0c846 support local file path auto completer for lls / lcd command 2025-03-19 21:31:00 +08:00
tickstep e669063be9 fix path completer empty match issue 2025-03-19 17:33:55 +08:00
tickstep e63ba4478c refactor pan file path auto completer function 2025-03-19 17:22:04 +08:00
tickstep 23617d810b add prompt for adrive third vip #493 2025-03-19 15:31:39 +08:00
tickstep 3d87c6244f fix local windows path recognize error 2025-03-08 10:14:12 +08:00
tickstep 4f7941caf5 adapt local file path for windows 2025-03-07 23:22:17 +08:00
tickstep 095d06d99c add sort option for lls command 2025-03-07 22:49:54 +08:00
tickstep 7fbe082e58 add lcd lpwd lls command #98 2025-03-07 16:19:54 +08:00
tickstep 34a6242bde update manual 2025-03-07 10:50:01 +08:00
tickstep 71df114d8a tidy the accept complete file name list config 2025-03-07 10:03:52 +08:00
tickstep 622984ee26 update manual 2025-03-06 11:20:02 +08:00
tickstep 04ec8658e4 update plugin manual 2025-03-06 11:15:16 +08:00
tickstep 6f0d03bd58 add javascript plugin manual 2025-03-06 11:06:50 +08:00
tickstep 94c24ebf16 add sample code for js plugin 2025-03-05 20:47:56 +08:00
tickstep fb07969bb1 append PanFS.deleteFile() usage to manual 2025-03-05 20:14:20 +08:00
tickstep 9f83bad7d6 add a println function for plugin to print log text in console windows 2025-03-05 19:56:02 +08:00
tickstep 91b1e724fa add DeletePanFile plugin function #489 2025-03-05 17:33:57 +08:00
tickstep 787d3e1099 add issue template 2025-03-03 15:15:23 +08:00
tickstep 87f951b5f5 add remove file plugin callback #70 2025-03-03 11:46:08 +08:00
tickstep fdafcaed42 add sync task done plugin callback #304 2025-03-01 08:31:15 +08:00
tickstep 9bef162dad revision comments 2025-02-28 14:53:46 +08:00
tickstep 6b638d7817 fix NoSuchUpload error 2025-02-27 16:21:20 +08:00
tickstep ce6d0266bf fix upload database read error #263 2025-02-27 15:45:55 +08:00
tickstep acdc2c93ee fix uploaded file size statistics error 2025-02-27 15:17:08 +08:00
tickstep e9702ea449 update docker file 2025-02-26 11:16:41 +08:00
tickstep 212a8ef764 update dockerfile 2025-02-26 10:39:32 +08:00
tickstep 057362d5a9 update version to v0.3.6 2025-02-25 21:51:40 +08:00
tickstep ae3590f84a add javascript plugin sample to manual file 2025-02-25 10:01:59 +08:00
tickstep 770cd0356f update aliyunpan-api to v0.2.6 2025-02-24 22:05:24 +08:00
tickstep 12bca5683c fix upload file occurs PartNotSequential error #487 #486 #466 2025-02-24 15:54:03 +08:00
tickstep 68f4c44a55 add more logs for upload 2025-02-23 09:06:18 +08:00
tickstep 3aae75a46e fix upload part num issue 2025-02-22 23:08:21 +08:00
tickstep 44e7402b27 fix part num error 2025-02-22 17:44:34 +08:00
tickstep 499b08dd72 append album document 2025-02-18 14:59:18 +08:00
tickstep 29ba6903e6 show target drive name for upload file 2025-02-18 14:44:34 +08:00
tickstep 6d1687e519 add album command 2025-02-18 14:10:13 +08:00
tickstep d8d00600d8 add album permission for login 2025-02-18 14:08:27 +08:00
tickstep 304c4d4f93 adapt album new api 2025-02-14 16:03:35 +08:00
tickstep 4c75a3d6ee fix version string parse error #484 2025-02-05 09:45:56 +08:00
tickstep 34d58d3ebc update version to v0.3.5 2025-01-05 23:06:35 +08:00
tickstep 8410f5977c update copyright tag 2025-01-05 22:26:20 +08:00
tickstep 9730bb8a23 upgrade aliyunpan-api 2025-01-05 22:25:01 +08:00
tickstep 6ed0e6bae0 refactor upload command 2025-01-04 10:57:21 +08:00
tickstep 2b283b727e update copyright tag 2025-01-01 09:27:50 +08:00
tickstep f3d352800b adapt fast share api 2024-12-03 14:04:00 +08:00
tickstep 2eaba0c3c8 add fast share create command 2024-12-03 11:15:38 +08:00
tickstep 4a59f246f2 show error for user that file is too big to upload #469 #460 2024-11-10 13:31:35 +08:00
xiaoyaofenfen d45ef8c1b9 Merge pull request #455 from cuishuang/main
Remove duplicate and redundant code
2024-10-29 13:49:09 +08:00
tickstep ed26496bb7 add share command using openapi 2024-10-29 11:34:58 +08:00
tickstep 1e2bcee3e7 refactor share to share_web 2024-10-26 14:53:13 +08:00
cuishuang 1415e81eec Remove duplicate and redundant code
Signed-off-by: cuishuang <imcusg@gmail.com>
2024-09-27 10:47:22 +08:00
tickstep 7ebaf6ec17 update Dockerfile 2024-09-21 18:50:59 +08:00
tickstep 1e205b52a3 update version v0.3.4 2024-09-21 15:50:41 +08:00
tickstep 790056a488 add comments for upload 2024-09-21 15:49:22 +08:00
tickstep 68c89ec317 using aliyunpan api v0.2.4 & upgrade go to 1.22 2024-09-21 15:21:40 +08:00
tickstep 2849dbb472 add default ip type as ipv4 for docker 2024-09-21 14:13:56 +08:00
tickstep 68437a7a4c update docker file 2024-09-20 11:04:21 +08:00
tickstep 05f695a882 add scan interval time for sync command 2024-09-20 10:53:35 +08:00
tickstep fbfa313c1e add backgroup usage for donwload & upload command 2024-09-09 16:52:03 +08:00
tickstep ff97c172b6 show ips for sync docker 2024-09-04 15:40:31 +08:00
tickstep 47baab44a7 fix file upload PartNotSequential error #445 2024-08-30 11:49:43 +08:00
tickstep 0b5bd60702 add env command 2024-08-18 17:03:10 +08:00
tickstep 6f45a8048d add left time for upload 2024-08-16 22:57:15 +08:00
tickstep 7132342ed3 fix build error with go1.22 2024-08-12 20:51:44 +08:00
tickstep d85758b8b0 update version v0.3.3 2024-08-11 16:57:13 +08:00
tickstep 76e657db74 fix login error 2024-08-11 16:26:20 +08:00
tickstep 7870077565 add multi user download feature usage 2024-08-11 16:20:56 +08:00
tickstep 0a52136c2c update aliyunpan-api to v0.2.2 2024-08-11 14:51:50 +08:00
tickstep 210747fe14 add XDG prompt 2024-08-11 14:21:51 +08:00
tickstep 1f3131f718 remove useless prompt 2024-08-11 14:09:22 +08:00
tickstep 630866798b add multi user download option for download command 2024-08-10 08:16:02 +08:00
tickstep f56d103849 add multi user download option for download command 2024-08-09 21:26:50 +08:00
tickstep 97ee446b32 add multi user download option for download command 2024-08-09 21:20:40 +08:00
tickstep c05148df16 fix download exceed max concurrency limit 2024-08-09 12:57:46 +08:00
tickstep 7dfd89c29d fix download occurs zero speed issue #444 2024-08-09 10:17:02 +08:00
tickstep 0d823de18f make download parallel rule meets openapi limits 2024-08-09 09:43:49 +08:00
feny 605380ce70 show third party vip info for who command 2024-08-07 09:09:43 +08:00
tickstep e1428f2097 adapt XDG folder #433 2024-07-03 21:42:48 +08:00
tickstep b368060ffb update version v0.3.2 2024-05-01 07:39:06 +08:00
tickstep 504d38db90 remove none support commands 2024-04-27 22:18:38 +08:00
tickstep 4a0ad141dc remove none OpenAPI commands 2024-04-27 19:13:59 +08:00
tickstep a7a386e9a6 update readme file 2024-04-26 10:54:03 +08:00
tickstep 6506acba9a update readme 2024-04-23 10:12:52 +08:00
tickstep 1e6004d367 update document 2024-04-22 16:38:24 +08:00
tickstep 9796673138 update version to v0.3.1 2024-04-21 19:22:51 +08:00
tickstep b506bcc84d fix x-retry-after issue #408 2024-04-21 18:46:34 +08:00
tickstep 989abc71ee add run cycle mode config for docker 2024-04-21 18:43:01 +08:00
tickstep ebed4ff62f complete one time cycle mode 2024-04-21 18:37:46 +08:00
tickstep 465d8fa4e9 add one time backup for sync #414 2024-04-21 11:32:49 +08:00
tickstep e9ae974017 set local address binding match prefer ip type 2024-04-08 19:36:32 +08:00
tickstep 54dd4e766b fix switch user error 2024-04-02 09:42:08 +08:00
tickstep 4727b9696d fix token plugin param error 2024-04-01 23:25:34 +08:00
tickstep 4769d17281 set webapi optional 2024-04-01 22:46:24 +08:00
tickstep 03f3d37fb8 update readme file 2024-03-26 19:23:47 +08:00
tickstep d1a577810c support address name for local address binding #402 2024-03-26 19:09:58 +08:00
tickstep 670b5c5dad update readme file 2024-03-25 14:48:03 +08:00
tickstep 9d084610e5 complete backup policy for sync commnad 2024-03-20 21:29:59 +08:00
tickstep 9962e63c81 add sync policy for download&upload mode 2024-03-20 18:04:30 +08:00
tickstep f7b8d26964 add time format method 2024-03-20 10:03:01 +08:00
tickstep 280421fd88 fix macOS arm64 version update check error 2024-03-17 19:48:09 +08:00
tickstep 02b6d529d0 update readme file 2024-03-17 19:32:48 +08:00
82 changed files with 6623 additions and 2337 deletions
+31
View File
@@ -0,0 +1,31 @@
---
name: 问题反馈
about: 如果你在使用过程中发现问题,请使用该模板进行反馈。
labels: Bug
---
### Ⅰ. 使用环境
1. 操作系统:(Linux? macOS? Windows?)
2. aliyunpan版本号:(v0.3.6? v0.3.7?...)
### Ⅱ. 问题描述
### Ⅲ. 期望的结果
### Ⅳ. 如何复现问题
<!--
如果该问题是必现的,请帮忙整理复现步骤。
如果不是必现的,请尽可能说明问题出现之前你的操作步骤,越详细越好。
-->
1. xxx
2. xxx
3. xxx
### Ⅴ. 请提供相关的错误日志
<!-- 最好能提供debug日志。如何开启debug日志请参考README文档。 -->
```log
(日志请贴在这里。如果日志很多,可以以附件的形式上传)
```
@@ -0,0 +1,15 @@
---
name: 提新需求
about: 如果你想提出一个新需求,新功能建议,请使用此模板。
labels: Feature Request
---
### Ⅰ. 你当前使用环境
1. 操作系统:(Linux? macOS? Windows?)
2. aliyunpan版本号:(v0.3.6? v0.3.7?...)
### Ⅱ. 请描述你想要的新功能
<!-- 请简单描述你希望的新功能,例如:"upload上传命令添加一个xxx参数,用于实现xxx功能。" -->
### Ⅲ. 请描述新功能的使用场景
<!-- 可以讲讲你对新功能的看法,为什么需要增加这个功能,这个新功能在哪些场景下会使用。 -->
+70 -21
View File
@@ -4,13 +4,19 @@
# 特色
1. 多平台支持, 支持 Windows, macOS, linux(x86/x64/arm), android, iOS 等
2. 阿里云盘多用户支持
3. 支持同步盘,资源库无缝切换
3. 支持备份盘,资源库无缝切换
4. [下载](docs/manual.md#下载文件目录)网盘内文件, 支持多个文件或目录下载, 支持断点续传和单文件并行下载。支持软链接(符号链接)文件。
5. [上传](docs/manual.md#上传文件目录)本地文件, 支持多个文件或目录上传,支持排除指定文件夹/文件(正则表达式)功能。支持软链接(符号链接)文件。
6. [同步备份功能](docs/manual.md#同步备份功能)支持备份本地文件到云盘,备份云盘文件到本地,双向同步备份保持本地文件和网盘文件同步。常用于嵌入式或者NAS等设备,支持docker镜像部署。
7. 命令和文件路径输入支持Tab键自动补全,路径支持通配符匹配模式
8. 支持[JavaScript插件](docs/manual.md#JavaScript插件),你可以按照自己的需要定制上传/下载中关键步骤的行为,最大程度满足自己的个性化需求
9. 支持个人相册的相关操作,支持批量下载相册所有照片、视频文件到本地
8. 支持[JavaScript插件](docs/plugin_manual.md#简介),你可以按照自己的需要定制上传/下载中关键步骤的行为,最大程度满足自己的个性化需求
9. 支持共享相册的相关操作,支持批量下载相册所有普通照片、实况照片文件到本地
10. 支持多用户联合下载功能,对下载速度有极致追求的用户可以尝试使用该选项。详情请查看文档[多用户联合下载](docs/manual.md#多用户联合下载)
# 阿里云盘VIP会员推荐码
如果大家有打算开通阿里云盘VIP会员,可以使用阿里云盘APP扫描下面的优惠推荐码进行开通。
注意:您需要开通【三方应用权益包】,这样使用本程序下载才能加速,否则下载无法提速。
![](./assets/images/aliyunpan_cps_qrcode.png)
# 目录
- [关于](#关于)
@@ -53,15 +59,16 @@
2. arm64 : 适用64位ARM系统
3. 386 / x86 : 适用32系统,包括Intel和AMD的CPU系统
4. amd64 / x64 : 适用64位系统,包括Intel和AMD的CPU系统
5. mips : 适用MIPS指令集的CPU,例如国产龙芯CPU
6. macOS amd64适用Intel CPU的机器,macOS arm64目前主要是适用苹果M1芯片的机器
5. mips : 适用MIPS指令集的CPU,例如中标麒麟的系统。MIPS要注意大小端字序的不同对应的版本也不同。
6. macOS amd64适用Intel CPU的机器,macOS arm64目前主要是适用苹果M系列芯片的机器
7. iOS arm64适用iPhone手机,并且必须是越狱的手机才能正常运行
8. loong64 : 适用于LoongArch64架构的机器,例如国产龙芯CPU
参考例子:
```shell
wget https://github.com/tickstep/aliyunpan/releases/download/v0.3.0/aliyunpan-v0.3.0-linux-amd64.zip
unzip aliyunpan-v0.3.0-linux-amd64.zip
cd aliyunpan-v0.3.0-linux-amd64
wget https://github.com/tickstep/aliyunpan/releases/download/v0.4.0/aliyunpan-v0.4.0-linux-amd64.zip
unzip aliyunpan-v0.4.0-linux-amd64.zip
cd aliyunpan-v0.4.0-linux-amd64
./aliyunpan
```
@@ -100,7 +107,7 @@ winget source update
```
安装
```powershell
winget install tickstep.aliyunpan --silent
winget install tickstep.aliyunpan --source winget --silent
```
@@ -110,15 +117,26 @@ winget install tickstep.aliyunpan --silent
备份功能支持以下模式:
1. 备份本地文件,即上传本地文件到网盘,始终保持本地文件有一个完整的备份在网盘
2. 备份云盘文件,即下载网盘文件到本地,始终保持网盘的文件有一个完整的备份在本地
备份功能支持指定备份策略:
1. exclusive,排他备份文件(一比一镜像备份),目标目录多余的文件会被删除。保证备份的源目录,和目标目录文件一比一备份。源目录文件如果文件被删除,则对应的目标目录的文件也会被删除。
2. increment,增量备份文件,目标目录多余的文件不会被删除。只会把源目录修改的文件,新增的文件备份到目标目录。如果源目录有文件删除,或者目标目录有其他文件新增是不会被删除。
同步的基本逻辑如下所示,一次循环包括:扫描-对比-执行,一共三个环节。
![](./assets/images/sync_command-basic_logic.jpg)
```
docker run -d --name=aliyunpan-sync --restart=always -v "<your aliyunpan_config.json>:/home/app/config/aliyunpan_config.json" -v "<your local dir>:/home/app/data" -e ALIYUNPAN_PAN_DIR="<your drive pan dir>" -e ALIYUNPAN_SYNC_MODE="upload" -e ALIYUNPAN_SYNC_DRIVE="backup" tickstep/aliyunpan-sync:v0.3.0
docker run -d --name=aliyunpan-sync --restart=always -v "<your aliyunpan_config.json>:/home/app/config/aliyunpan_config.json" -v "<your local dir>:/home/app/data" -e ALIYUNPAN_PAN_DIR="<your drive pan dir>" -e ALIYUNPAN_SYNC_MODE="upload" -e ALIYUNPAN_SYNC_POLICY="increment" -e ALIYUNPAN_SYNC_DRIVE="backup" -e ALIYUNPAN_SYNC_LOG="true" tickstep/aliyunpan-sync:v0.4.0
<your aliyunpan_config.json>: 用户已经登录成功并保存好的aliyunpan_config.json凭据文件
<your local dir>:本地目标目录,绝对路径,例如:/tickstep/Documents/设计文档
ALIYUNPAN_PAN_DIR:云盘目标目录,绝对路径
ALIYUNPAN_SYNC_MODE:备份模式,支持: upload(备份本地文件到云盘),download(备份云盘文件到本地)
ALIYUNPAN_SYNC_POLICY:备份策略,支持:exclusive(排他备份文件,目标目录多余的文件会被删除),increment(增量备份文件,目标目录多余的文件不会被删除)
ALIYUNPAN_SYNC_DRIVE: 网盘,支持:backup(备份盘), resource(资源盘)
ALIYUNPAN_SYNC_LOG: 同步日志,true-开启同步日志显示,false-关闭同步日志
```
docker-compose启动方式请查看:[docker-compose.yml](https://github.com/tickstep/aliyunpan/blob/main/docs/manual.md#Docker%E8%BF%90%E8%A1%8C)
更详情文档请参考dockerhub网址:[tickstep/aliyunpan-sync](https://hub.docker.com/r/tickstep/aliyunpan-sync)
# 如何使用
@@ -223,6 +241,7 @@ aliyunpan:/ tickstep$ ls
```
### 下载文件
如果需要更改默认下载目录路径,可以通过 `aliyunpan config set -savedir <savedir>` 自定义下载文件保存的目录。
```shell
aliyunpan:/ tickstep$ download IMG_0106.JPG
@@ -242,7 +261,25 @@ aliyunpan:/ tickstep$ download IMG_0106.JPG
[1] 检验文件有效性成功: /root/Downloads/4d001d48564f43b..62874f04bbe6/IMG_0106.JPG
下载结束, 时间: 4秒, 数据总量: 1.48MB
aliyunpan:/ tickstep$
```
或者使用UI面板展示下载进度(使用-ui选项),更加直观和友好。
```shell
aliyunpan:/ tickstep$ download /photo -ui
┌──────────────────────────────────────────────────────────────────────────────────────────┐
│总速度: 341.59 KB/s | 文件: 0/7 | 失败: 0 | 状态: 下载中 | 已用时间: 9s | 剩余时间: 51s │
│总进度: [===> ] 7.4% (896.00KB/11.83MB) │
│──────────────────────────────────────────────────────────────────────────────────────────│
│ 1. IMG_0005.JPG [============> ] 66.9% 258.42KB/s 3s │
│ 2. IMG_0007.JPG [============> ] 67.3% 258.54KB/s 3s │
│ 3. IMG_0009.JPG [ ] 0.0% 等待中 - │
│──────────────────────────────────────────────────────────────────────────────────────────│
│[10:39:24] [0] 当前文件下载最大并发量为: 2, 单文件下载分片线程数为: 1, 下载缓存为: 64.00KB │
│[10:39:27] [2] 加入下载队列: /photo/p/IMG_0007.JPG │
│[10:39:27] [2] ---- 文件ID: 67dfe476a7573e76a2034626bd013f36581296ed 文件名: IMG_0007.... │
│[10:39:27] [3] 准备下载: /photo/p/IMG_0007.JPG │
│[10:39:27] [2] 将会下载到路径: /Users/tickstep/Downloads/4d001d48564f43b3bc5662874f04bb... │
│[10:39:28] [2] 下载开始 │
└──────────────────────────────────────────────────────────────────────────────────────────┘
```
### 上传文件
@@ -262,20 +299,32 @@ aliyunpan:/ tickstep$ upload /Users/tickstep/Downloads/apt.zip /tmp
上传结束, 时间: 18秒, 数据总量: 21.00MB
```
或者使用UI面板展示上传进度(使用-ui选项),更加直观和友好。
```shell
aliyunpan:/ tickstep$ upload /Users/tickstep/Downloads /photo -ui
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│总速度: 3.62 MB/s | 文件: 9/14 | 失败: 0 | 状态: 上传中 | 已用时间: 12s | 剩余时间: 17s │
│总进度: [==========================> ] 51.7% (68.56MB/132.73MB) │
│─────────────────────────────────────────────────────────────────────────────────────────│
│ 1. System Design Interview 2.zip [====> ] 26.2% 3.25MB/s 18s │
│ 2. ips_20260228.txt [ ] 0.0% 0 B/s - │
│ 3. neko-agent [ ] 0.0% 0 B/s - │
│─────────────────────────────────────────────────────────────────────────────────────────│
│[11:14:49] [9] 2026-03-02 11:14:26 正在计算文件SHA1: /Users/tickstep/Downloads/test/da... │
│[11:14:49] [13] 2026-03-02 11:14:26 正在检测和创建云盘文件夹: /photo/p2/test/output │
│[11:14:49] [9] 2026-03-02 11:14:26 检测秒传中, 请稍候... │
│[11:14:49] [9] 2026-03-02 11:14:26 秒传成功, 保存到网盘路径: /photo/p2/test/data/ips_2... │
│[11:14:49] [9] 2026-03-02 11:14:26 文件上传结果: 成功! 耗时 12秒 │
│[11:14:49] 完成: /Users/tickstep/Downloads/test/data/ips_20260226.txt │
└─────────────────────────────────────────────────────────────────────────────────────────┘
```
### 同步备份文件
同步备份功能,支持备份本地文件到云盘,备份云盘文件到本地,双向同步备份三种模式。支持JavaScript插件对备份文件进行过滤。
***注意:如果同步目录下有非常多的文件,最好在首次备份前先运行一次scan任务,等scan任务完成并建立起同步数据库后,再正常启动同步任务。这样同步任务可以更加快速同步并且能有效避免同步重复文件。***
同步备份功能,支持备份本地文件到云盘,备份云盘文件到本地两种模式。支持JavaScript插件对备份文件进行过滤。
例如:将本地目录 `/tickstep/Documents/设计文档` 中的文件备份上传到云盘目录 `/备份盘/我的文档`
```shell
首次运行建议先扫描并构建同步数据库,如下:
aliyunpan:/ tickstep$ sync start -ldir "/tickstep/Documents/设计文档" -pdir "/备份盘/我的文档" -mode "upload" -step scan
然后再正常启动同步任务,如下:
aliyunpan:/ tickstep$ sync start -ldir "/tickstep/Documents/设计文档" -pdir "/备份盘/我的文档" -mode "upload"
aliyunpan:/ tickstep$ sync start -ldir "/tickstep/Documents/设计文档" -pdir "/备份盘/我的文档" -mode "upload" -drive "backup"
启动同步备份进程
备份配置文件:(使用命令行配置)
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity version="0.3.0.0" processorArchitecture="*" name="com.tickstep.aliyunpan" type="win32"/>
<assemblyIdentity version="0.4.0.0" processorArchitecture="*" name="com.tickstep.aliyunpan" type="win32"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+53 -34
View File
@@ -12,31 +12,33 @@
// 参数说明
// context - 当前调用的上下文信息
// {
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "albumDriveId": "29519122"
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "resourceDriveId": "29519122"
// }
// appName - 应用名称,当前固定为aliyunpan
// version - 版本号
// userId - 当前登录用户的ID
// nickname - 用户昵称
// fileDriveId - 用户文件网盘ID
// albumDriveId - 用户相册网盘ID
// fileDriveId - 用户备份网盘ID
// resourceDriveId - 用户资源网盘ID
//
// params - 文件下载前参数
// {
// "driveId": "19519221",
// "driveFileName": "token.bat",
// "driveFilePath": "/aliyunpan/Downloads/token.bat",
// "driveFileSha1": "08FBE28A5B8791A2F50225E2EC5CEEC3C7955A11",
// "driveFileSize": 125330,
// "driveFileType": "file",
// "driveFileUpdatedAt": "2022-04-14 07:05:12",
// "localFilePath": "aliyunpan\\Downloads\\token.bat"
// "downloadActionId": "20976308-a026-450d-92b8-16a969fed0c6",
// "driveId": "19519221",
// "driveFileName": "token.bat",
// "driveFilePath": "/aliyunpan/Downloads/token.bat",
// "driveFileSha1": "08FBE28A5B8791A2F50225E2EC5CEEC3C7955A11",
// "driveFileSize": 125330,
// "driveFileType": "file",
// "driveFileUpdatedAt": "2022-04-14 07:05:12",
// "localFilePath": "aliyunpan\\Downloads\\token.bat"
// }
// downloadActionId - 下载动作ID。同一次下载动作(download命令)下载的文件,这个ID都是同一个值。注意这个ID不是永久的,每执行一次download命令会生成一个对应ID。
// driveId - 网盘ID
// driveFileName - 网盘文件名
// driveFilePath - 网盘文件绝对完整路径
@@ -48,8 +50,8 @@
//
// 返回值说明
// {
// "downloadApproved": "yes",
// "localFilePath": "newfolder\\token.bat"
// "downloadApproved": "yes",
// "localFilePath": "newfolder\\token.bat"
// }
// downloadApproved - 该文件是否下载,yes-确认下载,no-取消下载
// localFilePath - 文件保存的本地路径,这个是相对路径,如果为空""代表保持原本的目标路径。
@@ -83,33 +85,37 @@ function downloadFilePrepareCallback(context, params) {
// 参数说明
// context - 当前调用的上下文信息
// {
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "albumDriveId": "29519122"
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "resourceDriveId": "29519122"
// }
// appName - 应用名称,当前固定为aliyunpan
// version - 版本号
// userId - 当前登录用户的ID
// nickname - 用户昵称
// fileDriveId - 用户文件网盘ID
// albumDriveId - 用户相册网盘ID
// fileDriveId - 用户备份网盘ID
// resourceDriveId - 用户资源网盘ID
//
// params - 文件下载结束参数
// {
// "driveId": "19519221",
// "driveFileName": "token.bat",
// "driveFilePath": "/aliyunpan/Downloads/token.bat",
// "driveFileSha1": "08FBE28A5B8791A2F50225E2EC5CEEC3C7955A11",
// "driveFileSize": 125330,
// "driveFileType": "file",
// "driveFileUpdatedAt": "2022-04-14 07:05:12",
// "downloadActionId": "20976308-a026-450d-92b8-16a969fed0c6",
// "driveId": "19519221",
// "driveFileId": "65ebb9396d66dfcd9f25414bbdd81594ecb9fcd6",
// "driveFileName": "token.bat",
// "driveFilePath": "/aliyunpan/Downloads/token.bat",
// "driveFileSha1": "08FBE28A5B8791A2F50225E2EC5CEEC3C7955A11",
// "driveFileSize": 125330,
// "driveFileType": "file",
// "driveFileUpdatedAt": "2022-04-14 07:05:12",
// "downloadResult": "success",
// "localFilePath": "aliyunpan\\Downloads\\token.bat"
// "localFilePath": "aliyunpan\\Downloads\\token.bat"
// }
// downloadActionId - 下载动作ID。同一次下载动作(download命令)下载的文件,这个ID都是同一个值。注意这个ID不是永久的,每执行一次download命令会生成一个对应ID。
// driveId - 网盘ID
// driveFileId - 网盘文件ID
// driveFileName - 网盘文件名
// driveFilePath - 网盘文件绝对完整路径
// driveFileSize - 网盘文件大小,单位B
@@ -124,4 +130,17 @@ function downloadFilePrepareCallback(context, params) {
// ------------------------------------------------------------------------------------------
function downloadFileFinishCallback(context, params) {
console.log(params)
// // 云盘文件成功下载到本地后,删除云盘的文件
// if (params["downloadResult"] == "success") {
// if (params["driveFileType"] == "file") {
// // 文件下载成功,删除该云盘文件
// var userId = context["userId"]
// var driveId = params["driveId"]
// var driveFileId = params["driveFileId"]
// if (PluginUtil.PanFS.deleteFile(userId, driveId, driveFileId)) {
// console.log("插件删除云盘文件成功:" + params["driveFilePath"])
// }
// }
// }
}
+105
View File
@@ -0,0 +1,105 @@
// ==========================================================================================
// aliyunpan JS插件回调处理函数
// 支持 JavaScript ECMAScript 5.1 语言规范
//
// 更多内容请查看官方文档:https://github.com/tickstep/aliyunpan
// ==========================================================================================
// ------------------------------------------------------------------------------------------
// 函数说明:删除文件前的回调函数
//
// 参数说明
// context - 当前调用的上下文信息
// {
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "resourceDriveId": "29519122"
// }
// appName - 应用名称,当前固定为aliyunpan
// version - 版本号
// userId - 当前登录用户的ID
// nickname - 用户昵称
// fileDriveId - 用户备份网盘ID
// resourceDriveId - 用户资源网盘ID
//
// params - 删除文件前的调用参数
// {
// "count": 10,
// "items": [{}, {}, ...],
// }
// count - 文件总数
// items - 文件列表,文件详情请查看下面 RemoveFilePrepareItem 定义
//
// RemoveFilePrepareItem - 文件详情参数
// {
// "driveId": "19519221",
// "driveFileId": "65f6c5161ee4a1b9a02b41399282f281c6e6df21",
// "driveFileName": "token.bat",
// "driveFilePath": "/aliyunpan/Downloads/token.bat",
// "driveFileSize": 125330,
// "driveFileType": "file",
// "driveFileUpdatedAt": "2025-03-02 10:39:14",
// "driveFileCreatedAt": "2025-03-02 10:39:14"
// }
// driveId - 网盘ID
// driveFileId - 网盘文件的ID
// driveFileName - 网盘文件名
// driveFilePath - 网盘文件绝对完整路径
// driveFileSize - 网盘文件大小,单位B
// driveFileType - 网盘文件类型,file-文件,folder-文件夹
// driveFileUpdatedAt - 网盘文件修改时间
// driveFileCreatedAt - 网盘文件创建时间
//
// 返回值说明
// {
// "result": [{}, {}, ...],
// }
// result - 结果列表,每个文件的确认结果,文件详细定义请查看 RemoveFilePrepareResultItem 定义
//
// RemoveFilePrepareResultItem - 每项文件确认结果
// {
// "driveId": "19519221",
// "driveFileId": "65f6c5161ee4a1b9a02b41399282f281c6e6df21",
// "removeApproved": "yes"
// }
// driveId - 网盘ID
// driveFileId - 网盘文件的ID
// removeApproved - 该文件是否能删除,yes-确认删除,no-不能删除
// ------------------------------------------------------------------------------------------
function removeFilePrepareCallback(context, params) {
console.log(params)
var result = {"result":[]};
for(var i = 0; i < params.items.length; i++) {
var fileItem = params.items[i]
console.log(fileItem);
var fileItemApprovedResult = {
"driveId": fileItem["driveId"],
"driveFileId": fileItem["driveFileId"],
"removeApproved": "yes"
}
// 可以通过名称过滤
if (fileItem["driveFileType"] == "file") { // 文件
if (fileItem["driveFileName"] == "password.key") {
fileItemApprovedResult["removeApproved"] = "no"; // 禁止删除password.key文件
}
} else if (params["driveFileType"] == "folder") { // 文件夹
// 对文件夹进行处理
}
// 可以通过时间过滤,例如:保留最近10天的文件
// ...
// 可以通过数量过滤,例如:保留最新5个文件
// ...
// 放入结果列表
result["result"].push(fileItemApprovedResult)
}
// 返回
return result;
}
+118 -61
View File
@@ -12,28 +12,28 @@
// 参数说明
// context - 当前调用的上下文信息
// {
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "albumDriveId": "29519122"
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "resourceDriveId": "29519122"
// }
// appName - 应用名称,当前固定为aliyunpan
// version - 版本号
// userId - 当前登录用户的ID
// nickname - 用户昵称
// fileDriveId - 用户文件网盘ID
// albumDriveId - 用户相册网盘ID
// fileDriveId - 用户备份网盘ID
// resourceDriveId - 用户资源网盘ID
//
// params - 扫描本地文件前参数
// {
// "localFilePath": "D:\\Program Files\\aliyunpan\\Downloads\\token.bat",
// "localFileName": "token.bat",
// "localFileSize": 125330,
// "localFileType": "file",
// "localFileUpdatedAt": "2022-04-14 07:05:12",
// "driveId": "19519221"
// "localFilePath": "D:\\Program Files\\aliyunpan\\Downloads\\token.bat",
// "localFileName": "token.bat",
// "localFileSize": 125330,
// "localFileType": "file",
// "localFileUpdatedAt": "2022-04-14 07:05:12",
// "driveId": "19519221"
// }
// localFilePath - 本地文件绝对完整路径
// localFileName - 本地文件名
@@ -44,13 +44,13 @@
//
// 返回值说明
// {
// "syncScanLocalApproved": "yes"
// "syncScanLocalApproved": "yes"
// }
// syncScanLocalApproved - 该文件是否确认扫描,yes-允许扫描,no-禁止扫描。
// 禁止扫描的文件不会执行后续的动作,例如上传,下载。
// ------------------------------------------------------------------------------------------
function syncScanLocalFilePrepareCallback(context, params) {
console.log(params);
console.log(params);
var result = {
"syncScanLocalApproved": "yes"
};
@@ -60,7 +60,7 @@ function syncScanLocalFilePrepareCallback(context, params) {
result["syncScanLocalApproved"] = "no";
}
// 禁止~$开头文件上传(office暂存临时文件)
// 禁止~$开头文件上传(office暂存临时文件)
if (params["localFileName"].indexOf("~$") == 0) {
result["syncScanLocalApproved"] = "no";
}
@@ -74,9 +74,9 @@ function syncScanLocalFilePrepareCallback(context, params) {
if (params["localFileName"] == "password.key") {
result["syncScanLocalApproved"] = "no";
}
// 禁止@eadir文件上传
if (params["localFileName"] == "@eadir") {
// 禁止@eadir文件上传
if (params["localFileName"] == "@eadir") {
result["syncScanLocalApproved"] = "no";
}
return result;
@@ -89,29 +89,29 @@ function syncScanLocalFilePrepareCallback(context, params) {
// 参数说明
// context - 当前调用的上下文信息
// {
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "albumDriveId": "29519122"
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "resourceDriveId": "29519122"
// }
// appName - 应用名称,当前固定为aliyunpan
// version - 版本号
// userId - 当前登录用户的ID
// nickname - 用户昵称
// fileDriveId - 用户文件网盘ID
// albumDriveId - 用户相册网盘ID
// fileDriveId - 用户备份网盘ID
// resourceDriveId - 用户资源网盘ID
//
// params - 扫描云盘文件前参数
// {
// "driveId": "19519221",
// "driveFileName": "token.bat",
// "driveFilePath": "/aliyunpan/Downloads/token.bat",
// "driveFileSha1": "08FBE28A5B8791A2F50225E2EC5CEEC3C7955A11",
// "driveFileSize": 125330,
// "driveFileType": "file",
// "driveFileUpdatedAt": "2022-04-14 07:05:12"
// "driveId": "19519221",
// "driveFileName": "token.bat",
// "driveFilePath": "/aliyunpan/Downloads/token.bat",
// "driveFileSha1": "08FBE28A5B8791A2F50225E2EC5CEEC3C7955A11",
// "driveFileSize": 125330,
// "driveFileType": "file",
// "driveFileUpdatedAt": "2022-04-14 07:05:12"
// }
// driveId - 网盘ID
// driveFileName - 网盘文件名
@@ -123,7 +123,7 @@ function syncScanLocalFilePrepareCallback(context, params) {
//
// 返回值说明
// {
// "syncScanPanApproved": "yes"
// "syncScanPanApproved": "yes"
// }
// syncScanPanApproved - 该文件是否确认扫描,yes-允许扫描,no-禁止扫描。
// 禁止扫描的文件不会执行后续的动作,例如上传,下载。
@@ -159,41 +159,37 @@ function syncScanPanFilePrepareCallback(context, params) {
// 参数说明
// context - 当前调用的上下文信息
// {
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "albumDriveId": "29519122"
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "resourceDriveId": "29519122"
// }
// appName - 应用名称,当前固定为aliyunpan
// version - 版本号
// userId - 当前登录用户的ID
// nickname - 用户昵称
// fileDriveId - 用户文件网盘ID
// albumDriveId - 用户相册网盘ID
// fileDriveId - 用户备份网盘ID
// resourceDriveId - 用户资源网盘ID
//
// params - 同步文件后的参数
// {
// "action": "upload",
// "actionResult": "success",
// "driveId": "19519221",
// "fileName": "1.txt",
// "filePath": "D:/goprojects/dev/upload/1.txt",
// "fileSha1": "294C8F24C56042710813E95C55A0B018299BA9A7",
// "fileSize": "28077",
// "fileType": "file",
// "fileUpdatedAt": "2022-03-04 15:19:47"
// "action": "upload",
// "actionResult": "success",
// "driveId": "19519221",
// "driveFileId": "65ebb9396d66dfcd9f25414bbdd81594ecb9fcd6",
// "fileName": "1.txt",
// "filePath": "D:/goprojects/dev/upload/1.txt",
// "fileSha1": "294C8F24C56042710813E95C55A0B018299BA9A7",
// "fileSize": "28077",
// "fileType": "file",
// "fileUpdatedAt": "2022-03-04 15:19:47"
// }
// action - 同步动作,
// download-下载文件,
// upload-上传文件,
// delete_local-删除本地文件,
// delete_pan-删除云盘文件,
// create_local_folder-创建本地文件夹,
// create_pan_folder-创建云盘文件夹
// action - 同步动作,download-下载文件,upload-上传文件
// actionResult - 同步结果,success-成功,fail-失败
// driveId - 网盘ID
// driveFileId - 网盘文件ID,只有download模式才会有这个字段
// fileName - 文件名称
// filePath - 文件完整路径
// fileSha1 - 文件SHA1
@@ -205,5 +201,66 @@ function syncScanPanFilePrepareCallback(context, params) {
// (无)
// ------------------------------------------------------------------------------------------
function syncFileFinishCallback(context, params) {
console.log(params)
console.log(params)
// 云盘文件成功下载到本地后,删除云盘的文件
// if (params["action"] == "download") {
// if (params["actionResult"] == "success" && params["fileType"] == "file") {
// // 文件下载成功,删除该云盘文件
// var userId = context["userId"]
// var driveId = params["driveId"]
// var driveFileId = params["driveFileId"]
// if (PluginUtil.PanFS.deleteFile(userId, driveId, driveFileId)) {
// console.println("插件删除云盘文件成功:" + params["filePath"])
// }
// }
// }
}
// ------------------------------------------------------------------------------------------
// 函数说明:同步备份-同步任务同步全部文件完成时的回调函数(目前只有"只运行一次备份"的任务模式才有该回调)
//
// 参数说明
// context - 当前调用的上下文信息
// {
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "resourceDriveId": "29519122"
// }
// appName - 应用名称,当前固定为aliyunpan
// version - 版本号
// userId - 当前登录用户的ID
// nickname - 用户昵称
// fileDriveId - 用户备份网盘ID
// resourceDriveId - 用户资源网盘ID
//
// params - 任务完成的回调参数
// {
// "name": "备份相册",
// "id": "2e959a34-8435-4773-9a3e-687f2692ddc1",
// "userId": "4d001d48564f43b3bc5662874114bbe6",
// "driveName": "backup",
// "driveId": "19519221",
// "localFolderPath": "/Volumes/DataDisk3T/Games/Switch/图库备份",
// "panFolderPath": "/备份盘/Switch游戏/Album",
// "mode": "upload",
// "policy": "upload",
// }
// name - 任务名称
// id - 任务ID
// userId - 登录的用户ID
// driveName - 网盘名称,backup-备份盘,resource-资源盘
// driveId - 目标网盘ID
// localFolderPath - 本地目录
// panFolderPath - 云盘目录
// mode - 备份模式,upload-备份本地文件到云盘,download-备份云盘文件到本地
// policy - 备份策略,increment-增量备份,exclusive-排他备份
//
// 返回值说明
// 无
// ------------------------------------------------------------------------------------------
function syncAllFileFinishCallback(context, params) {
console.log(params)
}
+13 -13
View File
@@ -12,27 +12,27 @@
// 参数说明
// context - 当前调用的上下文信息
// {
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "albumDriveId": "29519122"
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "resourceDriveId": "29519122"
// }
// appName - 应用名称,当前固定为aliyunpan
// version - 版本号
// userId - 当前登录用户的ID
// nickname - 用户昵称
// fileDriveId - 用户文件网盘ID
// albumDriveId - 用户相册网盘ID
// fileDriveId - 用户备份网盘ID
// resourceDriveId - 用户资源网盘ID
//
// params - Token刷新参数
// {
// "result": "success",
// "message": "ok",
// "oldToken": "aa31fcc229c54d5ab6d8bfb17aff3711",
// "newToken": "bb31fcc229c54d5ab6d8bfb17aff3722",
// "updatedAt": "2022-04-14 07:05:12"
// "result": "success",
// "message": "ok",
// "oldToken": "aa31fcc229c54d5ab6d8bfb17aff3711",
// "newToken": "bb31fcc229c54d5ab6d8bfb17aff3722",
// "updatedAt": "2022-04-14 07:05:12"
// }
// result - Token刷新的结果,success-成功,fail-失败
// message - 消息说明,如果失败这里会有原因说明
+34 -34
View File
@@ -12,29 +12,29 @@
// 参数说明
// context - 当前调用的上下文信息
// {
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "albumDriveId": "29519122"
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "resourceDriveId": "29519122"
// }
// appName - 应用名称,当前固定为aliyunpan
// version - 版本号
// userId - 当前登录用户的ID
// nickname - 用户昵称
// fileDriveId - 用户文件网盘ID
// albumDriveId - 用户相册网盘ID
// fileDriveId - 用户备份网盘ID
// resourceDriveId - 用户资源网盘ID
//
// params - 文件上传前参数
// {
// "localFilePath": "D:\\Program Files\\aliyunpan\\Downloads\\token.bat",
// "localFileName": "token.bat",
// "localFileSize": 125330,
// "localFileType": "file",
// "localFileUpdatedAt": "2022-04-14 07:05:12",
// "driveId": "19519221",
// "driveFilePath": "aliyunpan/Downloads/token.bat"
// "localFilePath": "D:\\Program Files\\aliyunpan\\Downloads\\token.bat",
// "localFileName": "token.bat",
// "localFileSize": 125330,
// "localFileType": "file",
// "localFileUpdatedAt": "2022-04-14 07:05:12",
// "driveId": "19519221",
// "driveFilePath": "aliyunpan/Downloads/token.bat"
// }
// localFilePath - 本地文件绝对完整路径
// localFileName - 本地文件名
@@ -46,8 +46,8 @@
//
// 返回值说明
// {
// "uploadApproved": "yes",
// "driveFilePath": "newfolder/token.bat"
// "uploadApproved": "yes",
// "driveFilePath": "newfolder/token.bat"
// }
// uploadApproved - 该文件是否确认上传,yes-允许上传,no-禁止上传
// driveFilePath - 文件保存的网盘路径,这个是相对路径,如果为空""代表保持原本的目标路径。
@@ -84,31 +84,31 @@ function uploadFilePrepareCallback(context, params) {
// 参数说明
// context - 当前调用的上下文信息
// {
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "albumDriveId": "29519122"
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "resourceDriveId": "29519122"
// }
// appName - 应用名称,当前固定为aliyunpan
// version - 版本号
// userId - 当前登录用户的ID
// nickname - 用户昵称
// fileDriveId - 用户文件网盘ID
// albumDriveId - 用户相册网盘ID
// fileDriveId - 用户备份网盘ID
// resourceDriveId - 用户资源网盘ID
//
// params - 文件上传结束参数
// {
// "localFilePath": "D:\\Program Files\\aliyunpan\\Downloads\\token.bat",
// "localFileName": "token.bat",
// "localFileSize": 125330,
// "localFileType": "file",
// "localFileUpdatedAt": "2022-04-14 07:05:12",
// "localFileSha1": "08FBE28A5B8791A2F50225E2EC5CEEC3C7955A11",
// "uploadResult": "success",
// "driveId": "19519221",
// "driveFilePath": "/tmp/test/aliyunpan/Downloads/token.bat"
// "localFilePath": "D:\\Program Files\\aliyunpan\\Downloads\\token.bat",
// "localFileName": "token.bat",
// "localFileSize": 125330,
// "localFileType": "file",
// "localFileUpdatedAt": "2022-04-14 07:05:12",
// "localFileSha1": "08FBE28A5B8791A2F50225E2EC5CEEC3C7955A11",
// "uploadResult": "success",
// "driveId": "19519221",
// "driveFilePath": "/tmp/test/aliyunpan/Downloads/token.bat"
// }
// localFilePath - 本地文件绝对完整路径
// localFileName - 本地文件名
+1
View File
@@ -76,6 +76,7 @@ Pack() {
cp README.md "$output/$1"
cp docs/manual.md "$output/$1"
cp docs/plugin_manual.md "$output/$1"
cp -rf assets/scripts/* "$output/$1"
cp -rf assets/plugin "$output/$1"
cp -rf assets/sync_drive "$output/$1"
+12 -2
View File
@@ -1,9 +1,12 @@
# alpine:3.15.0
# alpine:3.21
# 参数
ARG DOCKER_IMAGE_HASH
FROM alpine@sha256:$DOCKER_IMAGE_HASH
#FROM alpine@sha256:$DOCKER_IMAGE_HASH
#FROM alpine
FROM alpine:$DOCKER_IMAGE_HASH
LABEL author="tickstep"
LABEL email="tickstep@outlook.com"
LABEL version="1.0"
@@ -13,6 +16,9 @@ LABEL description="sync & backup service for aliyun cloud drive"
ENV TZ=Asia/Shanghai
# 手动下载tzdata安装包,注意要下载对应架构的: https://dl-cdn.alpinelinux.org/alpine/v3.15/community/
RUN apk add -U tzdata
# 更新Alpine的软件源为国内镜像站点,提高下载速度。安装tzdata包
# RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# RUN apk add --no-cache tzdata
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 创建运行目录
@@ -55,11 +61,15 @@ ENV ALIYUNPAN_UPLOAD_BLOCK_SIZE=10240
ENV ALIYUNPAN_LOCAL_DIR=/home/app/data
ENV ALIYUNPAN_PAN_DIR=/sync_drive
ENV ALIYUNPAN_SYNC_MODE=upload
ENV ALIYUNPAN_SYNC_POLICY=increment
ENV ALIYUNPAN_SYNC_DRIVE=backup
ENV ALIYUNPAN_SYNC_CYCLE=infinity
ENV ALIYUNPAN_SYNC_PRIORITY=time
ENV ALIYUNPAN_SYNC_LOG=false
ENV ALIYUNPAN_LOCAL_DELAY_TIME=3
ENV ALIYUNPAN_SCAN_INTERVAL_TIME=1
ENV ALIYUNPAN_DEVICE_ID=""
ENV ALIYUNPAN_IP_TYPE="ipv4"
# 运行
ENTRYPOINT ./app.sh
+9 -1
View File
@@ -19,6 +19,7 @@ else
"localFolderPath": "$ALIYUNPAN_LOCAL_DIR",
"panFolderPath": "$ALIYUNPAN_PAN_DIR",
"mode": "$ALIYUNPAN_SYNC_MODE",
"policy": "$ALIYUNPAN_SYNC_POLICY",
"driveName": "$ALIYUNPAN_SYNC_DRIVE",
"lastSyncTime": "2022-06-12 19:28:20"
}
@@ -38,6 +39,9 @@ sleep 2s
# ./aliyunpan config set -device_id ${ALIYUNPAN_DEVICE_ID}
#fi
# show docker IPs
./aliyunpan tool getip
# check login already or not
./aliyunpan who
if [ $? -eq 0 ]
@@ -55,4 +59,8 @@ else
./aliyunpan config set -file_record_config 2
fi
./aliyunpan sync start -dp ${ALIYUNPAN_DOWNLOAD_PARALLEL} -up ${ALIYUNPAN_UPLOAD_PARALLEL} -dbs ${ALIYUNPAN_DOWNLOAD_BLOCK_SIZE} -ubs ${ALIYUNPAN_UPLOAD_BLOCK_SIZE} -log ${ALIYUNPAN_SYNC_LOG} -ldt ${ALIYUNPAN_LOCAL_DELAY_TIME}
# IP type
./aliyunpan config set -ip_type ${ALIYUNPAN_IP_TYPE}
# run
./aliyunpan sync start -dp ${ALIYUNPAN_DOWNLOAD_PARALLEL} -up ${ALIYUNPAN_UPLOAD_PARALLEL} -dbs ${ALIYUNPAN_DOWNLOAD_BLOCK_SIZE} -ubs ${ALIYUNPAN_UPLOAD_BLOCK_SIZE} -log ${ALIYUNPAN_SYNC_LOG} -ldt ${ALIYUNPAN_LOCAL_DELAY_TIME} -cycle ${ALIYUNPAN_SYNC_CYCLE} -sit ${ALIYUNPAN_SCAN_INTERVAL_TIME}
+7 -1
View File
@@ -29,9 +29,15 @@ services:
- ALIYUNPAN_PAN_DIR=/my_sync_dir
# 备份模式:upload(备份本地文件到云盘), download(备份云盘文件到本地)
- ALIYUNPAN_SYNC_MODE=upload
# 备份策略: exclusive(排他备份文件,目标目录多余的文件会被删除),increment(增量备份文件,目标目录多余的文件不会被删除)
- ALIYUNPAN_SYNC_POLICY=increment
# 备份周期, 支持两种: infinity(永久循环备份),onetime(只运行一次备份)
- ALIYUNPAN_SYNC_CYCLE=infinity
# 网盘:backup(备份盘), resource(资源盘)
- ALIYUNPAN_SYNC_DRIVE=backup
# 是否显示文件备份过程日志,true-显示,false-不显示
- ALIYUNPAN_SYNC_LOG=true
# 本地文件修改检测延迟间隔,单位秒。如果本地文件会被频繁修改,例如录制视频文件,配置好该时间可以避免上传未录制好的文件
- ALIYUNPAN_LOCAL_DELAY_TIME=3
- ALIYUNPAN_LOCAL_DELAY_TIME=3
# 扫描文件间隔时间,单位:分钟
- ALIYUNPAN_SCAN_INTERVAL_TIME=1
+1
View File
@@ -4,6 +4,7 @@
工具安装,运行下面的命令即可生成工具。也可以直接用 bin/ 文件夹下面的编译好的
```
go get github.com/josephspurrier/goversioninfo/cmd/goversioninfo
go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo
```
versioninfo.json - 里面有exe程序信息以及ico的配置
+183 -563
View File
@@ -15,17 +15,21 @@
* [输出工作目录](#输出工作目录)
* [列出目录](#列出目录)
* [下载文件/目录](#下载文件目录)
* [多用户联合下载](#多用户联合下载)
* [上传文件/目录](#上传文件目录)
* [创建目录](#创建目录)
* [删除文件/目录](#删除文件目录)
* [移动文件/目录](#移动文件目录)
* [备份盘和资源库之间转存文件](#备份盘和资源库之间转存文件)
* [重命名文件/目录](#重命名文件目录)
* [分享文件/目录](#分享文件目录)
+ [设置分享文件/目录](#设置分享文件目录)
+ [创建快传链接](#创建快传链接)
+ [列出已分享文件/目录](#列出已分享文件目录)
+ [取消分享文件/目录](#取消分享文件目录)
* [共享相册](#共享相册)
+ [展示共享相簿列表](#展示共享相簿列表)
+ [展示指定相簿中的文件](#展示指定相簿中的文件)
+ [下载相簿中的所有文件](#下载相簿中的所有文件)
* [同步备份功能](#同步备份功能)
+ [常用命令说明](#常用命令说明)
+ [备份配置文件说明](#备份配置文件说明)
@@ -34,16 +38,8 @@
+ [Windows后台启动](#Windows后台启动)
+ [Docker运行](#Docker运行)
* [JavaScript插件](#JavaScript插件)
+ [如何使用](#如何使用)
+ [JS中内置的函数](#JS中内置的函数)
+ [常见场景样例](#常见场景样例)
+ [1.禁止特定文件上传](#1.禁止特定文件上传)
+ [2.上传文件后删除本地文件](#2.上传文件后删除本地文件)
+ [3.下载文件并截断过长的文件名](#3.下载文件并截断过长的文件名)
+ [4.上传文件去掉文件名包含的部分字符](#4.上传文件去掉文件名包含的部分字符)
+ [5.Token刷新失败发送外部通知](#5.Token刷新失败发送外部通知)
* [显示和修改程序配置项](#显示和修改程序配置项)
- [常见问题Q&A](#常见问题Q&A)
- [常见问题Q&A](#常见问题QA)
* [1. 如何开启Debug调试日志](#1-如何开启Debug调试日志)
# 命令列表及说明
@@ -93,7 +89,6 @@ aliyunpan login
```
aliyunpan > login
请在浏览器打开以下链接进行登录,链接有效时间为5分钟。
注意:你需要进行一次授权一次扫码的两次登录。
https://openapi.alipan.com/oauth/authorize?client_id=cf9f70e8fc61430f8ec5ab5cadf31375&redirect_uri=https%3A%2F%2Fapi.tickstep.com%2Fauth%2Ftickstep%2Faliyunpan%2Ftoken%2Fopenapi%2F8206f0.....fb5db6b40336%2Fauth&scope=user:base,file:all:read,file:all:write
阿里云盘登录成功: tickstep
aliyunpan:/ tickstep$
@@ -221,17 +216,20 @@ aliyunpan d <网盘文件或目录的路径1> <文件或目录2> <文件或目
### 可选参数
```
--ow overwrite, 覆盖已存在的文件
--skip skip same name, 跳过已存在的同名文件,即使文件内容不一致(不检查SHA1)
--status 输出所有线程的工作状态
--save 将下载的文件直接保存到当前工作目录
--saveto value 将下载的文件直接保存到指定的目录
-x 为文件加上执行权限, (windows系统无效)
-p value 指定下载线程数 (default: 0)
-l value 指定同时进行下载文件的数量 (default: 0)
--retry value 下载失败最大重试次数 (default: 3)
--nocheck 下载文件完成后不校验文件
--exn value 指定排除的文件夹或者文件的名称,只支持正则表达式。支持排除多个名称,每一个名称就是一个exn参数
--ow overwrite, 覆盖已存在的文件
--status 输出所有线程的工作状态
--save 将下载的文件直接保存到当前工作目录
--saveto value 将下载的文件直接保存到指定的目录
-x 为文件加上执行权限, (windows系统无效)
-p value parallel,指定同时进行下载文件的数量(取值范围:1 ~ 3) (default: 1)
--sp value slice parallel,指定单个文件下载的最大线程(分片)数(取值范围:1 ~ 3) (default: 0)
--retry value 下载失败最大重试次数 (default: 3)
--nocheck 下载文件完成后不校验文件
--np no progress 不展示下载进度条
--driveId value 网盘ID
--exn value exclude name,指定排除的文件夹或者文件的名称,被排除的文件不会进行下载,只支持正则表达式。支持同时排除多个名称,每一个名称就是一个exn参数
--md (BETA) Multi-User Download,使用多用户联合下载,可以对单一文件叠加所有登录用户的下载速度
--ui (BETA) 使用UI面板显示下载详情和进度,更加直观和友好
```
@@ -246,15 +244,61 @@ aliyunpan d /我的文档/1.mp4
# 下载 /我的文档 整个目录!!
aliyunpan d /我的文档
# 下载 /photo 整个目录,并使用UI面板展示下载详情
aliyunpan d /photo -ui
```
下载的文件默认保存到 **程序所在目录** 的 download/ 目录, 支持设置指定目录, 重名的文件会自动跳过!
通过 `aliyunpan config set -savedir <savedir>` 可以自定义保存的目录.
通过 `aliyunpan config set -savedir <savedir>` 可以自定义保存的目录。
支持多个文件或目录下载,支持自动跳过下载重名的文件!
支持多个文件或目录下载.
### Linux后台下载
需要结合nohup进行启动。
新建 download.sh 脚本,内容如下
```
#!/bin/bash
自动跳过下载重名的文件!
# 是否开启调试日志
export ALIYUNPAN_VERBOSE=0
# (可选)配置目录的绝对路径(请更改成你自己的目录)
export ALIYUNPAN_CONFIG_DIR=/Users/tickstep/Applications/adrive/config
# aliyunpan程序所在的绝对路径(请更改成你自己的目录)
export ALIYUNPAN_BIN=/Users/tickstep/Applications/adrive/aliyunpan
# 本地目录(请更改成你自己的目录)
LOCAL_DIR="/tickstep/Documents/我的文档"
# 网盘目录(请更改成你自己的目录)
PAN_DIR="/我的文档"
# 执行下载
"$ALIYUNPAN_BIN" download --saveto "$LOCAL_DIR" "$PAN_DIR"
```
增加脚本执行权限
```
$ chmod +x download.sh
```
然后启动该脚本进行后台运行
```
$ nohup ./download.sh > aliyunpan.log 2>&1 &
```
## 多用户联合下载
前提:程序必须登录多个帐号,并且登录授权都有效。
```
# 使用多用户联合下载 /我的资源/1.mp4 文件。必须保证所有登录的用户在相同的网盘(备份盘/资源盘)下,相同的路径下,有相同的文件
aliyunpan download /我的资源/1.mp4 -md
```
什么是多用户联合下载?
由于阿里云盘的限制,一个用户下载的时候只能有3个线程并发数,未开通三方权益包的账号一个下载线程速度大概是500KB/s,3个并发即为1.5MB/s。
多用户联合下载,即是多个用户同时下载同一个文件,每个用户并发3个线程,N个用户即能开启 Nx3 个线程,对应的下载速度叠加为 Nx1.5MB/s。
对下载速度有极致追求的用户可以尝试使用该选项。
如果你的账号都开通了三方权益包,则一个用户下载速度为50MB/s,两个用户联合下载可以轻松突破100MB/s。
![](../assets/images/multi_user_download.png)
## 上传文件/目录
```
@@ -274,6 +318,9 @@ aliyunpan upload C:/Users/Administrator/Desktop/1.mp4 C:/Users/Administrator/Des
# 将本地的 C:\Users\Administrator\Desktop 整个目录上传到网盘 /视频 目录
aliyunpan upload C:/Users/Administrator/Desktop /视频
# 将本地的 C:\Users\Administrator\Desktop 整个目录上传到网盘 /视频 目录,并使用UI面板展示下载详情
aliyunpan upload C:/Users/Administrator/Desktop /视频 -ui
## 下面演示文件或者文件夹排除功能
# 将本地的 C:\Users\Administrator\Video 整个目录上传到网盘 /视频 目录,但是排除所有的.jpg文件
@@ -289,6 +336,54 @@ aliyunpan upload -exn "\.jpg$" -exn "\.mp3$" C:/Users/Administrator/Video /视
4)排除~号开头的文件:-exn "^~"
5)排除 myfile.txt 文件:-exn "^myfile.txt$"
```
### Linux后台上传
需要结合nohup进行启动。
新建 upload.sh 脚本,内容如下
```
#!/bin/bash
# 是否开启调试日志 (1=开启, 0=关闭)
export ALIYUNPAN_VERBOSE=0
# (可选)配置目录的绝对路径
export ALIYUNPAN_CONFIG_DIR="/Users/tickstep/Applications/adrive/config"
# aliyunpan程序所在的绝对路径
export ALIYUNPAN_BIN="/Users/tickstep/Applications/adrive/aliyunpan"
# 本地目录
LOCAL_DIR="/tickstep/Documents/我的文档"
# 网盘目录
PAN_DIR="/我的文档"
# 检查本地目录是否存在
if [ ! -d "$LOCAL_DIR" ]; then
echo "[ERROR] 本地目录不存在: $LOCAL_DIR"
exit 1
fi
# 执行上传(已修复引号、添加安全引用和覆盖策略)
"$ALIYUNPAN_BIN" upload \
-exn "^\." \
-exn "^@eadir$" \
-ow \
"$LOCAL_DIR" \
"$PAN_DIR"
echo "[INFO] 上传任务执行完毕"
```
增加脚本执行权限
```
$ chmod +x upload.sh
```
然后启动该脚本进行后台运行
```
$ nohup ./upload.sh > aliyunpan.log 2>&1 &
```
## 创建目录
```
@@ -335,25 +430,6 @@ aliyunpan mv <文件/目录1> <文件/目录2> <文件/目录3> ... <目标目
aliyunpan mv /我的文档/1.mp4 /
```
## 备份盘和资源库之间转存文件
```
aliyunpan xcp <文件/目录1> <文件/目录2> <文件/目录3> ... <目标盘目录>
```
注意: 拷贝多个文件和目录时, 请确保每一个文件和目录都存在, 否则拷贝操作会失败
### 例子
```
当前程序工作在备份盘下,将备份盘 /1.mp4 文件转存复制到 资源库下的 /来自备份盘/video 目录下
aliyunpan xcp /1.mp4 /来自备份盘/video
当前程序工作在资源库下,将资源库 /1.mp4 文件转存复制到 备份盘下的 /来自资源库/video 目录下
aliyunpan xcp /1.mp4 /来自资源库/video
当前程序工作在备份盘下,将 /我的资源 目录下所有的.mp4文件 复制到 /来自备份盘/video 目录下面,使用通配符匹配
aliyunpan xcp /我的资源/*.mp4 /来自备份盘/video
```
## 重命名文件/目录
```
aliyunpan rename <旧文件/目录名> <新文件/目录名>
@@ -395,15 +471,46 @@ aliyunpan share cancel <shareid_1> <shareid_2> ...
```
目前只支持通过分享id (shareid) 来取消分享.
## 共享相册
```
aliyunpan album
```
### 展示共享相簿列表
```
aliyunpan album list
```
### 展示指定相簿中的文件
展示相簿中文件,同名的相簿只会展示第一个符合条件的
```
例如:展示相簿中文件"我的相簿2025"
aliyunpan album list-file "我的相簿2025"
```
### 下载相簿中的所有文件
下载相簿中的所有文件到本地,支持普通照片、实况照片。
```
下载相簿 "我的相簿2025" 里面的所有文件
aliyunpan album download-file 我的相簿2025
```
## 同步备份功能
同步备份功能,支持备份本地文件到云盘,备份云盘文件到本地,双向同步备份三种模式。支持JavaScript插件对备份文件进行过滤。
同步备份功能,支持备份本地文件到云盘,备份云盘文件到本地两种模式。支持JavaScript插件对备份文件进行过滤。
指定本地目录和对应的一个网盘目录,以备份文件。网盘目录必须和本地目录独占使用,不要用作其他用途,不然备份可能会有问题。
备份功能支持以下模式:
1. 备份本地文件,即上传本地文件到网盘,始终保持本地文件有一个完整的备份在网盘
2. 备份云盘文件,即下载网盘文件到本地,始终保持网盘的文件有一个完整的备份在本地
备份功能一般用于NAS等系统,进行文件备份。比如备份照片,就可以使用这个功能定期备份照片到云盘,十分好用。
备份功能支持指定备份策略:
1. exclusive,排他备份文件,目标目录多余的文件会被删除。保证备份的源目录,和目标目录文件一比一备份。源目录文件如果文件被删除,则对应的目标目录的文件也会被删除。
2. increment,增量备份文件,目标目录多余的文件不会被删除。只会把源目录修改的文件,新增的文件备份到目标目录。如果源目录有文件删除,或者目标目录有其他文件新增是不会被删除。
备份功能一般用于NAS等系统,进行文件备份。比如备份照片,就可以使用这个功能定期备份照片到云盘。
同步的基本逻辑如下所示,一次循环包括:扫描-对比-执行,一共三个环节。
![](../assets/images/sync_command-basic_logic.jpg)
### 常用命令说明
```
@@ -432,7 +539,7 @@ aliyunpan sync start -dp 2 -up 1 -dbs 256 -ubs 1024
### 备份配置文件说明
如果你只有一个文件夹进行备份建议直接使用命令行配置启动即可。如果需要同时启动多个备份任务,则可以使用备份配置文件启动同步备份任务。
配置文件如下所示,如果你有通过环境变量ALIYUNPAN_CONFIG_DIR设置配置目录,则需要将sync_drive文件夹拷贝到配置的目录中才可以生效。
配置文件如下所示,如果你有通过环境变量```ALIYUNPAN_CONFIG_DIR```设置配置目录,则需要将```sync_drive```文件夹拷贝到配置的目录中才可以生效。
```
配置文件需要保存在:(配置目录)/sync_drive/sync_drive_config.json,样例如下:
@@ -500,14 +607,23 @@ drive - 网盘,支持:backup(备份盘), resource(资源盘)
sync.sh脚本,内容如下
```
# 请更改成你自己的目录
cd /path/to/aliyunpan/folder
#!/bin/bash
chmod +x ./aliyunpan
# 是否开启调试日志
export ALIYUNPAN_VERBOSE=0
# (可选)配置目录的绝对路径(请更改成你自己的目录)
export ALIYUNPAN_CONFIG_DIR=/Users/tickstep/Applications/adrive/config
# aliyunpan程序所在的绝对路径(请更改成你自己的目录)
export ALIYUNPAN_BIN=/Users/tickstep/Applications/adrive/aliyunpan
# 本地目录(请更改成你自己的目录)
LOCAL_DIR="/tickstep/Documents/设计文档"
# 网盘目录(请更改成你自己的目录)
PAN_DIR="/备份盘/我的文档"
# 指定配置参数并进行启动
# 支持的模式:upload(备份本地文件到云盘),download(备份云盘文件到本地),sync(双向同步备份)
./aliyunpan sync start -ldir "/tickstep/Documents/设计文档" -pdir "/备份盘/我的文档" -mode "upload" -drive "backup"
# 支持的模式:upload(备份本地文件到云盘),download(备份云盘文件到本地)
"$ALIYUNPAN_BIN" sync start -ldir "$LOCAL_DIR" -pdir "$PAN_DIR" -mode "upload" -drive "backup"
```
增加脚本执行权限
@@ -538,8 +654,8 @@ $ nohup ./sync.sh >/dev/null 2>&1 &
<log mode="roll"></log>
</service>
```
4. 将alisync.exe和alisync.xml存放在你PC上aliyunpan.exe工具所在目录,例如:
![](../assets/images/win10-alisync-service.png)
4. 将alisync.exe和alisync.xml存放在你PC上aliyunpan.exe工具所在目录,例如:
![](../assets/images/win10-alisync-service.png)
5. CMD命令行启动程序
```
# 安装服务(只需要第一次安装,后面不用再安装)
@@ -567,13 +683,15 @@ D:\Program Files\aliyunpan>alisync stop
1. 直接运行
```
docker run -d --name=aliyunpan-sync --restart=always -v "<your aliyunpan_config.json>:/home/app/config/aliyunpan_config.json" -v "<your local dir>:/home/app/data" -e ALIYUNPAN_PAN_DIR="<your drive pan dir>" -e ALIYUNPAN_SYNC_MODE="upload" -e ALIYUNPAN_SYNC_DRIVE="backup" tickstep/aliyunpan-sync:<tag>
docker run -d --name=aliyunpan-sync --restart=always -v "<your aliyunpan_config.json>:/home/app/config/aliyunpan_config.json" -v "<your local dir>:/home/app/data" -e ALIYUNPAN_PAN_DIR="<your drive pan dir>" -e ALIYUNPAN_SYNC_MODE="upload" -e ALIYUNPAN_SYNC_POLICY="increment" -e ALIYUNPAN_SYNC_DRIVE="backup" -e ALIYUNPAN_SYNC_LOG="true" tickstep/aliyunpan-sync:<tag>
<your aliyunpan_config.json>: 用户已经登录成功并保存好的aliyunpan_config.json凭据文件
<your local dir>:本地目标目录,绝对路径,例如:/tickstep/Documents/设计文档
ALIYUNPAN_PAN_DIR:云盘目标目录,绝对路径
ALIYUNPAN_SYNC_MODE:备份模式,支持两种: upload(备份本地文件到云盘),download(备份云盘文件到本地)
ALIYUNPAN_SYNC_DRIVE: 网盘,支持两种:backup(备份盘), resource(资源盘)
ALIYUNPAN_SYNC_MODE:备份模式,支持: upload(备份本地文件到云盘),download(备份云盘文件到本地)
ALIYUNPAN_SYNC_POLICY:备份策略,支持:exclusive(排他备份文件,目标目录多余的文件会被删除),increment(增量备份文件,目标目录多余的文件不会被删除)
ALIYUNPAN_SYNC_DRIVE: 网盘,支持:backup(备份盘), resource(资源盘)
ALIYUNPAN_SYNC_LOG: 同步日志,true-开启同步日志显示,false-关闭同步日志
```
2. docker-compose运行
@@ -610,521 +728,23 @@ services:
- ALIYUNPAN_PAN_DIR=/my_sync_dir
# 备份模式:upload(备份本地文件到云盘), download(备份云盘文件到本地)
- ALIYUNPAN_SYNC_MODE=upload
# 备份策略: exclusive(排他备份文件,目标目录多余的文件会被删除),increment(增量备份文件,目标目录多余的文件不会被删除)
- ALIYUNPAN_SYNC_POLICY=increment
# 备份周期, 支持两种: infinity(永久循环备份),onetime(只运行一次备份)
- ALIYUNPAN_SYNC_CYCLE=infinity
# 网盘:backup(备份盘), resource(资源盘)
- ALIYUNPAN_SYNC_DRIVE=backup
# 是否显示文件备份过程日志,true-显示,false-不显示
- ALIYUNPAN_SYNC_LOG=true
# 本地文件修改检测延迟间隔,单位秒。如果本地文件会被频繁修改,例如录制视频文件,配置好该时间可以避免上传未录制好的文件
- ALIYUNPAN_LOCAL_DELAY_TIME=3
- ALIYUNPAN_LOCAL_DELAY_TIME=3
```
3. sync_handler.js插件说明
可以使用JS插件过滤备份的文件。
```javascript
// ==========================================================================================
// aliyunpan JS插件回调处理函数
// 支持 JavaScript ECMAScript 5.1 语言规范
//
// 更多内容请查看官方文档:https://github.com/tickstep/aliyunpan
// ==========================================================================================
// ------------------------------------------------------------------------------------------
// 函数说明:同步备份-扫描本地文件前的回调函数
//
// 参数说明
// context - 当前调用的上下文信息
// {
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "albumDriveId": "29519122"
// }
// appName - 应用名称,当前固定为aliyunpan
// version - 版本号
// userId - 当前登录用户的ID
// nickname - 用户昵称
// fileDriveId - 用户文件网盘ID
// albumDriveId - 用户相册网盘ID
//
// params - 扫描本地文件前参数
// {
// "localFilePath": "D:\\Program Files\\aliyunpan\\Downloads\\token.bat",
// "localFileName": "token.bat",
// "localFileSize": 125330,
// "localFileType": "file",
// "localFileUpdatedAt": "2022-04-14 07:05:12",
// "driveId": "19519221"
// }
// localFilePath - 本地文件绝对完整路径
// localFileName - 本地文件名
// localFileSize - 本地文件大小,单位B
// localFileType - 本地文件类型,file-文件,folder-文件夹
// localFileUpdatedAt - 文件修改时间
// driveId - 备份的目标网盘ID
//
// 返回值说明
// {
// "syncScanLocalApproved": "yes"
// }
// syncScanLocalApproved - 该文件是否确认扫描,yes-允许扫描,no-禁止扫描。
// 禁止扫描的文件不会执行后续的动作,例如上传,下载。
// ------------------------------------------------------------------------------------------
function syncScanLocalFilePrepareCallback(context, params) {
console.log(params);
var result = {
"syncScanLocalApproved": "yes"
};
// 禁止.开头文件上传
if (params["localFileName"].indexOf(".") == 0) {
result["syncScanLocalApproved"] = "no";
}
// 禁止~$开头文件上传(office暂存临时文件)
if (params["localFileName"].indexOf("~$") == 0) {
result["syncScanLocalApproved"] = "no";
}
// 禁止.txt文件上传(正则表达式方式)
if (params["localFileName"].search(/.txt$/i) >= 0) {
result["syncScanLocalApproved"] = "no";
}
// 禁止password.key文件上传
if (params["localFileName"] == "password.key") {
result["syncScanLocalApproved"] = "no";
}
// 禁止@eadir文件上传
if (params["localFileName"] == "@eadir") {
result["syncScanLocalApproved"] = "no";
}
return result;
}
// ------------------------------------------------------------------------------------------
// 函数说明:同步备份-扫描云盘文件前的回调函数
//
// 参数说明
// context - 当前调用的上下文信息
// {
// "appName": "aliyunpan",
// "version": "v0.1.3",
// "userId": "11001d48564f43b3bc5662874f04bb11",
// "nickname": "tickstep",
// "fileDriveId": "19519111",
// "albumDriveId": "29519122"
// }
// appName - 应用名称,当前固定为aliyunpan
// version - 版本号
// userId - 当前登录用户的ID
// nickname - 用户昵称
// fileDriveId - 用户文件网盘ID
// albumDriveId - 用户相册网盘ID
//
// params - 扫描云盘文件前参数
// {
// "driveId": "19519221",
// "driveFileName": "token.bat",
// "driveFilePath": "/aliyunpan/Downloads/token.bat",
// "driveFileSha1": "08FBE28A5B8791A2F50225E2EC5CEEC3C7955A11",
// "driveFileSize": 125330,
// "driveFileType": "file",
// "driveFileUpdatedAt": "2022-04-14 07:05:12"
// }
// driveId - 网盘ID
// driveFileName - 网盘文件名
// driveFilePath - 网盘文件绝对完整路径
// driveFileSize - 网盘文件大小,单位B
// driveFileSha1 - 网盘文件SHA1
// driveFileType - 网盘文件类型,file-文件,folder-文件夹
// driveFileUpdatedAt - 网盘文件修改时间
//
// 返回值说明
// {
// "syncScanPanApproved": "yes"
// }
// syncScanPanApproved - 该文件是否确认扫描,yes-允许扫描,no-禁止扫描。
// 禁止扫描的文件不会执行后续的动作,例如上传,下载。
// ------------------------------------------------------------------------------------------
function syncScanPanFilePrepareCallback(context, params) {
console.log(params);
var result = {
"syncScanPanApproved": "yes"
};
// 禁止.开头文件下载
if (params["driveFileName"].indexOf(".") == 0) {
result["syncScanPanApproved"] = "no";
}
// 禁止~$开头文件下载(office暂存临时文件)
if (params["driveFileName"].indexOf("~$") == 0) {
result["syncScanPanApproved"] = "no";
}
// 禁止.txt文件下载(正则表达式方式)
// if (params["driveFileName"].search(/.txt$/i) >= 0) {
// result["syncScanPanApproved"] = "no";
// }
return result;
}
```
可以使用JS插件过滤备份的文件。更多细节请查看文档:[JavaScript插件手册](https://github.com/tickstep/aliyunpan/blob/main/docs/plugin_manual.md#如何使用)
## JavaScript插件
支持javascript插件,你可以按照自己的需要定制上传/下载中关键步骤的行为,最大程度满足自己的个性化需求。
例如:
1.排除某个特定敏感文件的上传
2.上传文件进行改名,但是本地文件不做更改
3.上传完成文件,删除本地文件
4.上传的文件路径进行更改,但是本地的文件保持不变
5.上传文件成功后,通过HTTP通知其他服务
6.排除某些网盘文件的下载
7.下载的文件进行改名,但是网盘的文件保持不变
8.下载的文件路径进行更改,但是网盘的文件保持不变
9.下载文件完成后,通过HTTP通知其他服务
10.同步备份功能,支持过滤本地文件,或者过滤云盘文件。定制上传或者下载需要同步的文件
11.Token刷新失败或者过期,发送外部通知(HTTP&邮件)
### 如何使用
JS插件的样本文件默认存放在程序所在的plugin/js文件夹下,分为下载(download_handler.js.sample)、上传(upload_handler.js.sample)、同步备份(sync_handler.js.sample)、用户Token(token_handler.js.sample)插件。
建议拷贝一份并将后缀名更改为.js,例如:upload_handler.js,不然插件不会生效。
你必须具备一定的JS语言基础,然后按照里面的样例根据自己所需进行改动即可。
如果你有通过环境变量ALIYUNPAN_CONFIG_DIR设置配置目录,则需要将plugin文件夹拷贝到配置的目录中才可以生效。
### JS中内置的函数
目前只开放了如下函数,你可以在你的js脚本中直接调用
1.console.log()
打印日志
```js
console.log("hello world");
```
2.PluginUtil.Http.get()
发起HTTP的get请求
```js
var header = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36",
"Content-Type": "application/json",
"Accept": "application/json"
};
try {
var r = PluginUtil.Http.get(header, "https://625f528c53a42eaa07f37e13.mockapi.io/files/1");
console.log(r);
} catch (e) {
if (e !== "Error") {
throw e;
}
}
```
3.PluginUtil.Http.post()
发起HTTP的post请求
```js
var header = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36",
"Content-Type": "application/json",
"Accept": "application/json"
};
try {
var reqDataStr = JSON.stringify({
"id": "1",
"localFilePath": "/usr/local/src/borders_burundi_producer.gram.aab",
"localFileSize": 1111,
"uploadApproved": false
});
var r = PluginUtil.Http.post(header, "https://625f528c53a42eaa07f37e13.mockapi.io/files", reqDataStr);
console.log(r);
} catch (e) {
if (e !== "Error") {
throw e;
}
}
```
4.PluginUtil.LocalFS.deleteFile()
删除本地指定文件,不支持文件夹
```js
PluginUtil.LocalFS.deleteFile(params["localFilePath"]);
```
5.PluginUtil.Email.sendTextMail()
发送文本邮件,定义如下
```
PluginUtil.Email.sendTextMail(mailServer, userName, password, to, subject, body)
其中:
mailServer - smtp服务器+端口
userName - 发件人邮箱地址
password - 发件人邮箱密码
to - 收件人邮箱地址
subject - 邮件标题
body - 邮件内容,纯文本
```
样例
```js
PluginUtil.Email.sendTextMail("smtp.qq.com:465", "123xxx@qq.com", "pwdxxxxxx", "12545xxx@qq.com", "文件上传通知", "该文件已经上传完毕");
```
6.PluginUtil.Email.sendHtmlMail()
发送HTML富文本邮件,定义如下
```
PluginUtil.Email.sendHtmlMail(mailServer, userName, password, to, subject, body)
其中:
mailServer - smtp服务器+端口
userName - 发件人邮箱地址
password - 发件人邮箱密码
to - 收件人邮箱地址
subject - 邮件标题
body - 邮件内容,HTML富文本
```
样例
```js
var html = "<html>"
+"<body>"
+"<h1>文件上传通知</h1>"
+"<h2>该文件已经上传完毕</h2>"
+"</body>"
+"</html>";
PluginUtil.Email.sendHtmlMail("smtp.qq.com:465", "123xxx@qq.com", "pwdxxxxxx", "12545xxx@qq.com", "文件上传通知", html);
```
7.PluginUtil.KV.putString()
存储键值对,定义如下
```
PluginUtil.KV.putString(key, value)
其中:
key - 键,字符串
value - 值,字符串
```
样例
```js
PluginUtil.KV.putString("mykey", "1670419352");
```
8.PluginUtil.KV.getString()
获取存储的键值,指定对应的键,返回存储的值,如果没有则返回空字符串,定义如下
```
PluginUtil.KV.getString(key)
其中:
key - 键,字符串
```
样例
```js
var value = PluginUtil.KV.getString("mykey");
```
### 常见场景样例
这里收集了一些常见的需求样例,可以作为插件定制的样例模板。
#### 1.禁止特定文件上传
使用JavaScript上传插件中的`uploadFilePrepareCallback`函数。如下所示:
```js
function uploadFilePrepareCallback(context, params) {
console.log(params)
var result = {
"uploadApproved": "yes",
"driveFilePath": ""
};
if (params["localFileType"] != "file") {
// do nothing
return result;
}
// 禁止点号.开头的文件上传
if (params["localFileName"].indexOf(".") == 0) {
result["uploadApproved"] = "no";
}
// 禁止.txt文件上传
if (params["localFileName"].search(/.txt$/i) >= 0) {
result["uploadApproved"] = "no";
}
// 禁止password.key文件上传
if (params["localFileName"] == "password.key") {
result["uploadApproved"] = "no";
}
return result;
}
```
#### 2.上传文件后删除本地文件
使用JavaScript上传插件中的`uploadFileFinishCallback`函数。如下所示:
```js
function uploadFileFinishCallback(context, params) {
console.log(params);
if (params["localFileType"] != "file") {
// do nothing
return;
}
if (params["uploadResult"] == "success") {
PluginUtil.LocalFS.deleteFile(params["localFilePath"]);
}
}
```
#### 3.下载文件并截断过长的文件名
有些文件的路径或者名称太长,下载的时候可能会由于路径名称过程导致无法下载,这时候可以使用JavaScript下载插件中的`downloadFilePrepareCallback`函数定制下载保存的文件名。
如下所示:
```js
function downloadFilePrepareCallback(context, params) {
console.log(params)
var result = {
"downloadApproved": "yes",
"localFilePath": ""
};
if (params["driveFileType"] != "file") {
return result;
}
// 下面的代码都是分隔路径,方便后面修改路径时使用
var filePath = params["localFilePath"];
filePath = filePath.replace(/\\/g, "/");
// 目录完整路径
var dirPath = "";
// 文件名,不包括后缀名
var fileName = "";
// 文件后缀名
var fileExt = "";
var idx = filePath.lastIndexOf('/');
if (idx > 0) {
dirPath = filePath.substring(0,idx);
fileName = filePath.substring(idx+1,filePath.length);
} else {
fileName = filePath;
}
idx = fileName.lastIndexOf(".")
if (idx > 0) {
fileExt = fileName.substring(idx,fileName.length);
fileName = fileName.substring(0, fileName.length-fileExt.length)
}
// 开始按照需要截断太长的文件路径,例如下面的这个例子:
//
// dirPath + "/" ==> 这个的意思是保留前面的文件夹路径,如果不需要就去掉
// fileName.substr(0,10) ==> 文件名只取前面10个字符,其他的不要了
// + fileExt ==> 把文件的后缀名补上
var saveFilePath = dirPath + "/" + fileName.substr(0,10) + fileExt;
// 返回
result["localFilePath"] = saveFilePath;
return result;
}
```
效果如下:
1)假如网盘源路径是:`/亚马逊书籍合集/亚马逊 kindle ebook 大合集5289册/经济金融 (2)/解密Instagram( 《金融时报》和麦肯锡2020年度商业书籍!社交应用如何改变世界?解锁打造估值千亿美元爆品的核心方法! ) by 莎拉·弗莱尔(z-lib.org).mobi`
2)下载后保存本地的路径是:`D:\test\亚马逊书籍合集\亚马逊 kindle ebook 大合集5289册\经济金融 (2)\解密Instagra.mobi`
```
[1] ----
文件ID: 630f0623e67c0a1d2e554b1994a29108d089f0d5
文件名: 解密Instagram( 《金融时报》和麦肯锡2020年度商业书籍!社交应用如何改变世界?解锁打造估值千亿美元爆品的核心方法! ) by 莎拉·弗莱尔(z-lib.org).mobi
文件类型: 文件
文件路径: /亚马逊书籍合集/亚马逊 kindle ebook 大合集5289册/经济金融 (2)/解密Instagram( 《金融时报》和麦肯锡2020年度商业书籍!社交应用如何改变世界?解锁打造估值千亿美元爆品的核心方法! ) by 莎拉·弗莱尔(z-lib.org).mobi
插件修改文件下载保存路径为: D:\test\亚马逊书籍合集/亚马逊 kindle ebook 大合集5289册/经济金融 (2)/解密Instagra.mobi
[1] 准备下载: /亚马逊书籍合集/亚马逊 kindle ebook 大合集5289册/经济金融 (2)/解密Instagram( 《金融时报》和麦肯锡2020年度商业书籍!社交应用如何改变世界?解锁打造估值千亿美元爆品的核心方法! ) by 莎拉·弗莱尔(z-lib.org).mobi
[1] 将会下载到路径: D:\test\亚马逊书籍合集/亚马逊 kindle ebook 大合集5289册/经济金融 (2)/解密Instagra.mobi
[1] 下载开始
[1] 下载完成, 保存位置: D:\test\亚马逊书籍合集/亚马逊 kindle ebook 大合集5289册/经济金融 (2)/解密Instagra.mobi
[1] 检验文件有效性成功: D:\test\亚马逊书籍合集/亚马逊 kindle ebook 大合集5289册/经济金融 (2)/解密Instagra.mobi
```
#### 4.上传文件去掉文件名包含的部分字符
例如本地文件夹名称为```[周杰伦]范特西[mp3]```,上传到网盘希望能更改成名称```[周杰伦]范特西```但保持本地文件夹名称不变,因为文件夹有很多不希望每个手动更改,可以这样实现:
```js
function uploadFilePrepareCallback(context, params) {
var result = {
"uploadApproved": "yes",
"driveFilePath": ""
};
// 去掉网盘保存路径中包含的[mp3]字段
var filePath = params["driveFilePath"];
filePath = filePath.replace(/\[mp3\]/g, "");
result["driveFilePath"] = filePath;
return result;
}
```
#### 5.Token刷新失败发送外部通知
Token刷新失败发送外部通知,例如Server酱
```js
function userTokenRefreshFinishCallback(context, params) {
var header = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36",
"Content-Type": "application/json",
"Accept": "application/json"
};
try {
if (params["result"] === "fail") {
// 避免频繁发送
var ONE_MINUTE = 60 * 1000;
var lastSendEmailTime = PluginUtil.KV.getString("email_last_send_time");
if (lastSendEmailTime != "") {
if ((Date.now() - lastSendEmailTime) < (10 * ONE_MINUTE)) {
console.log("距离上次发送邮件小于10分钟,先不发送了");
return
}
}
PluginUtil.KV.putString("email_last_send_time", Date.now());
var reqData = {
"text": "Token刷新失败",
"desp": params["message"]
};
var r = PluginUtil.Http.post(header, "https://sctapi.ftqq.com/xxxxxkeyxxxxxx.send", JSON.stringify(reqData));
}
} catch (e) {
if (e !== "Error") {
throw e;
}
}
}
```
或者发送邮件通知
```js
function userTokenRefreshFinishCallback(context, params) {
try {
if (params["result"] === "fail") {
// 避免频繁发送
var ONE_MINUTE = 60 * 1000;
var lastSendEmailTime = PluginUtil.KV.getString("email_last_send_time");
if (lastSendEmailTime != "") {
if ((Date.now() - lastSendEmailTime) < (10 * ONE_MINUTE)) {
console.log("距离上次发送邮件小于10分钟,先不发送了");
return
}
}
PluginUtil.KV.putString("email_last_send_time", Date.now());
// 发送通知邮件,请确保你的发送邮箱具备smtp发送权限,没有的需要找邮箱提供商配置开启
console.log("发送通知邮件");
PluginUtil.Email.sendTextMail("smtp.qq.com:465", "123xxx@qq.com", "pwdxxxxxx", "12545xxx@qq.com", "Token刷新失败了", "Token过期了,骚年快去手动恢复吧");
}
} catch (e) {
if (e !== "Error") {
throw e;
}
}
}
```
本程序支持javascript插件,更多细节请查看文档:[JavaScript插件手册](https://github.com/tickstep/aliyunpan/blob/main/docs/plugin_manual.md)
## 显示和修改程序配置项
```
@@ -1170,4 +790,4 @@ Windows
### 第二步
打开aliyunpan命令行程序,任何云盘命令都有类似如下日志输出
![](../assets/images/debug-log-screenshot.png)
![](../assets/images/debug-log-screenshot.png)
+553
View File
@@ -0,0 +1,553 @@
# 目录
- [简介](#简介)
- [如何使用](#如何使用)
- [JS中内置的函数](#JS中内置的函数)
+ [console.log()](#consolelog)
+ [console.println()](#consoleprintln)
+ [PluginUtil.Http.get()](#PluginUtilHttpget)
+ [PluginUtil.Http.post()](#PluginUtilHttppost)
+ [PluginUtil.LocalFS.deleteFile()](#PluginUtilLocalFSdeleteFile)
+ [PluginUtil.PanFS.deleteFile()](#PluginUtilPanFSdeleteFile)
+ [PluginUtil.Email.sendTextMail()](#PluginUtilEmailsendTextMail)
+ [PluginUtil.Email.sendHtmlMail()](#PluginUtilEmailsendHtmlMail)
+ [PluginUtil.KV.putString()](#PluginUtilKVputString)
+ [PluginUtil.KV.getString()](#PluginUtilKVgetString)
+ [PluginUtil.HashTool.md5Hex()](#PluginUtilHashToolmd5Hex)
- [常见场景样例](#常见场景样例)
+ [1.禁止特定文件上传](#1禁止特定文件上传)
+ [2.上传文件后删除本地文件](#2上传文件后删除本地文件)
+ [3.下载文件并截断过长的文件名](#3下载文件并截断过长的文件名)
+ [4.上传文件去掉文件名包含的部分字符](#4上传文件去掉文件名包含的部分字符)
+ [5.上传文件时过滤指定目录或者文件路径](#5上传文件时过滤指定目录或者文件路径)
+ [6.下载云盘文件到本地后删除云盘对应的文件](#6下载云盘文件到本地后删除云盘对应的文件)
+ [7.Token刷新失败发送外部通知](#7Token刷新失败发送外部通知)
+ [8.每次只下载指定数量的文件](#8每次只下载指定数量的文件)
# 简介
本程序支持javascript插件。通过JS插件,你可以按照自己的需要定制上传、下载、同步、删除过程中关键步骤的行为,最大程度满足自己的个性化需求。
例如:
1. 排除某个特定敏感文件的上传
2. 上传文件进行改名,但是本地文件不做更改
3. 上传完成文件,删除本地文件
4. 上传的文件路径进行更改,但是本地的文件保持不变
5. 上传文件成功后,通过HTTP通知其他服务
6. 排除某些网盘文件的下载
7. 下载的文件进行改名,但是网盘的文件保持不变
8. 下载的文件路径进行更改,但是网盘的文件保持不变
9. 下载文件完成后,通过HTTP通知其他服务
10. 同步备份功能,支持过滤本地文件,或者过滤云盘文件。定制上传或者下载需要同步的文件
# 如何使用
JS插件的样本文件默认存放在程序所在的```plugin/js```文件夹下,分别为:
1. 下载插件(download_handler.js.sample)
2. 上传插件(upload_handler.js.sample)
3. 删除插件(remove_handler.js.sample)
4. 同步备份插件(sync_handler.js.sample)
5. 用户Token插件(token_handler.js.sample)
建议拷贝一份并将后缀名更改为.js,例如:upload_handler.js,不然插件不会生效。
你必须具备一定的JS语言基础,然后按照里面的样例根据自己所需进行改动即可。如果你不会JS那也没关系,你可以提issue需求,然后我们开发成员或者网友会给你提供JS脚本代码。
注意:如果你有通过环境变量```ALIYUNPAN_CONFIG_DIR```设置配置目录,则需要将plugin文件夹拷贝到配置的目录中才可以生效。
# JS中内置的函数
目前开放了如下函数,你可以在你的js脚本中直接调用,以用于增强JS脚本的扩展性、可玩性以及可适用性。
## console.log()
打印日志,这个日志需要开启debug日志才会在控制台窗口显示
```js
console.log("hello world");
```
## console.println()
打印日志,这个日志会直接在控制台窗口显示,无需开启debug日志
```js
console.println("hello world");
```
## PluginUtil.Http.get()
发起HTTP的get请求
```js
var header = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36",
"Content-Type": "application/json",
"Accept": "application/json"
};
try {
var r = PluginUtil.Http.get(header, "https://625f528c53a42eaa07f37e13.mockapi.io/files/1");
console.log(r);
} catch (e) {
if (e !== "Error") {
throw e;
}
}
```
## PluginUtil.Http.post()
发起HTTP的post请求
```js
var header = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36",
"Content-Type": "application/json",
"Accept": "application/json"
};
try {
var reqDataStr = JSON.stringify({
"id": "1",
"localFilePath": "/usr/local/src/borders_burundi_producer.gram.aab",
"localFileSize": 1111,
"uploadApproved": false
});
var r = PluginUtil.Http.post(header, "https://625f528c53a42eaa07f37e13.mockapi.io/files", reqDataStr);
console.log(r);
} catch (e) {
if (e !== "Error") {
throw e;
}
}
```
## PluginUtil.LocalFS.deleteFile()
删除本地指定文件,不支持文件夹
```
PluginUtil.LocalFS.deleteFile(localFilePath);
其中:
localFilePath - 本地文件的绝对完整路径
```
样例
```js
PluginUtil.LocalFS.deleteFile("/Users/tickstep/Downloads/IMG_0884.HEIC");
```
## PluginUtil.PanFS.deleteFile()
删除云盘指定文件,支持文件、文件夹
```
PluginUtil.PanFS.deleteFile(userId, driveId, panFileId);
其中:
userId - 登录的用户ID
driveId - 网盘ID
panFileId - 网盘文件ID
```
样例
```js
var userId = context["userId"]
var driveId = params["driveId"]
var driveFileId = params["driveFileId"]
if (PluginUtil.PanFS.deleteFile(userId, driveId, driveFileId)) {
console.println("插件删除云盘文件成功")
}
```
## PluginUtil.Email.sendTextMail()
发送文本邮件,定义如下
```
PluginUtil.Email.sendTextMail(mailServer, userName, password, to, subject, body)
其中:
mailServer - smtp服务器+端口
userName - 发件人邮箱地址
password - 发件人邮箱密码
to - 收件人邮箱地址
subject - 邮件标题
body - 邮件内容,纯文本
```
样例
```js
PluginUtil.Email.sendTextMail("smtp.qq.com:465", "123xxx@qq.com", "pwdxxxxxx", "12545xxx@qq.com", "文件上传通知", "该文件已经上传完毕");
```
## PluginUtil.Email.sendHtmlMail()
发送HTML富文本邮件,定义如下
```
PluginUtil.Email.sendHtmlMail(mailServer, userName, password, to, subject, body)
其中:
mailServer - smtp服务器+端口
userName - 发件人邮箱地址
password - 发件人邮箱密码
to - 收件人邮箱地址
subject - 邮件标题
body - 邮件内容,HTML富文本
```
样例
```js
var html = "<html>"
+"<body>"
+"<h1>文件上传通知</h1>"
+"<h2>该文件已经上传完毕</h2>"
+"</body>"
+"</html>";
PluginUtil.Email.sendHtmlMail("smtp.qq.com:465", "123xxx@qq.com", "pwdxxxxxx", "12545xxx@qq.com", "文件上传通知", html);
```
## PluginUtil.KV.putString()
存储键值对,定义如下
```
PluginUtil.KV.putString(key, value)
其中:
key - 键,字符串
value - 值,字符串
```
样例
```js
PluginUtil.KV.putString("mykey", "1670419352");
```
## PluginUtil.KV.getString()
获取存储的键值,指定对应的键,返回存储的值,如果没有则返回空字符串,定义如下
```
PluginUtil.KV.getString(key)
其中:
key - 键,字符串
```
样例
```js
var value = PluginUtil.KV.getString("mykey");
```
## PluginUtil.HashTool.md5Hex()
计算指定字符串text的MD5值,返回hex格式的字符串MD5,定义如下
```
PluginUtil.HashTool.md5Hex(text)
其中:
text - 字符串
```
样例
```js
var md5 = PluginUtil.HashTool.md5Hex("123456");
```
# 常见场景样例
这里收集了一些常见的需求样例,可以作为插件定制的样例模板。
## 1.禁止特定文件上传
使用JavaScript上传插件中的`uploadFilePrepareCallback`函数。如下所示:
```js
function uploadFilePrepareCallback(context, params) {
console.log(params)
var result = {
"uploadApproved": "yes",
"driveFilePath": ""
};
if (params["localFileType"] != "file") {
// do nothing
return result;
}
// 禁止点号.开头的文件上传
if (params["localFileName"].indexOf(".") == 0) {
result["uploadApproved"] = "no";
}
// 禁止.txt文件上传
if (params["localFileName"].search(/.txt$/i) >= 0) {
result["uploadApproved"] = "no";
}
// 禁止password.key文件上传
if (params["localFileName"] == "password.key") {
result["uploadApproved"] = "no";
}
return result;
}
```
## 2.上传文件后删除本地文件
使用JavaScript上传插件中的`uploadFileFinishCallback`函数。如下所示:
```js
function uploadFileFinishCallback(context, params) {
console.log(params);
if (params["localFileType"] != "file") {
// do nothing
return;
}
if (params["uploadResult"] == "success") {
PluginUtil.LocalFS.deleteFile(params["localFilePath"]);
}
}
```
## 3.下载文件并截断过长的文件名
有些文件的路径或者名称太长,下载的时候可能会由于路径名称过程导致无法下载,这时候可以使用JavaScript下载插件中的`downloadFilePrepareCallback`函数定制下载保存的文件名。
如下所示:
```js
function downloadFilePrepareCallback(context, params) {
console.log(params)
var result = {
"downloadApproved": "yes",
"localFilePath": ""
};
if (params["driveFileType"] != "file") {
return result;
}
// 下面的代码都是分隔路径,方便后面修改路径时使用
var filePath = params["localFilePath"];
filePath = filePath.replace(/\\/g, "/");
// 目录完整路径
var dirPath = "";
// 文件名,不包括后缀名
var fileName = "";
// 文件后缀名
var fileExt = "";
var idx = filePath.lastIndexOf('/');
if (idx > 0) {
dirPath = filePath.substring(0,idx);
fileName = filePath.substring(idx+1,filePath.length);
} else {
fileName = filePath;
}
idx = fileName.lastIndexOf(".")
if (idx > 0) {
fileExt = fileName.substring(idx,fileName.length);
fileName = fileName.substring(0, fileName.length-fileExt.length)
}
// 开始按照需要截断太长的文件路径,例如下面的这个例子:
//
// dirPath + "/" ==> 这个的意思是保留前面的文件夹路径,如果不需要就去掉
// fileName.substr(0,10) ==> 文件名只取前面10个字符,其他的不要了
// + fileExt ==> 把文件的后缀名补上
var saveFilePath = dirPath + "/" + fileName.substr(0,10) + fileExt;
// 返回
result["localFilePath"] = saveFilePath;
return result;
}
```
效果如下:
1)假如网盘源路径是:`/亚马逊书籍合集/亚马逊 kindle ebook 大合集5289册/经济金融 (2)/解密Instagram( 《金融时报》和麦肯锡2020年度商业书籍!社交应用如何改变世界?解锁打造估值千亿美元爆品的核心方法! ) by 莎拉·弗莱尔(z-lib.org).mobi`
2)下载后保存本地的路径是:`D:\test\亚马逊书籍合集\亚马逊 kindle ebook 大合集5289册\经济金融 (2)\解密Instagra.mobi`
```
[1] ----
文件ID: 630f0623e67c0a1d2e554b1994a29108d089f0d5
文件名: 解密Instagram( 《金融时报》和麦肯锡2020年度商业书籍!社交应用如何改变世界?解锁打造估值千亿美元爆品的核心方法! ) by 莎拉·弗莱尔(z-lib.org).mobi
文件类型: 文件
文件路径: /亚马逊书籍合集/亚马逊 kindle ebook 大合集5289册/经济金融 (2)/解密Instagram( 《金融时报》和麦肯锡2020年度商业书籍!社交应用如何改变世界?解锁打造估值千亿美元爆品的核心方法! ) by 莎拉·弗莱尔(z-lib.org).mobi
插件修改文件下载保存路径为: D:\test\亚马逊书籍合集/亚马逊 kindle ebook 大合集5289册/经济金融 (2)/解密Instagra.mobi
[1] 准备下载: /亚马逊书籍合集/亚马逊 kindle ebook 大合集5289册/经济金融 (2)/解密Instagram( 《金融时报》和麦肯锡2020年度商业书籍!社交应用如何改变世界?解锁打造估值千亿美元爆品的核心方法! ) by 莎拉·弗莱尔(z-lib.org).mobi
[1] 将会下载到路径: D:\test\亚马逊书籍合集/亚马逊 kindle ebook 大合集5289册/经济金融 (2)/解密Instagra.mobi
[1] 下载开始
[1] 下载完成, 保存位置: D:\test\亚马逊书籍合集/亚马逊 kindle ebook 大合集5289册/经济金融 (2)/解密Instagra.mobi
[1] 检验文件有效性成功: D:\test\亚马逊书籍合集/亚马逊 kindle ebook 大合集5289册/经济金融 (2)/解密Instagra.mobi
```
## 4.上传文件去掉文件名包含的部分字符
例如本地文件夹名称为```[周杰伦]范特西[mp3]```,上传到网盘希望能更改成名称```[周杰伦]范特西```但保持本地文件夹名称不变,因为文件夹有很多不希望每个手动更改,可以这样实现:
```js
function uploadFilePrepareCallback(context, params) {
var result = {
"uploadApproved": "yes",
"driveFilePath": ""
};
// 去掉网盘保存路径中包含的[mp3]字段
var filePath = params["driveFilePath"];
filePath = filePath.replace(/\[mp3\]/g, "");
result["driveFilePath"] = filePath;
return result;
}
```
## 5.上传文件时过滤指定目录或者文件路径
upload命令本身支持exn参数排除上传文件或目录,但是只能指定文件名称,不能指定文件的路径。如果需要排除指定路径则可以通过插件脚本实现,样例如下:
```js
function uploadFilePrepareCallback(context, params) {
//(自行配置)禁止上传的本地【目录列表】,使用绝对路径,可以配置多个路径用逗号分隔
var forbiddenUploadFolders = ["/Users/tickstep/Downloads/up/target","/Users/tickstep/Downloads/up/.idea"]
//(自行配置)禁止上传的本地【文件列表】,使用绝对路径,可以配置多个路径用逗号分隔
var forbiddenUploadFiles = ["/Users/tickstep/Downloads/up/pom.xml"]
// -------------------- 以下代码不要修改 --------------------
var result = {
"uploadApproved": "yes",
"driveFilePath": ""
};
// 下面的代码都是分隔路径,方便后面使用
var filePath = params["localFilePath"];
filePath = filePath.replace(/\\/g, "/");
// 目录完整路径
var dirPath = "";
// 文件名,不包括后缀名
var fileName = "";
// 文件后缀名
var fileExt = "";
var idx = filePath.lastIndexOf('/');
if (idx > 0) {
dirPath = filePath.substring(0,idx);
fileName = filePath.substring(idx+1,filePath.length);
} else {
fileName = filePath;
}
idx = fileName.lastIndexOf(".")
if (idx > 0) {
fileExt = fileName.substring(idx,fileName.length);
fileName = fileName.substring(0, fileName.length-fileExt.length)
}
if (params["localFileType"] == "file") {
for (var i = 0; i < forbiddenUploadFiles.length; i++) {
if (forbiddenUploadFiles[i].replace(/\\/g, "/") == params["localFilePath"]) {
result["uploadApproved"] = "no"; // 禁止文件上传
break
}
}
for (var i = 0; i < forbiddenUploadFolders.length; i++) {
if (forbiddenUploadFolders[i].replace(/\\/g, "/") == dirPath) {
result["uploadApproved"] = "no"; // 禁止文件上传
break
}
}
} else if (params["localFileType"] == "folder") {
for (var i = 0; i < forbiddenUploadFolders.length; i++) {
if (forbiddenUploadFolders[i].replace(/\\/g, "/") == params["localFilePath"]) {
result["uploadApproved"] = "no"; // 禁止文件上传
break
}
}
}
return result;
}
```
## 6.下载云盘文件到本地后删除云盘对应的文件
```js
function downloadFileFinishCallback(context, params) {
console.log(params)
// 云盘文件成功下载到本地后,删除云盘的文件
if (params["downloadResult"] == "success") {
if (params["driveFileType"] == "file") {
// 文件下载成功,删除该云盘文件
var userId = context["userId"]
var driveId = params["driveId"]
var driveFileId = params["driveFileId"]
if (PluginUtil.PanFS.deleteFile(userId, driveId, driveFileId)) {
console.println("插件删除云盘文件成功:" + params["driveFilePath"])
}
}
}
}
```
## 7.Token刷新失败发送外部通知
Token刷新失败发送外部通知,例如Server酱
```js
function userTokenRefreshFinishCallback(context, params) {
var header = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36",
"Content-Type": "application/json",
"Accept": "application/json"
};
try {
if (params["result"] === "fail") {
// 避免频繁发送
var ONE_MINUTE = 60 * 1000;
var lastSendEmailTime = PluginUtil.KV.getString("email_last_send_time");
if (lastSendEmailTime != "") {
if ((Date.now() - lastSendEmailTime) < (10 * ONE_MINUTE)) {
console.log("距离上次发送邮件小于10分钟,先不发送了");
return
}
}
PluginUtil.KV.putString("email_last_send_time", Date.now());
var reqData = {
"text": "Token刷新失败",
"desp": params["message"]
};
var r = PluginUtil.Http.post(header, "https://sctapi.ftqq.com/xxxxxkeyxxxxxx.send", JSON.stringify(reqData));
}
} catch (e) {
if (e !== "Error") {
throw e;
}
}
}
```
或者发送邮件通知
```js
function userTokenRefreshFinishCallback(context, params) {
try {
if (params["result"] === "fail") {
// 避免频繁发送
var ONE_MINUTE = 60 * 1000;
var lastSendEmailTime = PluginUtil.KV.getString("email_last_send_time");
if (lastSendEmailTime != "") {
if ((Date.now() - lastSendEmailTime) < (10 * ONE_MINUTE)) {
console.log("距离上次发送邮件小于10分钟,先不发送了");
return
}
}
PluginUtil.KV.putString("email_last_send_time", Date.now());
// 发送通知邮件,请确保你的发送邮箱具备smtp发送权限,没有的需要找邮箱提供商配置开启
console.log("发送通知邮件");
PluginUtil.Email.sendTextMail("smtp.qq.com:465", "123xxx@qq.com", "pwdxxxxxx", "12545xxx@qq.com", "Token刷新失败了", "Token过期了,骚年快去手动恢复吧");
}
} catch (e) {
if (e !== "Error") {
throw e;
}
}
}
```
## 8.每次只下载指定数量的文件
每次运行download下载命令,只下载指定数量的文件,只要数量够了其他文件就跳过不再下载,其他文件等下一次运行download命令的时候再下载。
```js
function downloadFilePrepareCallback(context, params) {
var result = {
"downloadApproved": "yes",
"localFilePath": ""
};
// 这次下载限制的最大文件总数量
const maxCountOfDownloadAction = 3;
// 只处理文件
if (params["driveFileType"] != "file") {
return
}
// 获取这次下载动作,已经下载的文件数量
var keyOfThisDownloadAction = "download:" + params["downloadActionId"];
var valueOfThisDownloadAction = PluginUtil.KV.getString(keyOfThisDownloadAction);
if (valueOfThisDownloadAction == "") {
valueOfThisDownloadAction = "0";
}
var countOfThisDownloadAction = parseInt(valueOfThisDownloadAction);
if (countOfThisDownloadAction >= maxCountOfDownloadAction) {
// 下载数量已达到最大,其他文件不再下载
result["downloadApproved"] = "no";
return result;
}
// 该文件需要下载,记录相关信息
var keyOfThisDownloadFile = "file:" + PluginUtil.HashTool.md5Hex(params["driveFilePath"]);
var valueOfThisDownloadFile = PluginUtil.KV.getString(keyOfThisDownloadFile);
if (valueOfThisDownloadFile == "finish") {
// 已经在下载的文件不做处理
return result;
}
PluginUtil.KV.putString(keyOfThisDownloadFile, "downloading");
// 更新下载总数量
countOfThisDownloadAction += 1;
PluginUtil.KV.putString(keyOfThisDownloadAction, String(countOfThisDownloadAction));
return result;
}
function downloadFileFinishCallback(context, params) {
var keyOfThisDownloadFile = "file:" + PluginUtil.HashTool.md5Hex(params["driveFilePath"]);
console.log(keyOfThisDownloadFile)
PluginUtil.KV.putString(keyOfThisDownloadFile, "finish");
}
```
+5 -4
View File
@@ -1,9 +1,10 @@
module github.com/tickstep/aliyunpan
go 1.20
go 1.22
require (
github.com/GeertJohan/go.incremental v1.0.0
github.com/adrg/xdg v0.4.0
github.com/deckarep/golang-set v1.8.0
github.com/dop251/goja v0.0.0-20220408131256-ffe77e20c6f1
github.com/jordan-wright/email v0.0.0-20200602115436-fd8a7622303e
@@ -13,10 +14,11 @@ require (
github.com/olekukonko/tablewriter v0.0.2-0.20190618033246-cc27d85e17ce
github.com/peterh/liner v1.2.1
github.com/satori/go.uuid v1.2.0
github.com/tickstep/aliyunpan-api v0.2.0
github.com/tickstep/aliyunpan-api v0.2.9
github.com/tickstep/bolt v1.3.4
github.com/tickstep/library-go v0.1.1
github.com/tickstep/library-go v0.1.3
github.com/urfave/cli v1.21.1-0.20190817182405-23c83030263f
golang.org/x/sys v0.15.0
)
require (
@@ -31,7 +33,6 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/russross/blackfriday v1.5.2 // indirect
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect
golang.org/x/sys v0.0.0-20210423082822-04245dca01da // indirect
golang.org/x/text v0.3.7 // indirect
)
+11 -8
View File
@@ -1,6 +1,8 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/GeertJohan/go.incremental v1.0.0 h1:7AH+pY1XUgQE4Y1HcXYaMqAI0m9yrFqo/jt0CW30vsg=
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls=
github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
@@ -96,15 +98,15 @@ github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJ
github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tickstep/aliyunpan-api v0.2.0 h1:CIejpKnqcOiKmpj8ptr8Oa+iiHHsFo805ysrPgDPakc=
github.com/tickstep/aliyunpan-api v0.2.0/go.mod h1:SN+N5Vh3lLq3dVimlxKxviCl50gLGXZO96nHHc3qd2o=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tickstep/aliyunpan-api v0.2.9 h1:0n7BNjmKEi/JYqNLhotBnCQJmJE3OAqiDX28q0Gknxs=
github.com/tickstep/aliyunpan-api v0.2.9/go.mod h1:UvQgQYMIgA5k1QsfKIVnEJ2h/HqDXIkAb16oookNc8o=
github.com/tickstep/bolt v1.3.4 h1:UN1txRsauOfR8a5mUWm5So0ng233PsIMVR5/+3WowCE=
github.com/tickstep/bolt v1.3.4/go.mod h1:Y24RwCywbOsKAyHiVcJ4K2QQfED+q67x7PEOH9OG7Q4=
github.com/tickstep/library-go v0.1.0/go.mod h1:uAHeNOIpoywCzlaeLrWmmRSupn03m9kJVZKOEmuarmA=
github.com/tickstep/library-go v0.1.1 h1:xPAyddCJkZy5S3qUxCg0hoEG9PR8/rdIQiLMrt2OtWI=
github.com/tickstep/library-go v0.1.1/go.mod h1:uAHeNOIpoywCzlaeLrWmmRSupn03m9kJVZKOEmuarmA=
github.com/tickstep/library-go v0.1.3 h1:OUj6nkimTsqhHXh5s+PbmHq5hR0m739Y1tu9QpfA2ng=
github.com/tickstep/library-go v0.1.3/go.mod h1:uAHeNOIpoywCzlaeLrWmmRSupn03m9kJVZKOEmuarmA=
github.com/urfave/cli v1.21.1-0.20190817182405-23c83030263f h1:xKDKjIsL76VUyHcA0G4Qe1cIAUB/nrq6Pt8D411bd1g=
github.com/urfave/cli v1.21.1-0.20190817182405-23c83030263f/go.mod h1:qXyCeJubPqsgeiLd3kvHOGHHSrQcNdjZ2ScXIcVZK/I=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@@ -121,8 +123,9 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+137 -443
View File
@@ -14,46 +14,36 @@
package command
import (
"encoding/json"
"fmt"
"github.com/olekukonko/tablewriter"
"github.com/tickstep/aliyunpan-api/aliyunpan"
"github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
"github.com/tickstep/aliyunpan-api/aliyunpan_web"
"github.com/tickstep/aliyunpan/cmder"
"github.com/tickstep/aliyunpan/cmder/cmdtable"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/tickstep/aliyunpan/internal/file/downloader"
"github.com/tickstep/aliyunpan/internal/functions/pandownload"
"github.com/tickstep/aliyunpan/internal/global"
"github.com/tickstep/aliyunpan/internal/taskframework"
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/aliyunpan/library/requester/transfer"
"github.com/tickstep/library-go/converter"
"github.com/tickstep/library-go/logger"
"github.com/tickstep/library-go/requester"
"github.com/tickstep/library-go/requester/rio/speeds"
"github.com/urfave/cli"
"os"
"path/filepath"
"strconv"
"sync/atomic"
"strings"
"time"
)
type (
AlbumFileCategoryOption string
)
var (
ImageOnlyOption AlbumFileCategoryOption = "image"
VideoOnlyOption AlbumFileCategoryOption = "video"
ImageVideoOnlyOption AlbumFileCategoryOption = "image_video"
AllFileOption AlbumFileCategoryOption = "none"
)
func CmdAlbum() cli.Command {
return cli.Command{
Name: "album",
Aliases: []string{"abm"},
Usage: "个人相册(Beta)",
Usage: "共享相册",
UsageText: cmder.App().Name + " album",
Category: "阿里云盘",
Before: ReloadConfigFunc,
@@ -66,12 +56,11 @@ func CmdAlbum() cli.Command {
{
Name: "list",
Aliases: []string{"ls"},
Usage: "展示相簿列表",
Usage: "展示共享相簿列表",
UsageText: cmder.App().Name + " album list",
Description: `
示例:
展示相簿列表
展示共享相簿列表
aliyunpan album ls
`,
Action: func(c *cli.Context) error {
@@ -79,94 +68,7 @@ func CmdAlbum() cli.Command {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
RunAlbumList()
return nil
},
Flags: []cli.Flag{},
},
{
Name: "new",
Aliases: []string{""},
Usage: "创建相簿",
UsageText: cmder.App().Name + " album new",
Description: `
示例:
新建相簿,名称为:我的相簿2022
aliyunpan album new "我的相簿2022"
新建相簿,名称为:我的相簿2022,描述为:存放2022所有文件
aliyunpan album new "我的相簿2022" "存放2022所有文件"
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
RunAlbumCreate(c.Args().Get(0), c.Args().Get(1))
return nil
},
Flags: []cli.Flag{},
},
{
Name: "rm",
Aliases: []string{""},
Usage: "删除相簿",
UsageText: cmder.App().Name + " album rm",
Description: `
删除相簿,同名的相簿只会删除第一个符合条件的
示例:
删除名称为"我的相簿2022"的相簿
aliyunpan album rm "我的相簿2022"
删除名称为"我的相簿2022-1" 和 "我的相簿2022-2"的相簿
aliyunpan album rm "我的相簿2022-1" "我的相簿2022-2"
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
RunAlbumDelete(c.Args())
return nil
},
Flags: []cli.Flag{},
},
{
Name: "rename",
Aliases: []string{""},
Usage: "重命名相簿",
UsageText: cmder.App().Name + " album rename",
Description: `
重命名相簿,同名的相簿只会修改第一个符合条件的
示例:
重命名相簿"我的相簿2022"为新的名称"我的相簿2022-new"
aliyunpan album rename "我的相簿2022" "我的相簿2022-new"
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
RunAlbumRename(c.Args().Get(0), c.Args().Get(1))
RunShareAlbumList()
return nil
},
Flags: []cli.Flag{},
@@ -188,76 +90,7 @@ func CmdAlbum() cli.Command {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
RunAlbumListFile(c.Args().Get(0))
return nil
},
Flags: []cli.Flag{},
},
{
Name: "rm-file",
Aliases: []string{"rf"},
Usage: "移除相簿中的文件",
UsageText: cmder.App().Name + " album rm-file",
Description: `
移除相簿中的文件,同名的相簿只会移除第一个符合条件的
示例:
移除相簿 "我的相簿2022" 中的文件 1.png 2.png
aliyunpan album rm-file 我的相簿2022 1.png 2.png
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
subArgs := c.Args()
if len(subArgs) < 2 {
fmt.Println("请指定移除的文件")
return nil
}
RunAlbumRmFile(subArgs[0], subArgs[1:])
return nil
},
Flags: []cli.Flag{},
},
{
Name: "add-file",
Aliases: []string{"af"},
Usage: "增加(文件/相册)网盘文件到相簿中",
UsageText: cmder.App().Name + " album add-file",
Description: `
增加文件到相簿中
示例:
增加当前目录下的 1.png 2.png 文件到相簿 "我的相簿2022" 中
aliyunpan album add-file 我的相簿2022 1.png 2.png
增加当前目录下的 myFolder 文件夹下所有文件到相簿 "我的相簿2022" 中
aliyunpan album add-file 我的相簿2022 myFolder
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
subArgs := c.Args()
if len(subArgs) < 2 {
fmt.Println("请指定增加的文件")
return nil
}
RunAlbumAddFile(subArgs[0], subArgs[1:], ImageVideoOnlyOption)
RunShareAlbumListFile(c.Args().Get(0))
return nil
},
Flags: []cli.Flag{},
@@ -273,17 +106,12 @@ func CmdAlbum() cli.Command {
下载相簿 "我的相簿2022" 里面的所有文件
aliyunpan album download-file 我的相簿2022
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
subArgs := c.Args()
if len(subArgs) == 0 {
fmt.Println("请指定下载的相簿名称")
@@ -304,6 +132,7 @@ func CmdAlbum() cli.Command {
IsOverwrite: c.Bool("ow"),
SaveTo: saveTo,
Parallel: 0,
SliceParallel: 0,
Load: 0,
MaxRetry: pandownload.DefaultDownloadMaxRetry,
NoCheck: false,
@@ -312,7 +141,7 @@ func CmdAlbum() cli.Command {
ExcludeNames: []string{},
}
RunAlbumDownloadFile(c.Args(), do)
RunShareAlbumDownloadFile(c.Args(), do)
return nil
},
Flags: []cli.Flag{
@@ -334,76 +163,25 @@ func CmdAlbum() cli.Command {
}
}
func RunAlbumList() {
func RunShareAlbumList() {
activeUser := GetActiveUser()
records, err := activeUser.PanClient().WebapiPanClient().AlbumListGetAll(&aliyunpan_web.AlbumListParam{})
records, err := activeUser.PanClient().OpenapiPanClient().ShareAlbumListGetAll()
if err != nil {
fmt.Printf("获取相簿列表失败: %s\n", err)
return
}
tb := cmdtable.NewTable(os.Stdout)
tb.SetHeader([]string{"#", "ALBUM_ID", "名称", "文件数量", "创建日期", "修改日期"})
tb.SetColumnAlignment([]int{tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_CENTER, tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_DEFAULT})
tb.SetHeader([]string{"#", "ALBUM_ID", "名称", "更新日期", "创建日期"})
tb.SetColumnAlignment([]int{tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_DEFAULT})
for k, record := range records {
tb.Append([]string{strconv.Itoa(k + 1), record.AlbumId, record.Name, strconv.Itoa(record.FileCount),
record.CreatedAtStr(), record.UpdatedAtStr()})
tb.Append([]string{strconv.Itoa(k + 1), record.AlbumId, record.Name, record.UpdatedAtStr(), record.CreatedAtStr()})
}
tb.Render()
}
func RunAlbumCreate(name, description string) {
if name == "" {
fmt.Printf("相簿名称不能为空\n")
return
}
activeUser := GetActiveUser()
_, err := activeUser.PanClient().WebapiPanClient().AlbumCreate(&aliyunpan_web.AlbumCreateParam{
Name: name,
Description: description,
})
if err != nil {
fmt.Printf("创建相簿失败: %s\n", err)
return
}
fmt.Printf("创建相簿成功: %s\n", name)
}
func RunAlbumDelete(nameList []string) {
if len(nameList) == 0 {
fmt.Printf("相簿名称不能为空\n")
return
}
activeUser := GetActiveUser()
records, err := activeUser.PanClient().WebapiPanClient().AlbumListGetAll(&aliyunpan_web.AlbumListParam{})
if err != nil {
fmt.Printf("获取相簿列表失败: %s\n", err)
return
}
for _, record := range records {
for i, name := range nameList {
if name == record.Name {
nameList = append(nameList[:i], nameList[i+1:]...)
_, err := activeUser.PanClient().WebapiPanClient().AlbumDelete(&aliyunpan_web.AlbumDeleteParam{
AlbumId: record.AlbumId,
})
if err != nil {
fmt.Printf("删除相簿失败: %s\n", name)
return
} else {
fmt.Printf("删除相簿成功: %s\n", name)
}
break
}
}
}
}
func getAlbumFromName(activeUser *config.PanUser, name string) *aliyunpan_web.AlbumEntity {
records, err := activeUser.PanClient().WebapiPanClient().AlbumListGetAll(&aliyunpan_web.AlbumListParam{})
func getShareAlbumFromName(activeUser *config.PanUser, name string) *aliyunpan.AlbumEntity {
records, err := activeUser.PanClient().OpenapiPanClient().ShareAlbumListGetAll()
if err != nil {
fmt.Printf("获取相簿列表失败: %s\n", err)
return nil
@@ -417,48 +195,21 @@ func getAlbumFromName(activeUser *config.PanUser, name string) *aliyunpan_web.Al
return nil
}
func RunAlbumRename(name, newName string) {
if len(name) == 0 {
fmt.Printf("相簿名称不能为空\n")
return
}
if len(newName) == 0 {
fmt.Printf("相簿名称不能为空\n")
return
}
activeUser := GetActiveUser()
record := getAlbumFromName(activeUser, name)
if record == nil {
return
}
_, err := activeUser.PanClient().WebapiPanClient().AlbumEdit(&aliyunpan_web.AlbumEditParam{
AlbumId: record.AlbumId,
Description: record.Description,
Name: newName,
})
if err != nil {
fmt.Printf("重命名相簿失败: %s\n", name)
return
} else {
fmt.Printf("重命名相簿成功: %s -> %s\n", name, newName)
}
}
func RunAlbumListFile(name string) {
func RunShareAlbumListFile(name string) {
if len(name) == 0 {
fmt.Printf("相簿名称不能为空\n")
return
}
activeUser := GetActiveUser()
record := getAlbumFromName(activeUser, name)
record := getShareAlbumFromName(activeUser, name)
if record == nil {
return
}
fileList, er := activeUser.PanClient().WebapiPanClient().AlbumListFileGetAll(&aliyunpan_web.AlbumListFileParam{
fileList, er := activeUser.PanClient().OpenapiPanClient().ShareAlbumListFileGetAll(&aliyunpan.ShareAlbumListFileParam{
AlbumId: record.AlbumId,
Limit: 100,
})
if er != nil {
fmt.Printf("获取相簿文件列表失败:%s\n", er)
@@ -467,174 +218,13 @@ func RunAlbumListFile(name string) {
renderTable(opLs, false, "", fileList)
}
func RunAlbumRmFile(name string, nameList []string) {
if len(name) == 0 {
fmt.Printf("相簿名称不能为空\n")
return
}
if len(nameList) == 0 {
fmt.Printf("指定文件不能为空\n")
return
}
activeUser := GetActiveUser()
album := getAlbumFromName(activeUser, name)
if album == nil {
return
}
fileList, er := activeUser.PanClient().WebapiPanClient().AlbumListFileGetAll(&aliyunpan_web.AlbumListFileParam{
AlbumId: album.AlbumId,
})
if er != nil {
fmt.Printf("获取相簿文件列表失败:%s\n", er)
return
}
param := &aliyunpan_web.AlbumDeleteFileParam{
AlbumId: album.AlbumId,
DriveFileList: []aliyunpan.FileBatchActionParam{},
}
for _, file := range fileList {
if len(nameList) == 0 {
break
}
for i, name := range nameList {
if name == file.FileName {
nameList = append(nameList[:i], nameList[i+1:]...)
param.AddFileItem(file.DriveId, file.FileId)
break
}
}
}
// 1-500 范围
if len(param.DriveFileList) == 0 {
fmt.Printf("没有符合的文件\n")
return
}
// delete file
_, e := activeUser.PanClient().WebapiPanClient().AlbumDeleteFile(param)
if e != nil {
fmt.Printf("删除相簿文件失败:%s\n", e)
return
}
fmt.Printf("删除相簿文件成功:%s\n", name)
}
// RunAlbumAddFile 增加网盘文件到相簿
func RunAlbumAddFile(albumName string, filePathList []string, filterOption AlbumFileCategoryOption) {
activeUser := GetActiveUser()
if albumName == "" {
fmt.Printf("必须指定相簿名称\n")
return
}
album := getAlbumFromName(activeUser, albumName)
if album == nil {
fmt.Printf("相簿不存在\n")
return
}
paths, err := makePathAbsolute(activeUser.ActiveDriveId, filePathList...)
if err != nil {
fmt.Println(err)
return
}
if len(paths) == 0 {
fmt.Printf("没有有效的文件\n")
return
}
fmt.Printf("正在获取增加的文件信息,该操作可能会非常耗费时间,请耐心等待...\n")
param := &aliyunpan_web.AlbumAddFileParam{
AlbumId: album.AlbumId,
DriveFileList: []aliyunpan.FileBatchActionParam{},
}
for k := range paths {
filePath := paths[k]
fileInfo, apierr := activeUser.PanClient().WebapiPanClient().FileInfoByPath(activeUser.ActiveDriveId, filePath)
if apierr != nil {
fmt.Printf("获取文件信息失败: %s\n", filePath)
continue
}
if fileInfo.IsFile() {
// file
if isFileMatchCondition(fileInfo, filterOption) {
param.AddFileItem(fileInfo.DriveId, fileInfo.FileId)
}
} else {
// folder
activeUser.PanClient().WebapiPanClient().FilesDirectoriesRecurseList(activeUser.ActiveDriveId, fileInfo.Path, func(depth int, _ string, fd *aliyunpan.FileEntity, apiError *apierror.ApiError) bool {
if apiError != nil {
logger.Verbosef("%s\n", apiError)
return true
}
if !fd.IsFolder() {
if isFileMatchCondition(fd, filterOption) {
param.AddFileItem(fd.DriveId, fd.FileId)
}
}
time.Sleep(2 * time.Second)
return true
})
}
time.Sleep(2 * time.Second)
}
if len(param.DriveFileList) == 0 {
fmt.Printf("没有符合的文件\n")
return
}
// add file
_, e := activeUser.PanClient().WebapiPanClient().AlbumAddFile(param)
if e != nil {
fmt.Printf("增加相簿文件失败:%s\n", e)
return
}
fmt.Printf("增加相簿文件成功:%s\n", albumName)
}
func isFileMatchCondition(fileInfo *aliyunpan.FileEntity, filterOption AlbumFileCategoryOption) bool {
if fileInfo == nil {
return false
}
if filterOption == ImageOnlyOption {
return fileInfo.Category == "image"
} else if filterOption == VideoOnlyOption {
return fileInfo.Category == "video"
} else if filterOption == ImageVideoOnlyOption {
return fileInfo.Category == "image" || fileInfo.Category == "video"
} else if filterOption == AllFileOption {
return true
}
return false
}
func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) {
func RunShareAlbumDownloadFile(albumNames []string, options *DownloadOptions) {
if len(albumNames) == 0 {
fmt.Printf("相簿名称不能为空\n")
fmt.Printf("请指定相簿名称\n")
return
}
activeUser := GetActiveUser()
activeUser.PanClient().WebapiPanClient().EnableCache()
activeUser.PanClient().WebapiPanClient().ClearCache()
defer activeUser.PanClient().WebapiPanClient().DisableCache()
// pan token expired checker
continueFlag := int32(0)
atomic.StoreInt32(&continueFlag, 0)
defer func() {
atomic.StoreInt32(&continueFlag, 1)
}()
go func(flag *int32) {
for atomic.LoadInt32(flag) == 0 {
time.Sleep(time.Duration(1) * time.Minute)
if RefreshWebTokenInNeed(activeUser, config.Config.DeviceName) {
logger.Verboseln("update access token for download task")
}
}
}(&continueFlag)
if options == nil {
options = &DownloadOptions{}
}
@@ -668,6 +258,16 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) {
if options.Parallel > config.MaxFileDownloadParallelNum {
options.Parallel = config.MaxFileDownloadParallelNum
}
// 设置单个文件下载分片线程数
if options.SliceParallel < 1 { // 用户没有主动设置,则使用下面自动配置的策略
if options.Parallel > 1 {
// 如果是多文件下载,则一个文件只能同时使用1个线程
options.SliceParallel = 1
} else {
// 如果是单文件下载,则可以使用3个最大线程
options.SliceParallel = 3
}
}
// 保存文件的本地根文件夹
originSaveRootPath := ""
@@ -693,6 +293,7 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) {
panClient = activeUser.PanClient()
)
cfg.MaxParallel = options.Parallel
cfg.SliceParallel = options.SliceParallel
var (
executor = taskframework.TaskExecutor{
@@ -707,14 +308,26 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) {
globalSpeedsStat := &speeds.Speeds{}
// 处理队列
allShareAlbumList, err := activeUser.PanClient().OpenapiPanClient().ShareAlbumListGetAll()
if err != nil {
fmt.Printf("获取相簿列表失败: %s\n", err)
return
}
for k := range albumNames {
record := getAlbumFromName(activeUser, albumNames[k])
var record *aliyunpan.AlbumEntity
for _, album := range allShareAlbumList {
if album.Name == albumNames[k] {
record = album
break
}
}
if record == nil {
continue
}
// 获取相簿下的所有文件
fileList, er := activeUser.PanClient().WebapiPanClient().AlbumListFileGetAll(&aliyunpan_web.AlbumListFileParam{
fileList, er := activeUser.PanClient().OpenapiPanClient().ShareAlbumListFileGetAll(&aliyunpan.ShareAlbumListFileParam{
AlbumId: record.AlbumId,
Limit: 100,
})
if er != nil {
fmt.Printf("获取相簿文件出错,请稍后重试: %s\n", albumNames[k])
@@ -724,9 +337,61 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) {
fmt.Printf("相簿里面没有文件: %s\n", albumNames[k])
continue
}
for _, f := range fileList {
// 补全虚拟网盘路径,规则:/<相簿名称>/文件名称
f.Path = "/" + albumNames[k] + "/" + f.FileName
// 相薄文件是没有子文件夹的,这个fileList就是该相册里面的全部文件了,所以不需要再遍历子文件夹了
idx := 0
for {
if idx >= len(fileList) {
break
}
f := fileList.Item(idx)
idx += 1
// 处理实况照片
if f.IsAlbumLivePhotoFile() {
// 如果是实况照片,则需要下载图片+视频两个文件
// 获取下载链接
durl, apierr := activeUser.PanClient().OpenapiPanClient().ShareAlbumGetFileDownloadUrl(&aliyunpan.ShareAlbumGetFileUrlParam{
AlbumId: f.AlbumId,
DriveId: f.DriveId,
FileId: f.FileId,
})
if apierr != nil {
logger.Verbosef("ERROR: get album file download url error: %s\n", f.FileId)
fmt.Printf("\n下载照片失败: %s\n", f.FileName)
continue
}
if durl.StreamsUrl != nil { // 实况图片(照片+视频)下载链接
// 照片文件
photoFile := cloneFileEntity(f)
photoFileSize := int64(0)
if durl.StreamsUrl.Heic != "" {
beforeStr, _ := strings.CutSuffix(f.FileName, ".livp")
photoFile.FileName = beforeStr + ".HEIC"
photoFile.FileExtension = "heic"
photoFileSize = getHttpDownloadFileSize(durl.StreamsUrl.Heic)
} else if durl.StreamsUrl.Jpeg != "" {
beforeStr, _ := strings.CutSuffix(f.FileName, ".livp")
photoFile.FileName = beforeStr + ".JPG"
photoFile.FileExtension = "jpg"
photoFileSize = getHttpDownloadFileSize(durl.StreamsUrl.Jpeg)
}
photoFile.FileSize = photoFileSize
fileList = append(fileList, photoFile)
// 视频文件
videoFile := cloneFileEntity(f)
videoFile.FileSize = f.FileSize - photoFileSize
if durl.StreamsUrl.Mov != "" {
beforeStr, _ := strings.CutSuffix(f.FileName, ".livp")
videoFile.FileName = beforeStr + ".MOV"
videoFile.FileExtension = "mov"
}
fileList = append(fileList, videoFile)
}
continue
}
// 补全虚拟网盘路径,规则:/共享相册/<相簿名称>/文件名称
f.Path = "/共享相册/" + albumNames[k] + "/" + f.FileName
// 生成下载项
newCfg := *cfg
@@ -734,22 +399,21 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) {
Cfg: &newCfg, // 复制一份新的cfg
PanClient: panClient,
VerbosePrinter: panCommandVerbose,
PrintFormat: downloadPrintFormat(options.Load),
ParentTaskExecutor: &executor,
DownloadStatistic: statistic,
IsPrintStatus: options.IsPrintStatus,
IsExecutedPermission: options.IsExecutedPermission,
IsOverwrite: options.IsOverwrite,
NoCheck: options.NoCheck,
FilePanSource: global.AlbumSource,
FilePanPath: f.Path,
DriveId: f.DriveId, // 必须使用文件的DriveId,因为一个相簿的文件会来自多个网盘(资源库/备份盘)
DriveId: f.DriveId, // 一个相簿的文件会来自多个网盘(资源库/备份盘)
GlobalSpeedsStat: globalSpeedsStat,
FileRecorder: nil,
}
// TODO: 相册下载需要重构
// 设置相簿文件信息
//unit.SetFileInfo(pandownload.AlbumFileSource, f)
unit.SetFileInfo(global.AlbumSource, f)
// 设置储存的路径
if options.SaveTo != "" {
@@ -785,3 +449,33 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) {
tb.Render()
}
}
func cloneFileEntity(entity *aliyunpan.FileEntity) *aliyunpan.FileEntity {
data, err := json.Marshal(entity) // 序列化原始对象
if err != nil {
panic(err)
}
newEntity := &aliyunpan.FileEntity{}
err = json.Unmarshal(data, newEntity) // 反序列化到新的对象中
if err != nil {
panic(err)
}
return newEntity
}
func getHttpDownloadFileSize(fileUrl string) int64 {
client := requester.NewHTTPClient()
client.SetKeepAlive(true)
client.SetTimeout(10 * time.Minute)
// header
headers := map[string]string{
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"referer": "https://www.aliyundrive.com/",
}
resp, err := client.Req("GET", fileUrl, nil, headers)
if err != nil {
return -1
}
fileSize := resp.ContentLength
return fileSize
}
+787
View File
@@ -0,0 +1,787 @@
// Copyright (c) 2020 tickstep.
//
// 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.
package command
import (
"fmt"
"github.com/olekukonko/tablewriter"
"github.com/tickstep/aliyunpan-api/aliyunpan"
"github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
"github.com/tickstep/aliyunpan-api/aliyunpan_web"
"github.com/tickstep/aliyunpan/cmder"
"github.com/tickstep/aliyunpan/cmder/cmdtable"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/tickstep/aliyunpan/internal/file/downloader"
"github.com/tickstep/aliyunpan/internal/functions/pandownload"
"github.com/tickstep/aliyunpan/internal/functions/panlogin"
"github.com/tickstep/aliyunpan/internal/taskframework"
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/aliyunpan/library/requester/transfer"
"github.com/tickstep/library-go/converter"
"github.com/tickstep/library-go/logger"
"github.com/tickstep/library-go/requester/rio/speeds"
"github.com/urfave/cli"
"os"
"path/filepath"
"strconv"
"sync/atomic"
"time"
)
type (
AlbumFileCategoryOption string
)
var (
ImageOnlyOption AlbumFileCategoryOption = "image"
VideoOnlyOption AlbumFileCategoryOption = "video"
ImageVideoOnlyOption AlbumFileCategoryOption = "image_video"
AllFileOption AlbumFileCategoryOption = "none"
)
func CmdAlbumWeb() cli.Command {
return cli.Command{
Name: "albumw",
Usage: "个人相册",
UsageText: cmder.App().Name + " albumw",
Category: "阿里云盘",
Before: ReloadConfigFunc,
Action: func(c *cli.Context) error {
cli.ShowCommandHelp(c, c.Command.Name)
return nil
},
Subcommands: []cli.Command{
{
Name: "list",
Aliases: []string{"ls"},
Usage: "展示相簿列表",
UsageText: cmder.App().Name + " album list",
Description: `
示例:
展示相簿列表
aliyunpan albumw ls
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
RunAlbumList()
return nil
},
Flags: []cli.Flag{},
},
{
Name: "new",
Aliases: []string{""},
Usage: "创建相簿",
UsageText: cmder.App().Name + " album new",
Description: `
示例:
新建相簿,名称为:我的相簿2022
aliyunpan albumw new "我的相簿2022"
新建相簿,名称为:我的相簿2022,描述为:存放2022所有文件
aliyunpan albumw new "我的相簿2022" "存放2022所有文件"
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
RunAlbumCreate(c.Args().Get(0), c.Args().Get(1))
return nil
},
Flags: []cli.Flag{},
},
{
Name: "rm",
Aliases: []string{""},
Usage: "删除相簿",
UsageText: cmder.App().Name + " album rm",
Description: `
删除相簿,同名的相簿只会删除第一个符合条件的
示例:
删除名称为"我的相簿2022"的相簿
aliyunpan albumw rm "我的相簿2022"
删除名称为"我的相簿2022-1" 和 "我的相簿2022-2"的相簿
aliyunpan albumw rm "我的相簿2022-1" "我的相簿2022-2"
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
RunAlbumDelete(c.Args())
return nil
},
Flags: []cli.Flag{},
},
{
Name: "rename",
Aliases: []string{""},
Usage: "重命名相簿",
UsageText: cmder.App().Name + " album rename",
Description: `
重命名相簿,同名的相簿只会修改第一个符合条件的
示例:
重命名相簿"我的相簿2022"为新的名称"我的相簿2022-new"
aliyunpan albumw rename "我的相簿2022" "我的相簿2022-new"
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
RunAlbumRename(c.Args().Get(0), c.Args().Get(1))
return nil
},
Flags: []cli.Flag{},
},
{
Name: "list-file",
Aliases: []string{"lf"},
Usage: "展示相簿中的文件",
UsageText: cmder.App().Name + " album list-file",
Description: `
展示相簿中文件,同名的相簿只会展示第一个符合条件的
示例:
展示相簿中文件"我的相簿2022"
aliyunpan albumw list-file "我的相簿2022"
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
RunAlbumListFile(c.Args().Get(0))
return nil
},
Flags: []cli.Flag{},
},
{
Name: "rm-file",
Aliases: []string{"rf"},
Usage: "移除相簿中的文件",
UsageText: cmder.App().Name + " album rm-file",
Description: `
移除相簿中的文件,同名的相簿只会移除第一个符合条件的
示例:
移除相簿 "我的相簿2022" 中的文件 1.png 2.png
aliyunpan albumw rm-file 我的相簿2022 1.png 2.png
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
subArgs := c.Args()
if len(subArgs) < 2 {
fmt.Println("请指定移除的文件")
return nil
}
RunAlbumRmFile(subArgs[0], subArgs[1:])
return nil
},
Flags: []cli.Flag{},
},
{
Name: "add-file",
Aliases: []string{"af"},
Usage: "增加(文件/相册)网盘文件到相簿中",
UsageText: cmder.App().Name + " album add-file",
Description: `
增加文件到相簿中
示例:
增加当前目录下的 1.png 2.png 文件到相簿 "我的相簿2022" 中
aliyunpan albumw add-file 我的相簿2022 1.png 2.png
增加当前目录下的 myFolder 文件夹下所有文件到相簿 "我的相簿2022" 中
aliyunpan albumw add-file 我的相簿2022 myFolder
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
subArgs := c.Args()
if len(subArgs) < 2 {
fmt.Println("请指定增加的文件")
return nil
}
RunAlbumAddFile(subArgs[0], subArgs[1:], ImageVideoOnlyOption)
return nil
},
Flags: []cli.Flag{},
},
{
Name: "download-file",
Aliases: []string{"df"},
Usage: "下载相簿中的所有文件到本地",
UsageText: cmder.App().Name + " album download-file",
Description: `
下载相簿中的所有文件
示例:
下载相簿 "我的相簿2022" 里面的所有文件
aliyunpan albumw download-file 我的相簿2022
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
subArgs := c.Args()
if len(subArgs) == 0 {
fmt.Println("请指定下载的相簿名称")
return nil
}
// 处理saveTo
var (
saveTo string
)
if c.String("saveto") != "" {
saveTo = filepath.Clean(c.String("saveto"))
}
do := &DownloadOptions{
IsPrintStatus: false,
IsExecutedPermission: false,
IsOverwrite: c.Bool("ow"),
SaveTo: saveTo,
Parallel: 0,
Load: 0,
MaxRetry: pandownload.DefaultDownloadMaxRetry,
NoCheck: false,
ShowProgress: !c.Bool("np"),
DriveId: parseDriveId(c),
ExcludeNames: []string{},
}
RunAlbumDownloadFile(c.Args(), do)
return nil
},
Flags: []cli.Flag{
cli.BoolFlag{
Name: "ow",
Usage: "overwrite, 覆盖已存在的文件",
},
cli.StringFlag{
Name: "saveto",
Usage: "将下载的文件直接保存到指定的目录",
},
cli.BoolFlag{
Name: "np",
Usage: "no progress 不展示下载进度条",
},
},
},
},
}
}
func RunAlbumList() {
activeUser := GetActiveUser()
records, err := activeUser.PanClient().WebapiPanClient().AlbumListGetAll(&aliyunpan_web.AlbumListParam{})
if err != nil {
fmt.Printf("获取相簿列表失败: %s\n", err)
return
}
tb := cmdtable.NewTable(os.Stdout)
tb.SetHeader([]string{"#", "ALBUM_ID", "名称", "文件数量", "创建日期", "修改日期"})
tb.SetColumnAlignment([]int{tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_CENTER, tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_DEFAULT})
for k, record := range records {
tb.Append([]string{strconv.Itoa(k + 1), record.AlbumId, record.Name, strconv.Itoa(record.FileCount),
record.CreatedAtStr(), record.UpdatedAtStr()})
}
tb.Render()
}
func RunAlbumCreate(name, description string) {
if name == "" {
fmt.Printf("相簿名称不能为空\n")
return
}
activeUser := GetActiveUser()
_, err := activeUser.PanClient().WebapiPanClient().AlbumCreate(&aliyunpan_web.AlbumCreateParam{
Name: name,
Description: description,
})
if err != nil {
fmt.Printf("创建相簿失败: %s\n", err)
return
}
fmt.Printf("创建相簿成功: %s\n", name)
}
func RunAlbumDelete(nameList []string) {
if len(nameList) == 0 {
fmt.Printf("相簿名称不能为空\n")
return
}
activeUser := GetActiveUser()
records, err := activeUser.PanClient().WebapiPanClient().AlbumListGetAll(&aliyunpan_web.AlbumListParam{})
if err != nil {
fmt.Printf("获取相簿列表失败: %s\n", err)
return
}
for _, record := range records {
for i, name := range nameList {
if name == record.Name {
nameList = append(nameList[:i], nameList[i+1:]...)
_, err := activeUser.PanClient().WebapiPanClient().AlbumDelete(&aliyunpan_web.AlbumDeleteParam{
AlbumId: record.AlbumId,
})
if err != nil {
fmt.Printf("删除相簿失败: %s\n", name)
return
} else {
fmt.Printf("删除相簿成功: %s\n", name)
}
break
}
}
}
}
func getAlbumFromName(activeUser *config.PanUser, name string) *aliyunpan.AlbumEntity {
records, err := activeUser.PanClient().WebapiPanClient().AlbumListGetAll(&aliyunpan_web.AlbumListParam{})
if err != nil {
fmt.Printf("获取相簿列表失败: %s\n", err)
return nil
}
for _, record := range records {
if name == record.Name {
return record
}
}
return nil
}
func RunAlbumRename(name, newName string) {
if len(name) == 0 {
fmt.Printf("相簿名称不能为空\n")
return
}
if len(newName) == 0 {
fmt.Printf("相簿名称不能为空\n")
return
}
activeUser := GetActiveUser()
record := getAlbumFromName(activeUser, name)
if record == nil {
return
}
_, err := activeUser.PanClient().WebapiPanClient().AlbumEdit(&aliyunpan_web.AlbumEditParam{
AlbumId: record.AlbumId,
Description: record.Description,
Name: newName,
})
if err != nil {
fmt.Printf("重命名相簿失败: %s\n", name)
return
} else {
fmt.Printf("重命名相簿成功: %s -> %s\n", name, newName)
}
}
func RunAlbumListFile(name string) {
if len(name) == 0 {
fmt.Printf("相簿名称不能为空\n")
return
}
activeUser := GetActiveUser()
record := getAlbumFromName(activeUser, name)
if record == nil {
return
}
fileList, er := activeUser.PanClient().WebapiPanClient().AlbumListFileGetAll(&aliyunpan_web.AlbumListFileParam{
AlbumId: record.AlbumId,
})
if er != nil {
fmt.Printf("获取相簿文件列表失败:%s\n", er)
return
}
renderTable(opLs, false, "", fileList)
}
func RunAlbumRmFile(name string, nameList []string) {
if len(name) == 0 {
fmt.Printf("相簿名称不能为空\n")
return
}
if len(nameList) == 0 {
fmt.Printf("指定文件不能为空\n")
return
}
activeUser := GetActiveUser()
album := getAlbumFromName(activeUser, name)
if album == nil {
return
}
fileList, er := activeUser.PanClient().WebapiPanClient().AlbumListFileGetAll(&aliyunpan_web.AlbumListFileParam{
AlbumId: album.AlbumId,
})
if er != nil {
fmt.Printf("获取相簿文件列表失败:%s\n", er)
return
}
param := &aliyunpan_web.AlbumDeleteFileParam{
AlbumId: album.AlbumId,
DriveFileList: []aliyunpan.FileBatchActionParam{},
}
for _, file := range fileList {
if len(nameList) == 0 {
break
}
for i, name := range nameList {
if name == file.FileName {
nameList = append(nameList[:i], nameList[i+1:]...)
param.AddFileItem(file.DriveId, file.FileId)
break
}
}
}
// 1-500 范围
if len(param.DriveFileList) == 0 {
fmt.Printf("没有符合的文件\n")
return
}
// delete file
_, e := activeUser.PanClient().WebapiPanClient().AlbumDeleteFile(param)
if e != nil {
fmt.Printf("删除相簿文件失败:%s\n", e)
return
}
fmt.Printf("删除相簿文件成功:%s\n", name)
}
// RunAlbumAddFile 增加网盘文件到相簿
func RunAlbumAddFile(albumName string, filePathList []string, filterOption AlbumFileCategoryOption) {
activeUser := GetActiveUser()
if albumName == "" {
fmt.Printf("必须指定相簿名称\n")
return
}
album := getAlbumFromName(activeUser, albumName)
if album == nil {
fmt.Printf("相簿不存在\n")
return
}
paths, err := makePathAbsolute(activeUser.ActiveDriveId, filePathList...)
if err != nil {
fmt.Println(err)
return
}
if len(paths) == 0 {
fmt.Printf("没有有效的文件\n")
return
}
fmt.Printf("正在获取增加的文件信息,该操作可能会非常耗费时间,请耐心等待...\n")
param := &aliyunpan_web.AlbumAddFileParam{
AlbumId: album.AlbumId,
DriveFileList: []aliyunpan.FileBatchActionParam{},
}
for k := range paths {
filePath := paths[k]
fileInfo, apierr := activeUser.PanClient().WebapiPanClient().FileInfoByPath(activeUser.ActiveDriveId, filePath)
if apierr != nil {
fmt.Printf("获取文件信息失败: %s\n", filePath)
continue
}
if fileInfo.IsFile() {
// file
if isFileMatchCondition(fileInfo, filterOption) {
param.AddFileItem(fileInfo.DriveId, fileInfo.FileId)
}
} else {
// folder
activeUser.PanClient().WebapiPanClient().FilesDirectoriesRecurseList(activeUser.ActiveDriveId, fileInfo.Path, func(depth int, _ string, fd *aliyunpan.FileEntity, apiError *apierror.ApiError) bool {
if apiError != nil {
logger.Verbosef("%s\n", apiError)
return true
}
if !fd.IsFolder() {
if isFileMatchCondition(fd, filterOption) {
param.AddFileItem(fd.DriveId, fd.FileId)
}
}
time.Sleep(2 * time.Second)
return true
})
}
time.Sleep(2 * time.Second)
}
if len(param.DriveFileList) == 0 {
fmt.Printf("没有符合的文件\n")
return
}
// add file
_, e := activeUser.PanClient().WebapiPanClient().AlbumAddFile(param)
if e != nil {
fmt.Printf("增加相簿文件失败:%s\n", e)
return
}
fmt.Printf("增加相簿文件成功:%s\n", albumName)
}
func isFileMatchCondition(fileInfo *aliyunpan.FileEntity, filterOption AlbumFileCategoryOption) bool {
if fileInfo == nil {
return false
}
if filterOption == ImageOnlyOption {
return fileInfo.Category == "image"
} else if filterOption == VideoOnlyOption {
return fileInfo.Category == "video"
} else if filterOption == ImageVideoOnlyOption {
return fileInfo.Category == "image" || fileInfo.Category == "video"
} else if filterOption == AllFileOption {
return true
}
return false
}
func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) {
if len(albumNames) == 0 {
fmt.Printf("相簿名称不能为空\n")
return
}
activeUser := GetActiveUser()
activeUser.PanClient().WebapiPanClient().EnableCache()
activeUser.PanClient().WebapiPanClient().ClearCache()
defer activeUser.PanClient().WebapiPanClient().DisableCache()
// pan token expired checker
continueFlag := int32(0)
atomic.StoreInt32(&continueFlag, 0)
defer func() {
atomic.StoreInt32(&continueFlag, 1)
}()
go func(flag *int32) {
for atomic.LoadInt32(flag) == 0 {
time.Sleep(time.Duration(1) * time.Minute)
re := RefreshWebTokenInNeed(activeUser)
if re.Code == panlogin.SUCCESS {
logger.Verboseln("update access token for download task")
}
}
}(&continueFlag)
if options == nil {
options = &DownloadOptions{}
}
if options.MaxRetry < 0 {
options.MaxRetry = pandownload.DefaultDownloadMaxRetry
}
options.IsExecutedPermission = false
// 设置下载配置
cfg := &downloader.Config{
Mode: transfer.RangeGenMode_BlockSize,
CacheSize: config.Config.CacheSize,
BlockSize: MaxDownloadRangeSize,
MaxRate: config.Config.MaxDownloadRate,
InstanceStateStorageFormat: downloader.InstanceStateStorageFormatJSON,
ShowProgress: options.ShowProgress,
ExcludeNames: options.ExcludeNames,
}
if cfg.CacheSize == 0 {
cfg.CacheSize = int(DownloadCacheSize)
}
// 设置下载最大并发量
if options.Parallel < 1 {
options.Parallel = config.Config.MaxDownloadParallel
if options.Parallel == 0 {
options.Parallel = config.DefaultFileDownloadParallelNum
}
}
if options.Parallel > config.MaxFileDownloadParallelNum {
options.Parallel = config.MaxFileDownloadParallelNum
}
// 保存文件的本地根文件夹
originSaveRootPath := ""
if options.SaveTo != "" {
originSaveRootPath = options.SaveTo
} else {
// 使用默认的保存路径
originSaveRootPath = GetActiveUser().GetSavePath("")
}
fi, err1 := os.Stat(originSaveRootPath)
if err1 != nil && !os.IsExist(err1) {
os.MkdirAll(originSaveRootPath, 0777) // 首先在本地创建目录
} else {
if !fi.IsDir() {
fmt.Println("本地保存路径不是文件夹,请删除或者创建对应的文件夹:", originSaveRootPath)
return
}
}
fmt.Printf("\n[0] 当前文件下载最大并发量为: %d, 下载缓存为: %s\n\n", options.Parallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2))
var (
panClient = activeUser.PanClient()
)
cfg.MaxParallel = options.Parallel
var (
executor = taskframework.TaskExecutor{
IsFailedDeque: true, // 统计失败的列表
}
statistic = &pandownload.DownloadStatistic{}
)
// 配置执行器任务并发数,即同时下载文件并发数
executor.SetParallel(cfg.MaxParallel)
// 全局速度统计
globalSpeedsStat := &speeds.Speeds{}
// 处理队列
for k := range albumNames {
record := getAlbumFromName(activeUser, albumNames[k])
if record == nil {
continue
}
// 获取相簿下的所有文件
fileList, er := activeUser.PanClient().WebapiPanClient().AlbumListFileGetAll(&aliyunpan_web.AlbumListFileParam{
AlbumId: record.AlbumId,
})
if er != nil {
fmt.Printf("获取相簿文件出错,请稍后重试: %s\n", albumNames[k])
continue
}
if fileList == nil || len(fileList) == 0 {
fmt.Printf("相簿里面没有文件: %s\n", albumNames[k])
continue
}
for _, f := range fileList {
// 补全虚拟网盘路径,规则:/<相簿名称>/文件名称
f.Path = "/" + albumNames[k] + "/" + f.FileName
// 生成下载项
newCfg := *cfg
unit := pandownload.DownloadTaskUnit{
Cfg: &newCfg, // 复制一份新的cfg
PanClient: panClient,
VerbosePrinter: panCommandVerbose,
ParentTaskExecutor: &executor,
DownloadStatistic: statistic,
IsPrintStatus: options.IsPrintStatus,
IsExecutedPermission: options.IsExecutedPermission,
IsOverwrite: options.IsOverwrite,
NoCheck: options.NoCheck,
FilePanPath: f.Path,
DriveId: f.DriveId, // 必须使用文件的DriveId,因为一个相簿的文件会来自多个网盘(资源库/备份盘)
GlobalSpeedsStat: globalSpeedsStat,
FileRecorder: nil,
}
// TODO: 相册下载需要重构
// 设置相簿文件信息
//unit.SetFileInfo(pandownload.AlbumFileSource, f)
// 设置储存的路径
if options.SaveTo != "" {
unit.OriginSaveRootPath = options.SaveTo
unit.SavePath = filepath.Join(options.SaveTo, f.Path)
} else {
// 使用默认的保存路径
unit.OriginSaveRootPath = GetActiveUser().GetSavePath("")
unit.SavePath = GetActiveUser().GetSavePath(f.Path)
}
info := executor.Append(&unit, options.MaxRetry)
fmt.Printf("[%s] 加入下载队列: %s\n", info.Id(), f.Path)
}
}
// 开始计时
statistic.StartTimer()
// 开始执行
executor.Execute()
fmt.Printf("\n下载结束, 时间: %s, 数据总量: %s\n", utils.ConvertTime(statistic.Elapsed()), converter.ConvertFileSize(statistic.TotalSize(), 2))
// 输出失败的文件列表
failedList := executor.FailedDeque()
if failedList.Size() != 0 {
fmt.Printf("以下文件下载失败: \n")
tb := cmdtable.NewTable(os.Stdout)
for e := failedList.Shift(); e != nil; e = failedList.Shift() {
item := e.(*taskframework.TaskInfoItem)
tb.Append([]string{item.Info.Id(), item.Unit.(*pandownload.DownloadTaskUnit).FilePanPath})
}
tb.Render()
}
}
+141 -33
View File
@@ -15,38 +15,48 @@ package command
import (
"fmt"
"os"
"path"
"path/filepath"
"runtime"
"sort"
"github.com/tickstep/aliyunpan/cmder"
"github.com/tickstep/aliyunpan/cmder/cmdtable"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/tickstep/aliyunpan/internal/file/downloader"
"github.com/tickstep/aliyunpan/internal/functions/pandownload"
"github.com/tickstep/aliyunpan/internal/global"
"github.com/tickstep/aliyunpan/internal/log"
"github.com/tickstep/aliyunpan/internal/taskframework"
"github.com/tickstep/aliyunpan/internal/ui"
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/aliyunpan/library/requester/transfer"
"github.com/tickstep/library-go/converter"
"github.com/tickstep/library-go/logger"
"github.com/tickstep/library-go/requester/rio/speeds"
"github.com/urfave/cli"
"os"
"path"
"path/filepath"
"runtime"
)
type (
//DownloadOptions 下载可选参数
DownloadOptions struct {
// DownloadActionId 下载动作ID,唯一标识一次下载动作的ID。这个ID每次下载任务启动的时候会自动生成,同一次下载任务下载的文件,这个ID都是同一个值。
DownloadActionId string
IsPrintStatus bool
IsExecutedPermission bool
IsOverwrite bool
SaveTo string
Parallel int
Parallel int // 文件下载最大线程数,即同时进行下载文件的数量,阿里云盘限制最大为3
SliceParallel int // 单个文件分片下载最大线程数,阿里云盘限制最大为3
Load int
MaxRetry int
NoCheck bool
ShowProgress bool
DriveId string
ExcludeNames []string // 排除的文件名,包括文件夹和文件。即这些文件/文件夹不进行下载,支持正则表达式
IsMultiUserDownload bool // 是否启用多用户联合下载
IsUseUIDashboard bool // 是否使用UI下载面板显示下载进度
}
// LocateDownloadOption 获取下载链接可选参数
@@ -94,7 +104,10 @@ func CmdDownload() cli.Command {
下载 /我的资源/1.mp4 并保存下载的文件到本地的 d:/panfile
aliyunpan download --saveto d:/panfile /我的资源/1.mp4
使用多用户联合下载 /我的资源/1.mp4 文件。必须保证所有登录的用户在相同的网盘(备份盘/资源盘)下,相同的路径下,有相同的文件
aliyunpan download /我的资源/1.mp4 -md
参考:
以下是典型的排除特定文件或者文件夹的例子,注意:参数值必须是正则表达式。在正则表达式中,^表示匹配开头,$表示匹配结尾。
1)排除@eadir文件或者文件夹:-exn "^@eadir$"
@@ -124,33 +137,24 @@ func CmdDownload() cli.Command {
}
do := &DownloadOptions{
DownloadActionId: utils.UuidStr(),
IsPrintStatus: c.Bool("status"),
IsExecutedPermission: c.Bool("x"),
IsOverwrite: c.Bool("ow"),
SaveTo: saveTo,
Parallel: c.Int("p"),
SliceParallel: c.Int("sp"),
Load: 0,
MaxRetry: c.Int("retry"),
NoCheck: c.Bool("nocheck"),
ShowProgress: !c.Bool("np"),
DriveId: parseDriveId(c),
ExcludeNames: c.StringSlice("exn"),
IsMultiUserDownload: c.Bool("md"),
IsUseUIDashboard: c.Bool("ui"),
}
// 获取下载文件锁,保证下载操作单实例
//locker := filelocker.NewFileLocker(config.GetLockerDir() + "/aliyunpan-download")
//if e := filelocker.LockFile(locker, 0755, true, 5*time.Second); e != nil {
// logger.Verboseln(e)
// fmt.Println("本应用其他实例正在执行下载,请先停止或者等待其完成")
// return nil
//}
RunDownload(c.Args(), do)
// 释放文件锁
//if locker != nil {
// filelocker.UnlockFile(locker)
//}
return nil
},
Flags: []cli.Flag{
@@ -176,7 +180,13 @@ func CmdDownload() cli.Command {
},
cli.IntFlag{
Name: "p",
Usage: "指定同时进行下载文件的数量(取值范围:1 ~ 20)",
Usage: "parallel,指定同时进行下载文件的数量(取值范围:1 ~ 3)",
Value: 1,
},
cli.IntFlag{
Name: "sp",
Usage: "slice parallel,指定单个文件下载的最大线程(分片)数(取值范围:1 ~ 3)",
Value: 0,
},
cli.IntFlag{
Name: "retry",
@@ -201,17 +211,18 @@ func CmdDownload() cli.Command {
Usage: "exclude name,指定排除的文件夹或者文件的名称,被排除的文件不会进行下载,只支持正则表达式。支持同时排除多个名称,每一个名称就是一个exn参数",
Value: nil,
},
cli.BoolFlag{
Name: "md",
Usage: "(BETA) Multi-User Download,使用多用户联合下载,可以对单一文件叠加所有登录用户的下载速度",
},
cli.BoolFlag{
Name: "ui",
Usage: "(BETA) 使用UI面板显示下载详情和进度,更加直观和友好",
},
},
}
}
func downloadPrintFormat(load int) string {
if load <= 1 {
return pandownload.DefaultPrintFormat
}
return "\r[%s] ↓ %s/%s %s/s in %s, left %s ..."
}
// RunDownload 执行下载网盘内文件
func RunDownload(paths []string, options *DownloadOptions) {
activeUser := GetActiveUser()
@@ -272,6 +283,17 @@ func RunDownload(paths []string, options *DownloadOptions) {
options.Parallel = config.MaxFileDownloadParallelNum
}
// 设置单个文件下载分片线程数
if options.SliceParallel < 1 { // 用户没有主动设置,则使用下面自动配置的策略
if options.Parallel > 1 {
// 如果是多文件下载,则一个文件只能同时使用1个线程
options.SliceParallel = 1
} else {
// 如果是单文件下载,则可以使用3个最大线程
options.SliceParallel = 3
}
}
// 保存文件的本地根文件夹
originSaveRootPath := ""
if options.SaveTo != "" {
@@ -295,12 +317,51 @@ func RunDownload(paths []string, options *DownloadOptions) {
fmt.Println(err)
return
}
fmt.Printf("\n[0] 当前文件下载最大并发量为: %d, 下载缓存为: %s\n\n", options.Parallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2))
// 多用户下载的辅助账号列表
var subPanClientList []*config.PanClient
if options.IsMultiUserDownload { // 多用户下载
c := config.Config
for _, u := range config.Config.UserList {
if u.UserId == activeUser.UserId {
// 当前登录用户,作为主用户,跳过
continue
}
// 初始化客户端
user, err := config.SetupUserByCookie(u.OpenapiToken, u.WebapiToken,
u.TicketId, u.UserId,
c.DeviceId, c.DeviceName,
c.ClientId, c.ClientSecret)
if err != nil {
logger.Verboseln("setup user error")
continue
}
if subPanClientList == nil {
subPanClientList = []*config.PanClient{}
}
subPanClientList = append(subPanClientList, user.PanClient())
}
if subPanClientList == nil || len(subPanClientList) == 0 {
fmt.Printf("\n当前登录用户只有一个,无法启用多用户联合下载\n")
subPanClientList = nil
}
}
if subPanClientList != nil || len(subPanClientList) > 0 {
// 已启用多用户下载
userCount := len(subPanClientList) + 1
fmt.Printf("\n*** 已启用多用户联合下载,用户数: %d ***\n", userCount)
// 多用户下载,并发数必须为1,以获得最大下载速度
options.Parallel = 1
// 阿里OpenAPI规定:文件分片下载的并发数为3,即某用户使用 App 时,可以同时下载 1 个文件的 3 个分片,或者同时下载 3 个文件的各 1 个分片。
options.SliceParallel = userCount * 3
}
var (
panClient = activeUser.PanClient()
)
cfg.MaxParallel = options.Parallel
cfg.SliceParallel = options.SliceParallel
var (
executor = taskframework.TaskExecutor{
@@ -314,47 +375,80 @@ func RunDownload(paths []string, options *DownloadOptions) {
// 全局速度统计
globalSpeedsStat := &speeds.Speeds{}
// 下载统计UI面板
var dashboard *ui.DashboardPanel = nil
if options.IsUseUIDashboard && options.ShowProgress && !options.IsPrintStatus && ui.IsTerminal(os.Stdout) {
dashboard = ui.NewDashboardPanel(ui.DashboardPanelDownload, cfg.MaxParallel, globalSpeedsStat, &ui.DashboardOptions{
Title: "下载统计UI面板",
ActiveSlots: 3, // 下载文件进度展示,最大同时展示3个
MaxHistory: 50, // 下载日志显示,最多同时显示50条,这个会按照窗口大小进行自适应显示
})
}
logf := func(format string, a ...interface{}) {
if dashboard != nil {
dashboard.Logf(format, a...)
return
}
fmt.Printf(format, a...)
}
// 下载记录器
fileRecorder := log.NewFileRecorder(config.GetLogDir() + "/download_file_records.csv")
// 输出当前下载配置信息
logf("\n[0] 当前文件下载最大并发量为: %d, 单文件下载分片线程数为: %d, 下载缓存为: %s\n", options.Parallel, options.SliceParallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2))
// 阿里云盘下载并发超额警告,阿里云盘下载最大并发数为3
if options.Parallel*options.SliceParallel > 3 {
logf("[0] 警告:当前下载并发配置数已超过阿里云盘限制的最大并发数,下载任务会被风控容易导致下载失败\n")
}
// 处理队列
for k := range paths {
// 使用通配符匹配
fileList, err2 := matchPathByShellPattern(options.DriveId, paths[k])
if err2 != nil {
fmt.Printf("获取文件出错,请稍后重试: %s\n", paths[k])
logf("获取文件出错,请稍后重试: %s\n", paths[k])
continue
}
if fileList == nil || len(fileList) == 0 {
// 文件不存在
fmt.Printf("文件不存在: %s\n", paths[k])
logf("文件不存在: %s\n", paths[k])
continue
}
// 排序,按名称排序,从小到大
sort.Slice(fileList, func(i, j int) bool {
return fileList[i].FileName < fileList[j].FileName
})
// 逐一下载
for _, f := range fileList {
newCfg := *cfg
// 是否排除下载
if utils.IsExcludeFile(f.Path, &newCfg.ExcludeNames) {
fmt.Printf("排除文件: %s\n", f.Path)
logf("排除文件: %s\n", f.Path)
continue
}
// 匹配的文件
unit := pandownload.DownloadTaskUnit{
DownloadActionId: options.DownloadActionId,
Cfg: &newCfg, // 复制一份新的cfg
PanClient: panClient,
SubPanClientList: subPanClientList,
VerbosePrinter: panCommandVerbose,
PrintFormat: downloadPrintFormat(options.Load),
ParentTaskExecutor: &executor,
DownloadStatistic: statistic,
IsPrintStatus: options.IsPrintStatus,
IsExecutedPermission: options.IsExecutedPermission,
IsOverwrite: options.IsOverwrite,
NoCheck: options.NoCheck,
FilePanSource: global.FileSource,
FilePanPath: f.Path,
DriveId: options.DriveId,
GlobalSpeedsStat: globalSpeedsStat,
FileRecorder: fileRecorder,
UI: dashboard,
}
// 设置储存的路径
@@ -367,16 +461,30 @@ func RunDownload(paths []string, options *DownloadOptions) {
unit.SavePath = GetActiveUser().GetSavePath(f.Path)
}
info := executor.Append(&unit, options.MaxRetry)
fmt.Printf("[%s] 加入下载队列: %s\n", info.Id(), f.Path)
if dashboard != nil {
dashboard.RegisterTask(info.Id(), f.Path, f.FileSize, f.IsFile())
}
logf("[%s] 加入下载队列: %s\n", info.Id(), f.Path)
}
}
// 开始计时
statistic.StartTimer()
// 启动UI面板显示
if dashboard != nil {
dashboard.Start()
}
// 开始执行
executor.Execute()
// 关闭UI面板
if dashboard != nil {
dashboard.Close()
}
// 完成下载,输出统计结果
fmt.Printf("\n下载结束, 时间: %s, 数据总量: %s\n", utils.ConvertTime(statistic.Elapsed()), converter.ConvertFileSize(statistic.TotalSize(), 2))
// 输出失败的文件列表
+9 -2
View File
@@ -18,6 +18,7 @@ import (
"github.com/tickstep/aliyunpan/cmder/cmdliner"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/tickstep/aliyunpan/internal/functions/panlogin"
"github.com/tickstep/aliyunpan/internal/global"
_ "github.com/tickstep/library-go/requester"
"github.com/urfave/cli"
"strings"
@@ -129,8 +130,14 @@ func RunLogin() (ticketId string, openapiToken, webapiToken *config.PanClientTok
}
ticketId = qrCodeUrlResult.TokenId
loginUrl := &strings.Builder{}
fmt.Fprintf(loginUrl, "https://openapi.alipan.com/oauth/authorize?client_id=%s&redirect_uri=https%%3A%%2F%%2Fapi.tickstep.com%%2Fauth%%2Ftickstep%%2Faliyunpan%%2Ftoken%%2Fopenapi%%2F%s%%2Fauth&scope=user:base,file:all:read,file:all:write",
config.Config.ClientId, ticketId)
if global.IsSupportNoneOpenApiCommands {
// 兼容以前的版本
fmt.Fprintf(loginUrl, "https://openapi.alipan.com/oauth/authorize?client_id=%s&redirect_uri=https%%3A%%2F%%2Fapi.tickstep.com%%2Fauth%%2Ftickstep%%2Faliyunpan%%2Ftoken%%2Fopenapi%%2F%s%%2Fauth&scope=user:base,file:all:read,file:all:write,file:share:write,album:shared:read",
config.Config.ClientId, ticketId)
} else {
fmt.Fprintf(loginUrl, "https://openapi.alipan.com/oauth/authorize?client_id=%s&redirect_uri=https%%3A%%2F%%2Fapi.tickstep.com%%2Fauth%%2Ftickstep%%2Faliyunpan%%2Ftoken%%2Fopenapi%%2F%s%%2Fauth2&scope=user:base,file:all:read,file:all:write,file:share:write,album:shared:read",
config.Config.ClientId, ticketId)
}
fmt.Printf("请在浏览器打开以下链接进行登录,链接有效时间为5分钟。\n注意:你需要进行一次授权一次扫码的两次登录。\n%s\n\n", loginUrl)
// handler waiting
+7 -1
View File
@@ -17,6 +17,7 @@ import (
"fmt"
"github.com/olekukonko/tablewriter"
"github.com/tickstep/aliyunpan-api/aliyunpan"
"github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
"github.com/tickstep/aliyunpan/cmder"
"github.com/tickstep/aliyunpan/cmder/cmdtable"
"github.com/tickstep/aliyunpan/internal/config"
@@ -69,6 +70,7 @@ func CmdLs() cli.Command {
`,
Category: "阿里云盘",
Before: ReloadConfigFunc,
After: SaveConfigFunc,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
@@ -144,7 +146,11 @@ func RunLs(driveId, targetPath string, lsOptions *LsOptions,
// 获取目标路径文件信息
targetPathInfo, err := activeUser.PanClient().OpenapiPanClient().FileInfoByPath(driveId, targetPath)
if err != nil {
fmt.Println(err)
if err.Code == apierror.ApiCodeFileNotFoundCode {
fmt.Println("指定目录不存在: " + targetPath)
} else {
fmt.Println(err)
}
return
}
+68 -2
View File
@@ -19,10 +19,12 @@ import (
"github.com/tickstep/aliyunpan/cmder"
"github.com/tickstep/aliyunpan/cmder/cmdtable"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/tickstep/aliyunpan/internal/plugins"
"github.com/urfave/cli"
"os"
"path"
"strconv"
"strings"
)
func CmdRm() cli.Command {
@@ -72,9 +74,26 @@ func CmdRm() cli.Command {
}
}
func queryFileListByPath(activeUser *config.PanUser, driveId, filePathPattern string) (files []*aliyunpan.FileEntity, e error) {
if !strings.ContainsAny(filePathPattern, aliyunpan.ShellPatternCharacters) {
// 没有通配符,直接查询
fi, err := activeUser.PanClient().OpenapiPanClient().FileInfoByPath(driveId, filePathPattern)
if err != nil {
return nil, err
}
files = append(files, fi)
} else {
// 包含通配符,使用通配符查询
files, e = matchPathByShellPattern(driveId, filePathPattern)
}
return files, e
}
// RunRemove 执行 批量删除文件/目录
func RunRemove(driveId string, paths ...string) {
activeUser := GetActiveUser()
pluginManger := plugins.NewPluginManager(config.GetPluginDir())
plugin, _ := pluginManger.GetPlugin()
cacheCleanDirs := []string{}
failedRmPaths := make([]string, 0, len(paths))
@@ -82,17 +101,62 @@ func RunRemove(driveId string, paths ...string) {
for _, p := range paths {
absolutePath := path.Clean(activeUser.PathJoin(driveId, p))
fileList, err1 := matchPathByShellPattern(driveId, absolutePath)
// 查询路径对应的文件信息
fileList, err1 := queryFileListByPath(activeUser, driveId, absolutePath)
if err1 != nil {
failedRmPaths = append(failedRmPaths, absolutePath)
continue
}
if fileList == nil || len(fileList) == 0 {
// 失败,文件不存在
// 跳过,没有满足条件的文件
failedRmPaths = append(failedRmPaths, absolutePath)
continue
}
// 调用插件
approvedToRemoveFiles := []*aliyunpan.FileEntity{}
pluginParam := &plugins.RemoveFilePrepareParams{
Count: len(fileList),
Items: make([]*plugins.RemoveFilePrepareItem, 0),
}
for _, f := range fileList {
pluginParam.Items = append(pluginParam.Items, &plugins.RemoveFilePrepareItem{
DriveId: f.DriveId,
DriveFileId: f.FileId,
DriveFileName: f.FileName,
DriveFilePath: f.Path,
DriveFileSize: f.FileSize,
DriveFileType: f.FileType,
DriveFileUpdatedAt: f.UpdatedAt,
DriveFileCreatedAt: f.CreatedAt,
})
}
if removeFilePrepareResult, er := plugin.RemoveFilePrepareCallback(plugins.GetContext(config.Config.ActiveUser()), pluginParam); er == nil && removeFilePrepareResult != nil {
for _, f := range fileList {
matchResult := false
for _, r := range removeFilePrepareResult.Result {
if strings.Compare(f.FileId, r.DriveFileId) == 0 {
matchResult = true
if strings.Compare("yes", r.RemoveApproved) != 0 {
// skip this file
fmt.Printf("插件不允许删除该文件: %s\n", f.Path)
} else {
approvedToRemoveFiles = append(approvedToRemoveFiles, f)
}
break
}
}
if !matchResult {
// 该文件没有确认结果,则默认删除
approvedToRemoveFiles = append(approvedToRemoveFiles, f)
}
}
} else {
// 默认删除全部文件
approvedToRemoveFiles = fileList
}
for _, f := range approvedToRemoveFiles {
// 删除匹配的文件
fdr, err := activeUser.PanClient().OpenapiPanClient().FileDelete(&aliyunpan.FileBatchActionParam{
DriveId: driveId,
@@ -127,5 +191,7 @@ func RunRemove(driveId string, paths ...string) {
fmt.Println("操作成功, 以下文件/目录已删除, 可在云盘文件回收站找回: ")
pnt()
activeUser.DeleteCache(cacheCleanDirs)
} else {
fmt.Println("本次操作没有删除任何文件")
}
}
+24 -237
View File
@@ -14,19 +14,14 @@
package command
import (
"encoding/csv"
"fmt"
"github.com/tickstep/aliyunpan-api/aliyunpan"
"github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
"github.com/tickstep/aliyunpan-api/aliyunpan_web"
"github.com/tickstep/aliyunpan/cmder"
"github.com/tickstep/aliyunpan/cmder/cmdtable"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/urfave/cli"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
)
@@ -74,10 +69,8 @@ func CmdShare() cli.Command {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
// 有效期
et := ""
timeFlag := "0"
if c.IsSet("time") {
@@ -92,11 +85,16 @@ func CmdShare() cli.Command {
et = ""
}
// 密码
sharePwd := ""
if c.IsSet("sharePwd") {
sharePwd = c.String("sharePwd")
}
if sharePwd == "" {
sharePwd = RandomStr(4)
}
// 模式
modeFlag := "3"
if c.IsSet("mode") {
modeFlag = c.String("mode")
@@ -108,7 +106,6 @@ func CmdShare() cli.Command {
return nil
}
}
if modeFlag == "1" {
if sharePwd == "" {
sharePwd = RandomStr(4)
@@ -116,7 +113,8 @@ func CmdShare() cli.Command {
} else {
sharePwd = ""
}
RunShareSet(modeFlag, parseDriveId(c), c.Args(), et, sharePwd)
RunOpenShareSet(modeFlag, parseDriveId(c), c.Args(), et, sharePwd)
return nil
},
Flags: []cli.Flag{
@@ -142,97 +140,12 @@ func CmdShare() cli.Command {
},
},
},
{
Name: "list",
Aliases: []string{"l"},
Usage: "列出已分享文件/目录",
UsageText: cmder.App().Name + " share list",
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
RunShareList()
return nil
},
Flags: []cli.Flag{},
},
{
Name: "cancel",
Aliases: []string{"c"},
Usage: "取消分享文件/目录",
UsageText: cmder.App().Name + " share cancel <shareid_1> <shareid_2> ...",
Description: `目前只支持通过分享id (shareid) 来取消分享.`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
if c.NArg() < 1 {
cli.ShowCommandHelp(c, c.Command.Name)
return nil
}
RunShareCancel(c.Args())
return nil
},
},
{
Name: "export",
Usage: "导出分享记录保存到文件",
UsageText: cmder.App().Name + " share export <csv file path>",
Description: `
导出分享记录,并保存到指定的文件。目前支持csv格式
示例:
导出所有有效的分享并保存成文件
aliyunpan share export "d:\myfoler\share_list.csv"
导出所有的分享并保存成文件
aliyunpan share export -option 2 "d:\myfoler\share_list.csv"
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
if c.NArg() < 1 {
cli.ShowCommandHelp(c, c.Command.Name)
return nil
}
opt := c.String("option")
if opt == "" {
opt = "1"
}
filePath := c.Args()[0]
RunShareExport(opt, filePath)
return nil
},
Flags: []cli.Flag{
cli.StringFlag{
Name: "option",
Usage: "导出选项,1-有效分享 2-全部分享",
Value: "1",
},
},
},
},
}
}
// RunShareSet 执行分享
func RunShareSet(modeFlag, driveId string, paths []string, expiredTime string, sharePwd string) {
// RunOpenShareSet 执行分享
func RunOpenShareSet(modeFlag, driveId string, paths []string, expiredTime string, sharePwd string) {
if len(paths) <= 0 {
fmt.Println("请指定文件路径")
return
@@ -267,9 +180,10 @@ func RunShareSet(modeFlag, driveId string, paths []string, expiredTime string, s
return
}
// 创建分享类型
if modeFlag == "3" {
// 快传
r, err1 := panClient.WebapiPanClient().FastShareLinkCreate(aliyunpan_web.FastShareCreateParam{
r, err1 := panClient.OpenapiPanClient().FastShareLinkCreate(aliyunpan.FastShareCreateParam{
DriveId: driveId,
FileIdList: fidList,
})
@@ -283,10 +197,10 @@ func RunShareSet(modeFlag, driveId string, paths []string, expiredTime string, s
}
fmt.Printf("创建快传链接成功\n")
fmt.Printf("链接:%s\n", r.ShareUrl)
fmt.Printf("链接:%s\n", strings.ReplaceAll(r.ShareUrl, "https://www.aliyundrive.com", "https://www.alipan.com"))
} else {
// 分享
r, err1 := panClient.WebapiPanClient().ShareLinkCreate(aliyunpan_web.ShareCreateParam{
r, err1 := panClient.OpenapiPanClient().ShareLinkCreate(aliyunpan.ShareCreateParam{
DriveId: driveId,
SharePwd: sharePwd,
Expiration: expiredTime,
@@ -301,143 +215,16 @@ func RunShareSet(modeFlag, driveId string, paths []string, expiredTime string, s
return
}
fmt.Printf("创建分享链接成功\n")
if modeFlag == "1" {
fmt.Printf("创建私密分享链接成功\n")
} else if modeFlag == "2" {
fmt.Printf("创建公开分享链接成功\n")
}
shareUrl := strings.ReplaceAll(r.ShareUrl, "https://www.aliyundrive.com", "https://www.alipan.com")
if len(sharePwd) > 0 {
fmt.Printf("链接:%s 提取码:%s\n", r.ShareUrl, r.SharePwd)
fmt.Printf("链接:%s 提取码:%s\n", shareUrl, r.SharePwd)
} else {
fmt.Printf("链接:%s\n", r.ShareUrl)
fmt.Printf("链接:%s\n", shareUrl)
}
}
}
// RunShareList 执行列出分享列表
func RunShareList() {
activeUser := GetActiveUser()
records, err := activeUser.PanClient().WebapiPanClient().ShareLinkList(activeUser.UserId)
if err != nil {
fmt.Printf("获取分享列表失败: %s\n", err)
return
}
tb := cmdtable.NewTable(os.Stdout)
tb.SetHeader([]string{"#", "ShARE_ID", "分享链接", "提取码", "文件名", "过期时间", "状态"})
now := time.Now()
for k, record := range records {
et := "永久有效"
if len(record.Expiration) > 0 {
et = record.Expiration
}
status := "有效"
if record.Status == "enabled" {
if record.FirstFile == nil {
status = "已删除"
} else {
cz := time.FixedZone("CST", 8*3600)
if len(record.Expiration) > 0 {
expiredTime, _ := time.ParseInLocation("2006-01-02 15:04:05", record.Expiration, cz)
if expiredTime.Unix() < now.Unix() {
status = "已过期"
}
}
}
} else if record.Status == "forbidden" {
status = "违规"
}
tb.Append([]string{strconv.Itoa(k + 1), record.ShareId, record.ShareUrl, record.SharePwd,
record.ShareName,
//record.FileIdList[0],
et,
status})
}
tb.Render()
}
// RunShareCancel 执行取消分享
func RunShareCancel(shareIdList []string) {
if len(shareIdList) == 0 {
fmt.Printf("取消分享操作失败, 没有任何 shareid\n")
return
}
activeUser := GetActiveUser()
r, err := activeUser.PanClient().WebapiPanClient().ShareLinkCancel(shareIdList)
if err != nil {
fmt.Printf("取消分享操作失败: %s\n", err)
return
}
if r != nil && len(r) > 0 {
fmt.Printf("取消分享操作成功\n")
} else {
fmt.Printf("取消分享操作失败\n")
}
}
func RunShareExport(option, saveFilePath string) {
activeUser := GetActiveUser()
records, err := activeUser.PanClient().WebapiPanClient().ShareLinkList(activeUser.UserId)
if err != nil {
fmt.Printf("获取分享列表失败: %s\n", err)
return
}
columns := [][]string{{"序号", "分享ID", "分享链接", "提取码", "文件名", "过期时间", "状态"}}
now := time.Now()
idx := 1
for _, record := range records {
et := "永久有效"
if len(record.Expiration) > 0 {
et = record.Expiration
}
status := "有效"
if record.FirstFile == nil {
status = "已删除"
if option == "1" {
continue
}
} else {
cz := time.FixedZone("CST", 8*3600)
if len(record.Expiration) > 0 {
expiredTime, _ := time.ParseInLocation("2006-01-02 15:04:05", record.Expiration, cz)
if expiredTime.Unix() < now.Unix() {
status = "已过期"
}
}
}
if option == "1" {
if status == "已过期" || status == "已删除" {
continue
}
}
line := []string{strconv.Itoa(idx), record.ShareId, record.ShareUrl, record.SharePwd, record.ShareName, et, status}
idx += 1
columns = append(columns, line)
}
// save to file
if ExportCsv(saveFilePath, columns) {
fmt.Println("分享导出成功:", saveFilePath)
}
}
func ExportCsv(savePath string, data [][]string) bool {
folder := filepath.Dir(savePath)
if _, err := os.Stat(folder); err != nil {
if !os.IsExist(err) {
os.MkdirAll(folder, os.ModePerm)
}
}
fp, err := os.Create(savePath) // 创建文件句柄
if err != nil {
fmt.Println("创建文件["+savePath+"]失败,%v", err)
return false
}
defer fp.Close()
fp.WriteString("\xEF\xBB\xBF") // 写入UTF-8 BOM
w := csv.NewWriter(fp) //创建一个新的写入文件流
w.WriteAll(data)
w.Flush()
return true
}
+443
View File
@@ -0,0 +1,443 @@
// Copyright (c) 2020 tickstep.
//
// 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.
package command
import (
"encoding/csv"
"fmt"
"github.com/tickstep/aliyunpan-api/aliyunpan"
"github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
"github.com/tickstep/aliyunpan/cmder"
"github.com/tickstep/aliyunpan/cmder/cmdtable"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/urfave/cli"
"os"
"path"
"path/filepath"
"strconv"
"time"
)
// web端分享接口
func CmdShareWeb() cli.Command {
return cli.Command{
Name: "sharew",
Usage: "分享文件/目录",
UsageText: cmder.App().Name + " sharew",
Category: "阿里云盘",
Before: ReloadConfigFunc,
Action: func(c *cli.Context) error {
cli.ShowCommandHelp(c, c.Command.Name)
return nil
},
Subcommands: []cli.Command{
{
Name: "set",
Aliases: []string{"s"},
Usage: "设置分享文件/目录",
UsageText: cmder.App().Name + " share set <文件/目录1> <文件/目录2> ...",
Description: `
示例:
创建文件 1.mp4 的分享链接
aliyunpan share set -mode 1 1.mp4
创建 /我的视频/ 目录下所有mp4文件的分享链接,支持通配符
aliyunpan share set -mode 1 /我的视频/*.mp4
创建文件 1.mp4 的分享链接,并指定分享密码为2333
aliyunpan share set -mode 1 -sharePwd 2333 1.mp4
创建文件 1.mp4 的分享链接,并指定有效期为1天
aliyunpan share set -mode 1 -time 1 1.mp4
创建文件 1.mp4 的快传链接
aliyunpan share set 1.mp4
`,
Action: func(c *cli.Context) error {
if c.NArg() < 1 {
cli.ShowCommandHelp(c, c.Command.Name)
return nil
}
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
et := ""
timeFlag := "0"
if c.IsSet("time") {
timeFlag = c.String("time")
}
now := time.Now()
if timeFlag == "1" {
et = now.Add(time.Duration(1) * time.Hour * 24).Format("2006-01-02 15:04:05")
} else if timeFlag == "2" {
et = now.Add(time.Duration(7) * time.Hour * 24).Format("2006-01-02 15:04:05")
} else {
et = ""
}
sharePwd := ""
if c.IsSet("sharePwd") {
sharePwd = c.String("sharePwd")
}
modeFlag := "3"
if c.IsSet("mode") {
modeFlag = c.String("mode")
}
if modeFlag == "1" || modeFlag == "2" {
if config.Config.ActiveUser().ActiveDriveId != config.Config.ActiveUser().DriveList.GetResourceDriveId() {
// 只有资源库才支持私有、公开分享
fmt.Println("只有资源库才支持分享链接,其他请使用快传链接")
return nil
}
}
if modeFlag == "1" {
if sharePwd == "" {
sharePwd = RandomStr(4)
}
} else {
sharePwd = ""
}
RunShareSet(modeFlag, parseDriveId(c), c.Args(), et, sharePwd)
return nil
},
Flags: []cli.Flag{
cli.StringFlag{
Name: "driveId",
Usage: "网盘ID",
Value: "",
},
cli.StringFlag{
Name: "time",
Usage: "有效期,0-永久,1-1天,2-7天",
Value: "0",
},
cli.StringFlag{
Name: "mode",
Usage: "模式,1-私密分享,2-公开分享,3-快传",
Value: "3",
},
cli.StringFlag{
Name: "sharePwd",
Usage: "自定义私密分享密码,4个字符,没有指定则随机生成",
Value: "",
},
},
},
{
Name: "list",
Aliases: []string{"l"},
Usage: "列出已分享文件/目录",
UsageText: cmder.App().Name + " share list",
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
RunShareList()
return nil
},
Flags: []cli.Flag{},
},
{
Name: "cancel",
Aliases: []string{"c"},
Usage: "取消分享文件/目录",
UsageText: cmder.App().Name + " share cancel <shareid_1> <shareid_2> ...",
Description: `目前只支持通过分享id (shareid) 来取消分享.`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
if c.NArg() < 1 {
cli.ShowCommandHelp(c, c.Command.Name)
return nil
}
RunShareCancel(c.Args())
return nil
},
},
{
Name: "export",
Usage: "导出分享记录保存到文件",
UsageText: cmder.App().Name + " share export <csv file path>",
Description: `
导出分享记录,并保存到指定的文件。目前支持csv格式
示例:
导出所有有效的分享并保存成文件
aliyunpan share export "d:\myfoler\share_list.csv"
导出所有的分享并保存成文件
aliyunpan share export -option 2 "d:\myfoler\share_list.csv"
`,
Action: func(c *cli.Context) error {
if config.Config.ActiveUser() == nil {
fmt.Println("未登录账号")
return nil
}
if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil {
fmt.Println("WEB客户端未登录,请登录后再使用该命令")
return nil
}
if c.NArg() < 1 {
cli.ShowCommandHelp(c, c.Command.Name)
return nil
}
opt := c.String("option")
if opt == "" {
opt = "1"
}
filePath := c.Args()[0]
RunShareExport(opt, filePath)
return nil
},
Flags: []cli.Flag{
cli.StringFlag{
Name: "option",
Usage: "导出选项,1-有效分享 2-全部分享",
Value: "1",
},
},
},
},
}
}
// RunShareSet 执行分享
func RunShareSet(modeFlag, driveId string, paths []string, expiredTime string, sharePwd string) {
if len(paths) <= 0 {
fmt.Println("请指定文件路径")
return
}
activeUser := GetActiveUser()
panClient := activeUser.PanClient()
allFileList := []*aliyunpan.FileEntity{}
for idx := 0; idx < len(paths); idx++ {
absolutePath := path.Clean(activeUser.PathJoin(driveId, paths[idx]))
fileList, err1 := matchPathByShellPattern(driveId, absolutePath)
if err1 != nil {
fmt.Println("文件不存在: " + absolutePath)
continue
}
if fileList == nil || len(fileList) == 0 {
// 文件不存在
fmt.Println("文件不存在: " + absolutePath)
continue
}
// 匹配的文件
allFileList = append(allFileList, fileList...)
}
fidList := []string{}
for _, f := range allFileList {
fidList = append(fidList, f.FileId)
}
if len(fidList) == 0 {
fmt.Printf("没有指定有效的文件\n")
return
}
if modeFlag == "3" {
// 快传
r, err1 := panClient.WebapiPanClient().FastShareLinkCreate(aliyunpan.FastShareCreateParam{
DriveId: driveId,
FileIdList: fidList,
})
if err1 != nil || r == nil {
if err1.Code == apierror.ApiCodeFileShareNotAllowed {
fmt.Printf("创建快传链接失败: 该文件类型不允许分享\n")
} else {
fmt.Printf("创建快传链接失败: %s\n", err1)
}
return
}
fmt.Printf("创建快传链接成功\n")
fmt.Printf("链接:%s\n", r.ShareUrl)
} else {
// 分享
r, err1 := panClient.WebapiPanClient().ShareLinkCreate(aliyunpan.ShareCreateParam{
DriveId: driveId,
SharePwd: sharePwd,
Expiration: expiredTime,
FileIdList: fidList,
})
if err1 != nil || r == nil {
if err1.Code == apierror.ApiCodeFileShareNotAllowed {
fmt.Printf("创建分享链接失败: 该文件类型不允许分享\n")
} else {
fmt.Printf("创建分享链接失败: %s\n", err1)
}
return
}
fmt.Printf("创建分享链接成功\n")
if len(sharePwd) > 0 {
fmt.Printf("链接:%s 提取码:%s\n", r.ShareUrl, r.SharePwd)
} else {
fmt.Printf("链接:%s\n", r.ShareUrl)
}
}
}
// RunShareList 执行列出分享列表
func RunShareList() {
activeUser := GetActiveUser()
records, err := activeUser.PanClient().WebapiPanClient().ShareLinkList(activeUser.UserId)
if err != nil {
fmt.Printf("获取分享列表失败: %s\n", err)
return
}
tb := cmdtable.NewTable(os.Stdout)
tb.SetHeader([]string{"#", "ShARE_ID", "分享链接", "提取码", "文件名", "过期时间", "状态"})
now := time.Now()
for k, record := range records {
et := "永久有效"
if len(record.Expiration) > 0 {
et = record.Expiration
}
status := "有效"
if record.Status == "enabled" {
if record.FirstFile == nil {
status = "已删除"
} else {
cz := time.FixedZone("CST", 8*3600)
if len(record.Expiration) > 0 {
expiredTime, _ := time.ParseInLocation("2006-01-02 15:04:05", record.Expiration, cz)
if expiredTime.Unix() < now.Unix() {
status = "已过期"
}
}
}
} else if record.Status == "forbidden" {
status = "违规"
}
tb.Append([]string{strconv.Itoa(k + 1), record.ShareId, record.ShareUrl, record.SharePwd,
record.ShareName,
//record.FileIdList[0],
et,
status})
}
tb.Render()
}
// RunShareCancel 执行取消分享
func RunShareCancel(shareIdList []string) {
if len(shareIdList) == 0 {
fmt.Printf("取消分享操作失败, 没有任何 shareid\n")
return
}
activeUser := GetActiveUser()
r, err := activeUser.PanClient().WebapiPanClient().ShareLinkCancel(shareIdList)
if err != nil {
fmt.Printf("取消分享操作失败: %s\n", err)
return
}
if r != nil && len(r) > 0 {
fmt.Printf("取消分享操作成功\n")
} else {
fmt.Printf("取消分享操作失败\n")
}
}
func RunShareExport(option, saveFilePath string) {
activeUser := GetActiveUser()
records, err := activeUser.PanClient().WebapiPanClient().ShareLinkList(activeUser.UserId)
if err != nil {
fmt.Printf("获取分享列表失败: %s\n", err)
return
}
columns := [][]string{{"序号", "分享ID", "分享链接", "提取码", "文件名", "过期时间", "状态"}}
now := time.Now()
idx := 1
for _, record := range records {
et := "永久有效"
if len(record.Expiration) > 0 {
et = record.Expiration
}
status := "有效"
if record.FirstFile == nil {
status = "已删除"
if option == "1" {
continue
}
} else {
cz := time.FixedZone("CST", 8*3600)
if len(record.Expiration) > 0 {
expiredTime, _ := time.ParseInLocation("2006-01-02 15:04:05", record.Expiration, cz)
if expiredTime.Unix() < now.Unix() {
status = "已过期"
}
}
}
if option == "1" {
if status == "已过期" || status == "已删除" {
continue
}
}
line := []string{strconv.Itoa(idx), record.ShareId, record.ShareUrl, record.SharePwd, record.ShareName, et, status}
idx += 1
columns = append(columns, line)
}
// save to file
if ExportCsv(saveFilePath, columns) {
fmt.Println("分享导出成功:", saveFilePath)
}
}
func ExportCsv(savePath string, data [][]string) bool {
folder := filepath.Dir(savePath)
if _, err := os.Stat(folder); err != nil {
if !os.IsExist(err) {
os.MkdirAll(folder, os.ModePerm)
}
}
fp, err := os.Create(savePath) // 创建文件句柄
if err != nil {
fmt.Println("创建文件["+savePath+"]失败,%v", err)
return false
}
defer fp.Close()
fp.WriteString("\xEF\xBB\xBF") // 写入UTF-8 BOM
w := csv.NewWriter(fp) //创建一个新的写入文件流
w.WriteAll(data)
w.Flush()
return true
}
+90 -30
View File
@@ -72,6 +72,7 @@ func CmdSync() cli.Command {
"localFolderPath": "D:/tickstep/Documents/设计文档",
"panFolderPath": "/sync_drive/我的文档",
"mode": "upload",
"policy": "increment",
"driveName": "backup"
}
]
@@ -80,7 +81,8 @@ func CmdSync() cli.Command {
name - 任务名称
localFolderPath - 本地目录
panFolderPath - 网盘目录
mode - 模式,支持两种: upload(备份本地文件到云盘),download(备份云盘文件到本地)
mode - 备份模式,支持两种: upload(备份本地文件到云盘),download(备份云盘文件到本地)
policy - 备份策略, 支持两种: exclusive(排他备份文件,目标目录多余的文件会被删除),increment(增量备份文件,目标目录多余的文件不会被删除)
driveName - 网盘名称,backup(备份盘),resource(资源盘)
例子:
@@ -160,6 +162,7 @@ driveName - 网盘名称,backup(备份盘),resource(资源盘)
localDir := c.String("ldir")
panDir := c.String("pdir")
mode := c.String("mode")
policy := c.String("policy")
driveName := c.String("drive")
if localDir != "" && panDir != "" {
// make path absolute
@@ -188,15 +191,20 @@ driveName - 网盘名称,backup(备份盘),resource(资源盘)
task = &syncdrive.SyncTask{}
task.LocalFolderPath = path.Clean(strings.ReplaceAll(localDir, "\\", "/"))
task.PanFolderPath = panDir
task.Mode = syncdrive.UploadOnly
if mode == string(syncdrive.UploadOnly) {
task.Mode = syncdrive.UploadOnly
} else if mode == string(syncdrive.DownloadOnly) {
task.Mode = syncdrive.DownloadOnly
task.Mode = syncdrive.Upload
if mode == string(syncdrive.Upload) {
task.Mode = syncdrive.Upload
} else if mode == string(syncdrive.Download) {
task.Mode = syncdrive.Download
} else if mode == string(syncdrive.SyncTwoWay) {
task.Mode = syncdrive.SyncTwoWay
} else {
task.Mode = syncdrive.UploadOnly
task.Mode = syncdrive.Upload
}
if policy == string(syncdrive.SyncPolicyExclusive) {
task.Policy = syncdrive.SyncPolicyExclusive
} else {
task.Policy = syncdrive.SyncPolicyIncrement
}
task.Name = path.Base(task.LocalFolderPath)
task.Id = utils.Md5Str(task.LocalFolderPath)
@@ -216,8 +224,18 @@ driveName - 网盘名称,backup(备份盘),resource(资源盘)
}
}
RunSync(task, dp, up, downloadBlockSize, uploadBlockSize, syncOpt, c.Int("ldt"))
cycleMode := syncdrive.CycleInfiniteLoop
if c.String("cycle") == "onetime" {
cycleMode = syncdrive.CycleOneTime
} else {
cycleMode = syncdrive.CycleInfiniteLoop
}
scanIntervalTime := int64(c.Int("sit") * 60)
if scanIntervalTime == 0 {
// 默认1分钟
scanIntervalTime = 60
}
RunSync(task, cycleMode, dp, up, downloadBlockSize, uploadBlockSize, syncOpt, c.Int("ldt"), scanIntervalTime)
return nil
},
Flags: []cli.Flag{
@@ -239,6 +257,21 @@ driveName - 网盘名称,backup(备份盘),resource(资源盘)
Usage: "备份模式, 支持两种: upload(备份本地文件到云盘),download(备份云盘文件到本地)",
Value: "upload",
},
cli.StringFlag{
Name: "policy",
Usage: "备份策略, 支持两种: exclusive(排他备份文件,目标目录多余的文件会被删除),increment(增量备份文件,目标目录多余的文件不会被删除)",
Value: "increment",
},
//cli.StringFlag{
// Name: "pri",
// Usage: "同步优先级,只对sync模式有效。当网盘和本地存在同名文件,优先使用哪个,选项支持三种: time-时间优先,local-本地优先,pan-网盘优先",
// Value: "time",
//},
cli.StringFlag{
Name: "cycle",
Usage: "备份周期, 支持两种: infinity(永久循环备份),onetime(只运行一次备份)",
Value: "infinity",
},
cli.IntFlag{
Name: "dp",
Usage: "download parallel, 下载并发数量,即可以同时并发下载多少个文件。0代表跟从配置文件设置(取值范围:1 ~ 10)",
@@ -266,17 +299,22 @@ driveName - 网盘名称,backup(备份盘),resource(资源盘)
},
cli.IntFlag{
Name: "ldt",
Usage: "local delay time,本地文件修改检测延迟间隔,单位秒。如果本地文件会被频繁修改,例如录制视频文件,配置好该时间可以避免上传未录制好的文件",
Usage: "local delay time,本地文件修改检测延迟间隔,单位秒。如果本地文件会被频繁修改,例如录制视频文件,配置好该时间可以避免上传未录制好的文件。",
Value: 3,
},
cli.IntFlag{
Name: "sit",
Usage: "scan interval time,扫描文件间隔时间,单位:分钟。",
Value: 1,
},
},
},
},
}
}
func RunSync(defaultTask *syncdrive.SyncTask, fileDownloadParallel, fileUploadParallel int, downloadBlockSize, uploadBlockSize int64,
flag syncdrive.SyncPriorityOption, localDelayTime int) {
func RunSync(defaultTask *syncdrive.SyncTask, cycleMode syncdrive.CycleMode, fileDownloadParallel, fileUploadParallel int, downloadBlockSize, uploadBlockSize int64,
flag syncdrive.SyncPriorityOption, localDelayTime int, scanTimeInterval int64) {
maxDownloadRate := config.Config.MaxDownloadRate
maxUploadRate := config.Config.MaxUploadRate
activeUser := GetActiveUser()
@@ -338,7 +376,7 @@ func RunSync(defaultTask *syncdrive.SyncTask, fileDownloadParallel, fileUploadPa
fmt.Printf("备份配置文件:%s\n下载并发:%d\n上传并发:%d\n下载分片大小:%s\n上传分片大小:%s\n",
syncConfigFile, fileDownloadParallel, fileUploadParallel, converter.ConvertFileSize(downloadBlockSize, 2),
converter.ConvertFileSize(uploadBlockSize, 2))
if _, e := syncMgr.Start(tasks); e != nil {
if _, e := syncMgr.Start(tasks, cycleMode, scanTimeInterval); e != nil {
fmt.Println("启动任务失败:", e)
return
}
@@ -346,29 +384,51 @@ func RunSync(defaultTask *syncdrive.SyncTask, fileDownloadParallel, fileUploadPa
_, ok := os.LookupEnv("ALIYUNPAN_DOCKER")
if ok {
// in docker container
// 使用休眠以节省CPU资源
fmt.Println("本命令不会退出,程序正在以Docker的方式运行。如需退出请借助Docker提供的方式。")
for {
time.Sleep(60 * time.Second)
}
} else {
if global.IsAppInCliMode {
// in cmd mode
c := ""
fmt.Println("本命令不会退出,如需要结束同步备份进程请输入y,然后按Enter键进行停止。")
for strings.ToLower(c) != "y" {
fmt.Scan(&c)
}
} else {
fmt.Println("本命令不会退出,程序正在以非交互的方式运行。如需退出请借助运行环境提供的方式。")
logger.Verboseln("App not in CLI mode, not need to listen to input stream")
if cycleMode == syncdrive.CycleInfiniteLoop {
// 使用休眠以节省CPU资源
fmt.Println("本命令不会退出,程序正在以Docker的方式运行。如需退出请借助Docker提供的方式。")
for {
time.Sleep(60 * time.Second)
}
} else {
for {
if syncMgr.IsAllTaskCompletely() {
fmt.Println("所有备份任务已完成")
break
}
time.Sleep(5 * time.Second)
}
syncMgr.DoTaskSyncCompletelyPluginCallback()
}
} else {
if cycleMode == syncdrive.CycleInfiniteLoop {
if global.IsAppInCliMode {
// in cmd mode
c := ""
fmt.Println("本命令不会退出,如需要结束同步备份进程请输入y,然后按Enter键进行停止。")
for strings.ToLower(c) != "y" {
fmt.Scan(&c)
}
} else {
fmt.Println("本命令不会退出,程序正在以非交互的方式运行。如需退出请借助运行环境提供的方式。")
logger.Verboseln("App not in CLI mode, not need to listen to input stream")
for {
time.Sleep(60 * time.Second)
}
}
} else {
for {
if syncMgr.IsAllTaskCompletely() {
fmt.Println("所有备份任务已完成")
break
}
time.Sleep(5 * time.Second)
}
syncMgr.DoTaskSyncCompletelyPluginCallback()
}
}
fmt.Println("正在停止同步备份任务,请稍等...")
fmt.Println("正在退出同步备份任务,请稍等...")
// stop task
syncMgr.Stop()
+78 -52
View File
@@ -15,12 +15,6 @@ package command
import (
"fmt"
"github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
"github.com/tickstep/aliyunpan/cmder"
"github.com/tickstep/aliyunpan/internal/log"
"github.com/tickstep/aliyunpan/internal/plugins"
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/library-go/requester/rio/speeds"
"os"
"path"
"path/filepath"
@@ -28,6 +22,14 @@ import (
"sync"
"time"
"github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
"github.com/tickstep/aliyunpan/cmder"
"github.com/tickstep/aliyunpan/internal/log"
"github.com/tickstep/aliyunpan/internal/plugins"
"github.com/tickstep/aliyunpan/internal/ui"
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/library-go/requester/rio/speeds"
"github.com/tickstep/library-go/logger"
"github.com/urfave/cli"
@@ -54,17 +56,18 @@ const (
type (
// UploadOptions 上传可选项
UploadOptions struct {
AllParallel int // 所有文件并发上传数量,即可以同时并发上传多少个文件
Parallel int // 单个文件并发上传数量
MaxRetry int
MaxTimeoutSec int // http请求超时时间,单位秒
NoRapidUpload bool
ShowProgress bool
IsOverwrite bool // 覆盖已存在的文件,如果同名文件已存在则移到回收站里
IsSkipSameName bool // 跳过已存在的文件,即使文件内容不一致(不检查SHA1)
DriveId string
ExcludeNames []string // 排除的文件名,包括文件夹和文件。即这些文件/文件夹不进行上传,支持正则表达式
BlockSize int64 // 分片大小
AllParallel int // 所有文件并发上传数量,即可以同时并发上传多少个文件
Parallel int // 单个文件并发上传数量
MaxRetry int
MaxTimeoutSec int // http请求超时时间,单位秒
NoRapidUpload bool
ShowProgress bool
IsOverwrite bool // 覆盖已存在的文件,如果同名文件已存在则移到回收站里
IsSkipSameName bool // 跳过已存在的文件,即使文件内容不一致(不检查SHA1)
DriveId string
ExcludeNames []string // 排除的文件名,包括文件夹和文件。即这些文件/文件夹不进行上传,支持正则表达式
BlockSize int64 // 分片大小
IsUseUIDashboard bool // 是否使用UI面板显示上传进度
}
)
@@ -114,6 +117,10 @@ var UploadFlags = []cli.Flag{
Usage: "block size,上传分片大小,单位KB。推荐值:1024 ~ 10240。当上传极大单文件时候请适当调高该值",
Value: 10240,
},
cli.BoolFlag{
Name: "ui",
Usage: "(BETA) 使用UI面板显示上传详情和进度,更加直观和友好",
},
}
func CmdUpload() cli.Command {
@@ -192,17 +199,18 @@ func CmdUpload() cli.Command {
//}
RunUpload(subArgs[:c.NArg()-1], subArgs[c.NArg()-1], &UploadOptions{
AllParallel: c.Int("p"), // 多文件上传的时候,允许同时并行上传的文件数量
Parallel: 1, // 一个文件同时多少个线程并发上传的数量。阿里云盘只支持单线程按顺序进行文件part数据上传,所以只能是1
MaxRetry: c.Int("retry"),
MaxTimeoutSec: timeout,
NoRapidUpload: c.Bool("norapid"),
ShowProgress: !c.Bool("np"),
IsOverwrite: c.Bool("ow"),
IsSkipSameName: c.Bool("skip"),
DriveId: parseDriveId(c),
ExcludeNames: c.StringSlice("exn"),
BlockSize: int64(c.Int("bs") * 1024),
AllParallel: c.Int("p"), // 多文件上传的时候,允许同时并行上传的文件数量
Parallel: 1, // 一个文件同时多少个线程并发上传的数量。阿里云盘只支持单线程按顺序进行文件part数据上传,所以只能是1
MaxRetry: c.Int("retry"),
MaxTimeoutSec: timeout,
NoRapidUpload: c.Bool("norapid"),
ShowProgress: !c.Bool("np"),
IsOverwrite: c.Bool("ow"),
IsSkipSameName: c.Bool("skip"),
DriveId: parseDriveId(c),
ExcludeNames: c.StringSlice("exn"),
BlockSize: int64(c.Int("bs") * 1024),
IsUseUIDashboard: c.Bool("ui"),
})
// 释放文件锁
@@ -222,21 +230,6 @@ func RunUpload(localPaths []string, savePath string, opt *UploadOptions) {
activeUser.PanClient().OpenapiPanClient().ClearCache()
defer activeUser.PanClient().OpenapiPanClient().DisableCache()
//// pan token expired checker
//continueFlag := int32(0)
//atomic.StoreInt32(&continueFlag, 0)
//defer func() {
// atomic.StoreInt32(&continueFlag, 1)
//}()
//go func(flag *int32) {
// for atomic.LoadInt32(flag) == 0 {
// time.Sleep(time.Duration(1) * time.Minute)
// if RefreshWebTokenInNeed(activeUser, config.Config.DeviceName) {
// logger.Verboseln("update access token for upload task")
// }
// }
//}(&continueFlag)
if opt == nil {
opt = &UploadOptions{}
}
@@ -264,7 +257,8 @@ func RunUpload(localPaths []string, savePath string, opt *UploadOptions) {
activeUser.PanClient().OpenapiPanClient().SetTimeout(time.Duration(opt.MaxTimeoutSec) * time.Second)
}
fmt.Printf("\n[0] 当前文件上传最大并发量为: %d, 上传分片大小为: %s\n", opt.AllParallel, converter.ConvertFileSize(opt.BlockSize, 2))
targetDriveName := config.Config.ActiveUser().DriveList.GetDriveNameById(opt.DriveId)
fmt.Printf("\n[0] 当前文件上传最大并发量为: %d, 上传分片大小为: %s, 目标网盘: %s\n", opt.AllParallel, converter.ConvertFileSize(opt.BlockSize, 2), targetDriveName)
savePath = activeUser.PathJoin(opt.DriveId, savePath)
_, err1 := activeUser.PanClient().OpenapiPanClient().FileInfoByPath(opt.DriveId, savePath)
@@ -304,6 +298,23 @@ func RunUpload(localPaths []string, savePath string, opt *UploadOptions) {
// 全局速度统计
globalSpeedsStat := &speeds.Speeds{}
// 上传统计UI面板
var dashboard *ui.DashboardPanel = nil
if opt.IsUseUIDashboard && opt.ShowProgress && ui.IsTerminal(os.Stdout) {
dashboard = ui.NewDashboardPanel(ui.DashboardPanelUpload, opt.AllParallel, globalSpeedsStat, &ui.DashboardOptions{
Title: "上传统计UI面板",
ActiveSlots: 3, // 下载文件进度展示,最大同时展示3个
MaxHistory: 50, // 下载日志显示,最多同时显示50条,这个会按照窗口大小进行自适应显示
})
}
logf := func(format string, a ...interface{}) {
if dashboard != nil {
dashboard.Logf(format, a...)
return
}
fmt.Printf(format, a...)
}
// 获取当前插件
plugin, _ := pluginManger.GetPlugin()
@@ -318,7 +329,7 @@ func RunUpload(localPaths []string, savePath string, opt *UploadOptions) {
// 是否排除上传
if utils.IsExcludeFile(curPath, &opt.ExcludeNames) {
fmt.Printf("排除文件: %s\n", curPath)
logf("排除文件: %s\n", curPath)
continue
}
@@ -340,7 +351,7 @@ func RunUpload(localPaths []string, savePath string, opt *UploadOptions) {
// 是否排除上传
if utils.IsExcludeFile(file.LogicPath, &opt.ExcludeNames) {
fmt.Printf("排除文件: %s\n", file.LogicPath)
logf("排除文件: %s\n", file.LogicPath)
return filepath.SkipDir
}
@@ -369,13 +380,13 @@ func RunUpload(localPaths []string, savePath string, opt *UploadOptions) {
if uploadFilePrepareResult, er := plugin.UploadFilePrepareCallback(plugins.GetContext(activeUser), pluginParam); er == nil && uploadFilePrepareResult != nil {
if strings.Compare("yes", uploadFilePrepareResult.UploadApproved) != 0 {
// skip upload this file
fmt.Printf("插件禁止该文件上传: %s\n", file.LogicPath)
logf("插件禁止该文件上传: %s\n", file.LogicPath)
return filepath.SkipDir
}
if uploadFilePrepareResult.DriveFilePath != "" {
targetSavePanRelativePath := strings.TrimPrefix(uploadFilePrepareResult.DriveFilePath, "/")
subSavePath = path.Clean(savePath + aliyunpan.PathSeparator + targetSavePanRelativePath)
fmt.Printf("插件修改文件网盘保存路径为: %s\n", subSavePath)
logf("插件修改文件网盘保存路径为: %s\n", subSavePath)
}
}
@@ -398,22 +409,26 @@ func RunUpload(localPaths []string, savePath string, opt *UploadOptions) {
IsSkipSameName: opt.IsSkipSameName,
GlobalSpeedsStat: globalSpeedsStat,
FileRecorder: fileRecorder,
UI: dashboard,
}, opt.MaxRetry)
fmt.Printf("[%s] 加入上传队列: %s\n", taskinfo.Id(), file.LogicPath)
logf("[%s] 加入上传队列: %s\n", taskinfo.Id(), file.LogicPath)
if dashboard != nil {
dashboard.RegisterTask(taskinfo.Id(), file.LogicPath, fi.Size(), !fi.IsDir())
}
} else {
// 创建文件夹
// 这样空文件夹也可以正确上传
saveFilePath := subSavePath
if saveFilePath != "/" {
folderCreateMutex.Lock()
fmt.Printf("正在检测和创建云盘文件夹: %s\n", saveFilePath)
logf("正在检测和创建云盘文件夹: %s\n", saveFilePath)
_, apierr1 := activeUser.PanClient().OpenapiPanClient().FileInfoByPath(opt.DriveId, saveFilePath)
time.Sleep(1 * time.Second)
if apierr1 != nil && apierr1.Code == apierror.ApiCodeFileNotFoundCode {
logger.Verbosef("%s 创建云盘文件夹: %s\n", utils.NowTimeStr(), saveFilePath)
rs, apierr := activeUser.PanClient().OpenapiPanClient().MkdirByFullPath(opt.DriveId, saveFilePath)
if apierr != nil || rs.FileId == "" {
fmt.Printf("创建云盘文件夹失败: %s\n", saveFilePath)
logf("创建云盘文件夹失败: %s\n", saveFilePath)
}
}
folderCreateMutex.Unlock()
@@ -425,19 +440,30 @@ func RunUpload(localPaths []string, savePath string, opt *UploadOptions) {
file := localfile.NewSymlinkFile(curPath)
if err = localfile.WalkAllFile(file, walkFunc); err != nil {
if err != filepath.SkipDir {
fmt.Printf("警告: 遍历错误: %s\n", err)
logf("警告: 遍历错误: %s\n", err)
}
}
}
// 执行上传任务
var failedList []*lane.Deque
// 启动UI面板显示
if dashboard != nil {
dashboard.Start()
}
executor.Execute()
failed := executor.FailedDeque()
if failed.Size() > 0 {
failedList = append(failedList, failed)
}
// 关闭UI面板
if dashboard != nil {
dashboard.Close()
}
fmt.Printf("\n")
fmt.Printf("上传结束, 时间: %s, 数据总量: %s\n", utils.ConvertTime(statistic.Elapsed()), converter.ConvertFileSize(statistic.TotalSize(), 2))
+8 -3
View File
@@ -46,7 +46,7 @@ func CmdSu() cli.Command {
示例:
aliyunpan su
aliyunpan su <uid or name>
aliyunpan su <uid>
`,
Category: "阿里云盘账号",
Before: ReloadConfigFunc,
@@ -94,7 +94,7 @@ func CmdSu() cli.Command {
cli.ShowCommandHelp(c, c.Command.Name)
}
switchedUser, err := config.Config.SwitchUser(uid, inputData)
switchedUser, err := config.Config.SwitchUser(uid)
if err != nil {
fmt.Printf("切换用户失败, %s\n", err)
return nil
@@ -129,7 +129,12 @@ func CmdWho() cli.Command {
}
activeUser := config.Config.ActiveUser()
cloudName := activeUser.GetDriveById(activeUser.ActiveDriveId).DriveName
fmt.Printf("当前帐号UID: %s, 昵称: %s, 用户名: %s, 当前使用网盘:%s\n", activeUser.UserId, activeUser.Nickname, activeUser.AccountName, cloudName)
user, _ := GetActivePanClient().OpenapiPanClient().GetUserInfo()
thirdParty := "未开通"
if user.ThirdPartyVip {
thirdParty = "已开通(" + user.ThirdPartyVipExpire + ")"
}
fmt.Printf("当前帐号UID: %s, 昵称: %s, 三方权益包: %s, 当前使用网盘:%s\n", activeUser.UserId, activeUser.Nickname, thirdParty, cloudName)
return nil
},
}
+130 -24
View File
@@ -23,6 +23,7 @@ import (
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/library-go/logger"
"github.com/urfave/cli"
"math"
"math/rand"
"net/url"
"path"
@@ -142,9 +143,9 @@ func NewWebLoginToken(accessToken string, expired int64) aliyunpan_web.WebLoginT
}
// RefreshWebTokenInNeed 刷新 webapi access token
func RefreshWebTokenInNeed(activeUser *config.PanUser, deviceName string) bool {
func RefreshWebTokenInNeed(activeUser *config.PanUser) *panlogin.CommonResultError {
if activeUser == nil {
return false
return panlogin.NewCommonResultError("activeUser is nil")
}
// refresh expired web token
@@ -153,7 +154,7 @@ func RefreshWebTokenInNeed(activeUser *config.PanUser, deviceName string) bool {
cz := time.FixedZone("CST", 8*3600) // 东8区
expiredTime := time.Unix(activeUser.WebapiToken.Expired, 0).In(cz)
now := time.Now()
if (expiredTime.Unix() - now.Unix()) <= (2 * 60) { // 有效期小于2min就刷新
if (expiredTime.Unix() - now.Unix()) <= (10 * 60) { // 有效期小于10min就刷新
pluginManger := plugins.NewPluginManager(config.GetPluginDir())
plugin, _ := pluginManger.GetPlugin()
params := &plugins.UserTokenRefreshFinishParams{
@@ -167,9 +168,12 @@ func RefreshWebTokenInNeed(activeUser *config.PanUser, deviceName string) bool {
// need update refresh token
logger.Verboseln("web access token expired, get new from server")
loginHelper := panlogin.NewLoginHelper(config.DefaultTokenServiceWebHost)
wt, e := loginHelper.GetWebapiNewToken(activeUser.TicketId, activeUser.UserId, activeUser.PanClient().WebapiPanClient().GetAccessToken())
if e != nil {
logger.Verboseln("get web token from server error: ", e)
wt, re := loginHelper.GetWebapiNewToken(activeUser.TicketId, activeUser.UserId, activeUser.PanClient().WebapiPanClient().GetAccessToken())
if re != nil {
logger.Verboseln("get web token from server error: ", re.Msg)
if re.Code != panlogin.SUCCESS {
return re
}
}
if wt != nil {
params.Result = "success"
@@ -191,42 +195,51 @@ func RefreshWebTokenInNeed(activeUser *config.PanUser, deviceName string) bool {
}
// create new signature
_, e := activeUser.PanClient().WebapiPanClient().CreateSession(nil)
if e != nil {
logger.Verboseln("call CreateSession error in RefreshWebTokenInNeed: " + e.Error())
_, e1 := activeUser.PanClient().WebapiPanClient().CreateSession(nil)
if e1 != nil {
logger.Verboseln("call CreateSession error in RefreshWebTokenInNeed: " + e1.Error())
}
return &panlogin.CommonResultError{
Code: 0,
Msg: "",
}
return true
} else {
// token refresh error
// if token has expired, callback plugin api for notify
if now.Unix() >= expiredTime.Unix() {
params.Result = "fail"
params.Message = e.Error()
params.Message = re.Msg
params.OldToken = activeUser.WebapiToken.AccessToken
if er1 := plugin.UserTokenRefreshFinishCallback(plugins.GetContext(activeUser), params); er1 != nil {
logger.Verbosef("UserTokenRefreshFinishCallback error: " + er1.Error())
}
}
}
} else {
return &panlogin.CommonResultError{
Code: 0,
Msg: "web token is validate, not need to refresh",
}
}
}
}
return false
return panlogin.NewCommonResultError("activeUser is invalidate")
}
// RefreshOpenTokenInNeed 刷新 openapi access token
func RefreshOpenTokenInNeed(activeUser *config.PanUser) bool {
func RefreshOpenTokenInNeed(activeUser *config.PanUser) *panlogin.CommonResultError {
if activeUser == nil {
return false
return panlogin.NewCommonResultError("activeUser is nil")
}
// OpenAPI的token只有2个小时有效期,需要在token过期之前进行刷新并把获取到的新token存储在本地
// refresh expired openapi token
if activeUser.PanClient().OpenapiPanClient() != nil {
if activeUser.OpenapiToken != nil && len(activeUser.OpenapiToken.AccessToken) > 0 {
cz := time.FixedZone("CST", 8*3600) // 东8区
expiredTime := time.Unix(activeUser.OpenapiToken.Expired, 0).In(cz)
now := time.Now()
if (expiredTime.Unix() - now.Unix()) <= (2 * 60) { // 有效期小于2min就刷新
now := time.Now().In(cz)
if (expiredTime.Unix() - now.Unix()) <= (10 * 60) { // 有效期小于10min就刷新
pluginManger := plugins.NewPluginManager(config.GetPluginDir())
plugin, _ := pluginManger.GetPlugin()
params := &plugins.UserTokenRefreshFinishParams{
@@ -240,13 +253,17 @@ func RefreshOpenTokenInNeed(activeUser *config.PanUser) bool {
// need update refresh token
logger.Verboseln("openapi access token expired, get new from server")
loginHelper := panlogin.NewLoginHelper(config.DefaultTokenServiceWebHost)
wt, e := loginHelper.GetOpenapiNewToken(activeUser.TicketId, activeUser.UserId, activeUser.PanClient().OpenapiPanClient().GetAccessToken())
if e != nil {
logger.Verboseln("get openapi token from server error: ", e)
wt, re := loginHelper.GetOpenapiNewToken(activeUser.TicketId, activeUser.UserId, activeUser.PanClient().OpenapiPanClient().GetAccessToken())
if re != nil {
logger.Verboseln("get openapi token from server error: ", re.Msg)
if re.Code != panlogin.SUCCESS {
return re
}
}
if wt != nil {
// 存储新token到本地
params.Result = "success"
params.OldToken = activeUser.WebapiToken.AccessToken
params.OldToken = activeUser.OpenapiToken.AccessToken
params.NewToken = wt.AccessToken
// update for user
@@ -261,23 +278,112 @@ func RefreshOpenTokenInNeed(activeUser *config.PanUser) bool {
logger.Verbosef("UserTokenRefreshFinishCallback error: " + er1.Error())
}
return true
return &panlogin.CommonResultError{
Code: 0,
Msg: "",
}
} else {
// token refresh error
// if token has expired, callback plugin api for notify
if now.Unix() >= expiredTime.Unix() {
params.Result = "fail"
params.Message = e.Error()
params.OldToken = activeUser.WebapiToken.AccessToken
params.Message = re.Msg
params.OldToken = activeUser.OpenapiToken.AccessToken
if er1 := plugin.UserTokenRefreshFinishCallback(plugins.GetContext(activeUser), params); er1 != nil {
logger.Verbosef("UserTokenRefreshFinishCallback error: " + er1.Error())
}
}
}
} else {
return &panlogin.CommonResultError{
Code: 0,
Msg: "openapi token is validate, not need to refresh",
}
}
}
}
return false
return panlogin.NewCommonResultError("activeUser is invalidate")
}
// AutomaticallyRefreshTokenTask 自动刷新Token的后台任务
func AutomaticallyRefreshTokenTask() {
go func() {
// 延迟启动自动刷新Token任务,因为主程序启动的时候默认会进行Token的初始化和刷新,避免无效的并行初始化Token
time.Sleep(time.Duration(15) * time.Minute)
// 检测延迟时间(分钟)
delayMinuteTime := time.Duration(1) * time.Minute
//delayMinuteTime := time.Duration(10) * time.Second // for test
// 请求重试次数
attempt := 1
for {
// Token刷新进程,不管是CLI命令行模式,还是直接命令模式,本刷新任务都会执行
time.Sleep(delayMinuteTime) // 定时器,每1分钟调用一次
// 重新加载配置文件
ReloadConfigFunc(nil)
activeUser := config.Config.ActiveUser()
if activeUser != nil && activeUser.UserId != "" && activeUser.PanClient() != nil {
// 刷新Openapi端Token
errResult := RefreshOpenTokenInNeed(activeUser)
attempt += 1
if errResult != nil && errResult.Code == panlogin.SUCCESS {
// 刷新成功,重置定时器
attempt = 1
delayMinuteTime = time.Duration(1) * time.Minute
// 刷新Webapi端Token
RefreshWebTokenInNeed(activeUser)
// 保存新token到配置文件
SaveConfigFunc(nil)
} else {
// 刷新Token失败
logger.Verboseln("刷新Token失败:", errResult.Msg)
if errResult.Code == panlogin.ERROR_NEED_LOGIN_AGAIN {
// token已经失效,需要重新登录
logger.Verboseln("token已经失效,需要重新登录,退出自动刷新Token任务")
return
} else if errResult.Code == panlogin.ERROR {
// 访问错误
delayMinuteTime = ProgressiveBackoffAlg(attempt)
} else if errResult.Code == panlogin.ERROR_TOO_MANY_REQUESTS {
// 请求过于频繁
delayMinuteTime = ProgressiveBackoffAlg(attempt)
}
}
}
}
}()
}
// ProgressiveBackoffAlg 指数退降算法
func ProgressiveBackoffAlg(attempt int) time.Duration {
maxRetries := 120
baseDelay := float64(1)
maxDelay := float64(1440)
if attempt >= maxRetries {
return time.Duration(maxDelay) * time.Minute
}
// 计算当前延迟
var delay float64
if attempt < 100 {
// 前 100 次:线性增长 + 小指数基数(缓慢增加)
delay = baseDelay * (1 + float64(attempt)/5) // 调整分母控制增长速度
} else {
// 100 次后:标准指数退避
delay = baseDelay * math.Pow(1.5, float64(attempt-100))
if delay > maxDelay {
delay = maxDelay
}
}
// 添加随机抖动(最多 20% 的扰动)
jitterSec := rand.Float64() * delay * 0.2 // ±20% 随机扰动
delay += jitterSec
return time.Duration(delay * float64(time.Minute))
}
func isIncludeFile(pattern string, fileName string) bool {
+13
View File
@@ -0,0 +1,13 @@
package command
import (
"fmt"
"testing"
)
func TestProgressiveBackoffAlg(t *testing.T) {
for attempt := 1; attempt <= 150; attempt++ {
delayTime := ProgressiveBackoffAlg(attempt)
fmt.Printf("第%d次,延迟分钟: %.2f\n", attempt, delayTime.Minutes())
}
}
+99
View File
@@ -0,0 +1,99 @@
// Copyright (c) 2020 tickstep.
//
// 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.
package command_local
import (
"fmt"
"github.com/tickstep/aliyunpan/internal/command"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/urfave/cli"
"os"
"runtime"
"strings"
)
// CmdLocalCd 切换本地工作目录
func CmdLocalCd() cli.Command {
return cli.Command{
Name: "lcd",
Category: "本地命令",
Usage: "切换本地工作目录",
Description: `
aliyunpan lcd <本地目录, 绝对路径或相对路径>
示例:
切换 /我的资源 工作目录:
aliyunpan lcd /我的资源
切换上级目录:
aliyunpan lcd ..
切换根目录:
aliyunpan lcd /
`,
Action: func(c *cli.Context) error {
if c.NArg() == 0 {
cli.ShowCommandHelp(c, c.Command.Name)
return nil
}
RunChangeLocalDirectory(c.Args().Get(0))
return nil
},
Flags: []cli.Flag{},
}
}
// CmdLocalPwd 当前本地工作目录
func CmdLocalPwd() cli.Command {
return cli.Command{
Name: "lpwd",
Usage: "输出本地工作目录",
Category: "本地命令",
Before: command.ReloadConfigFunc,
Action: func(c *cli.Context) error {
lwd := config.Config.LocalWorkdir
if lwd == "" {
// 默认为用户主页目录
lwd = GetLocalHomeDir()
config.Config.LocalWorkdir = lwd
}
if runtime.GOOS == "windows" {
lwd = strings.ReplaceAll(lwd, "/", "\\")
} else {
// Unix-like system, so just assume Unix
lwd = strings.ReplaceAll(lwd, "\\", "/")
}
fmt.Println("本地工作目录: " + lwd)
return nil
},
}
}
func RunChangeLocalDirectory(targetPath string) {
targetPath = LocalPathJoin(targetPath)
// 获取目标路径文件信息
localFileInfo, er := os.Stat(targetPath)
if er != nil {
fmt.Println("目录路径不存在")
return
}
if !localFileInfo.IsDir() {
fmt.Printf("错误: %s 不是一个目录 (文件夹)\n", targetPath)
return
}
config.Config.LocalWorkdir = strings.ReplaceAll(targetPath, "\\", "/")
fmt.Printf("改变本地工作目录: %s\n", config.Config.LocalWorkdir)
}
+216
View File
@@ -0,0 +1,216 @@
// Copyright (c) 2020 tickstep.
//
// 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.
package command_local
import (
"fmt"
"github.com/olekukonko/tablewriter"
"github.com/tickstep/aliyunpan-api/aliyunpan"
"github.com/tickstep/aliyunpan/cmder/cmdtable"
"github.com/tickstep/library-go/converter"
"github.com/urfave/cli"
"log"
"os"
"sort"
"strconv"
"strings"
)
func CmdLocalLs() cli.Command {
return cli.Command{
Name: "lls",
Category: "本地命令",
Usage: "列出本地目录",
Description: `
列出当前本地工作目录内的文件和目录, 或指定目录内的文件和目录
示例:
列出 我的资源 内的文件和目录
aliyunpan lls 我的资源
绝对路径
aliyunpan lls /我的资源
详细列出 我的资源 内的文件和目录
aliyunpan lls /我的资源
`,
Action: func(c *cli.Context) error {
var (
orderBy aliyunpan.FileOrderBy = aliyunpan.FileOrderByUpdatedAt
orderSort aliyunpan.FileOrderDirection = aliyunpan.FileOrderDirectionDesc
showAllFile bool = false
)
if c.IsSet("a") {
showAllFile = true
}
switch {
case c.IsSet("asc"):
orderSort = aliyunpan.FileOrderDirectionAsc
case c.IsSet("desc"):
orderSort = aliyunpan.FileOrderDirectionDesc
default:
orderSort = aliyunpan.FileOrderDirectionDesc
}
switch {
case c.IsSet("time"):
orderBy = aliyunpan.FileOrderByUpdatedAt
case c.IsSet("name"):
orderBy = aliyunpan.FileOrderByName
case c.IsSet("size"):
orderBy = aliyunpan.FileOrderBySize
default:
orderBy = aliyunpan.FileOrderByUpdatedAt
}
RunLocalLs(c.Args().Get(0), showAllFile, orderBy, orderSort)
return nil
},
Flags: []cli.Flag{
cli.BoolFlag{
Name: "a",
Usage: "显示所有文件",
},
cli.BoolFlag{
Name: "asc",
Usage: "升序排序",
},
cli.BoolFlag{
Name: "desc",
Usage: "降序排序",
},
cli.BoolFlag{
Name: "time",
Usage: "根据修改时间排序",
},
cli.BoolFlag{
Name: "name",
Usage: "根据文件名排序",
},
cli.BoolFlag{
Name: "size",
Usage: "根据大小排序",
},
},
}
}
func RunLocalLs(targetPath string,
showAllFile bool,
orderBy aliyunpan.FileOrderBy, orderDirection aliyunpan.FileOrderDirection) {
targetPath = LocalPathJoin(targetPath)
// 获取目标路径文件信息
localFileInfo, er := os.Stat(targetPath)
if er != nil {
fmt.Println("目录路径不存在")
return
}
if !localFileInfo.IsDir() {
fmt.Println("指定的路径不是目录")
return
}
fileEntryList, err := os.ReadDir(targetPath)
if err != nil {
log.Fatal(err)
}
fileInfoList := []os.FileInfo{}
folderInfoList := []os.FileInfo{}
for _, entry := range fileEntryList {
if fi, e := entry.Info(); e == nil {
if strings.HasPrefix(fi.Name(), ".") {
if !showAllFile {
continue
}
}
if fi.IsDir() {
folderInfoList = append(folderInfoList, fi)
} else {
fileInfoList = append(fileInfoList, fi)
}
}
}
// 过滤
// 排序
sortFileInfoList(folderInfoList, orderBy, orderDirection)
sortFileInfoList(fileInfoList, orderBy, orderDirection)
// 合并并渲染列表
renderFileList := append(folderInfoList, fileInfoList...)
renderTable(targetPath, renderFileList)
}
func sortFileInfoList(fileInfoList []os.FileInfo,
orderBy aliyunpan.FileOrderBy, orderDirection aliyunpan.FileOrderDirection) []os.FileInfo {
switch orderBy {
case aliyunpan.FileOrderByUpdatedAt: // 基于修改时间排序
sort.Slice(fileInfoList, func(i, j int) bool {
if orderDirection == aliyunpan.FileOrderDirectionAsc {
return fileInfoList[i].ModTime().UnixMilli() < fileInfoList[j].ModTime().UnixMilli()
} else {
return fileInfoList[i].ModTime().UnixMilli() > fileInfoList[j].ModTime().UnixMilli()
}
})
break
case aliyunpan.FileOrderByName: // 基于文件名排序
sort.Slice(fileInfoList, func(i, j int) bool {
if orderDirection == aliyunpan.FileOrderDirectionAsc {
return fileInfoList[i].Name() < fileInfoList[j].Name()
} else {
return fileInfoList[i].Name() > fileInfoList[j].Name()
}
})
break
case aliyunpan.FileOrderBySize: // 基于文件大小排序
sort.Slice(fileInfoList, func(i, j int) bool {
if orderDirection == aliyunpan.FileOrderDirectionAsc {
return fileInfoList[i].Size() < fileInfoList[j].Size()
} else {
return fileInfoList[i].Size() > fileInfoList[j].Size()
}
})
break
}
return fileInfoList
}
func renderTable(path string, files []os.FileInfo) {
tb := cmdtable.NewTable(os.Stdout)
var (
fileCount, dirCount, totalSize int64
)
tb.SetHeader([]string{"#", "文件大小", "修改日期", "文件(目录)"})
tb.SetColumnAlignment([]int{tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_RIGHT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT})
for k, file := range files {
if file.IsDir() {
dirCount += 1
tb.Append([]string{strconv.Itoa(k + 1), "-", file.ModTime().Format("2006-01-02 15:04:05"), file.Name() + aliyunpan.PathSeparator})
continue
}
fileCount += 1
totalSize += file.Size()
tb.Append([]string{strconv.Itoa(k + 1), converter.ConvertFileSize(file.Size(), 2), file.ModTime().Format("2006-01-02 15:04:05"), file.Name()})
}
tb.Append([]string{"", "总: " + converter.ConvertFileSize(totalSize, 2), "", fmt.Sprintf("文件总数: %d, 目录总数: %d", fileCount, dirCount)})
fmt.Printf("\n本地当前目录: %s\n", path)
fmt.Printf("----\n")
tb.Render()
fmt.Printf("----\n")
}
+62
View File
@@ -0,0 +1,62 @@
package command_local
import (
"github.com/tickstep/aliyunpan/internal/config"
"github.com/tickstep/aliyunpan/library/homedir"
"os"
"path"
"path/filepath"
"strings"
)
// GetLocalHomeDir 获取本地用户主目录
func GetLocalHomeDir() string {
// 默认为用户主页目录
if hd, e := os.UserHomeDir(); e == nil {
return hd
}
return ""
}
// LocalPathJoin 拼接本地路径
func LocalPathJoin(p string) string {
p = path.Clean(strings.ReplaceAll(p, "\\", "/"))
if filepath.IsAbs(p) {
return p
} else if strings.HasPrefix(p, "~") {
if d, e := homedir.Expand(p); e == nil {
return d
}
}
wd := config.Config.LocalWorkdir
if wd == "" {
wd = GetLocalHomeDir()
}
return path.Join(wd, p)
}
// LocalPathDir 获取当前文件夹的父文件夹路径
func LocalPathDir(p string) string {
p = strings.ReplaceAll(p, "\\", "/")
if strings.HasSuffix(p, ":/") { // windows卷标路径,例如:C:\ 、D:\、E:\
return p
}
parentDirPath := path.Dir(LocalPathClean(p))
return parentDirPath
}
// LocalPathBase 获取当前路径的文件(文件夹)名
func LocalPathBase(p string) string {
p = strings.ReplaceAll(p, "\\", "/")
if strings.HasSuffix(p, ":/") { // windows卷标路径,例如:C:\ 、D:\、E:\
return ""
}
baseName := path.Base(LocalPathClean(p))
return baseName
}
// LocalPathClean 规范化本地文件夹路径
func LocalPathClean(p string) string {
cleanPath := path.Clean(strings.ReplaceAll(p, "\\", "/"))
return cleanPath
}
+90 -29
View File
@@ -15,6 +15,7 @@ package config
import (
"fmt"
"github.com/adrg/xdg"
jsoniter "github.com/json-iterator/go"
"github.com/tickstep/aliyunpan-api/aliyunpan"
"github.com/tickstep/aliyunpan/cmder/cmdutil"
@@ -24,6 +25,7 @@ import (
"github.com/tickstep/library-go/logger"
"github.com/tickstep/library-go/requester"
"os"
"path"
"path/filepath"
"runtime"
"strings"
@@ -31,6 +33,8 @@ import (
)
const (
// EnvDownloadDir 配置下载环境变量
EnvDownloadDir = "ALIYUNPAN_DOWNLOAD_DIR"
// EnvVerbose 启用调试环境变量
EnvVerbose = "ALIYUNPAN_VERBOSE"
// EnvConfigDir 配置路径环境变量
@@ -50,7 +54,7 @@ const (
DefaultFileDownloadParallelNum = 5
// MaxFileDownloadParallelNum 最大文件下载并发数量。过大会被阿里云盘风控,导致无法下载
MaxFileDownloadParallelNum = 20
MaxFileDownloadParallelNum = 3
// DefaultTokenServiceWebHost 默认的token服务
DefaultTokenServiceWebHost = "https://api.tickstep.com"
@@ -111,6 +115,9 @@ type PanConfig struct {
ClientId string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
// 本地工作目录(lcd/lpwd/lls命令使用)
LocalWorkdir string `json:"localWorkdir"`
configFilePath string
configFile *os.File
fileMu sync.Mutex
@@ -198,10 +205,6 @@ func (c *PanConfig) init() error {
if c.Proxy != "" {
requester.SetGlobalProxy(c.Proxy)
}
// 设置本地网卡地址
if c.LocalAddrs != "" {
requester.SetLocalTCPAddrList(strings.Split(c.LocalAddrs, ",")...)
}
// 设置域名解析策略 IPv4 or IPv6
t := requester.IPAny
@@ -212,6 +215,15 @@ func (c *PanConfig) init() error {
}
requester.SetPreferIPType(t)
// 设置本地网卡地址
if c.LocalAddrs != "" {
ips := ParseLocalAddress(c.LocalAddrs, strings.ToLower(c.PreferIPType))
if len(ips) > 0 {
logger.Verboseln("bind local address list: ", ips)
requester.SetLocalTCPAddrList(ips...)
}
}
return nil
}
@@ -278,22 +290,7 @@ func (c *PanConfig) loadConfigFromFile() (err error) {
}
func (c *PanConfig) initDefaultConfig() {
// 设置默认的下载路径
switch runtime.GOOS {
case "windows":
c.SaveDir = cmdutil.ExecutablePathJoin("Downloads")
case "android":
// TODO: 获取完整的的下载路径
c.SaveDir = "/sdcard/Download"
default:
dataPath, ok := os.LookupEnv("HOME")
if !ok {
CmdConfigVerbose.Warn("Environment HOME not set")
c.SaveDir = cmdutil.ExecutablePathJoin("Downloads")
} else {
c.SaveDir = filepath.Join(dataPath, "Downloads")
}
}
//c.SaveDir = GetDefaultDownloadDir()
c.ConfigVer = ConfigVersion
c.VideoFileExtensions = DefaultVideoFileExtensions
c.DeviceId = RandomDeviceId() // 生成默认客户端ID
@@ -306,7 +303,7 @@ func (c *PanConfig) initDefaultConfig() {
// GetConfigDir 获取配置路径
func GetConfigDir() string {
// 按照以下顺序依次获取配置目录
// 1.环境变量ALIYUNPAN_CONFIG_DIR => 2. /etc/aliyunpan/ => 3. ~/.aliyunpan/ => 4.当前程序目录
// 1.环境变量ALIYUNPAN_CONFIG_DIR => 2. XDG_CONFIG_HOME/aliyunpan => 3. /etc/aliyunpan/ => 4. ~/.aliyunpan/ => 5.当前程序目录
// 1. 从环境变量读取
configDir, ok := os.LookupEnv(EnvConfigDir)
@@ -320,7 +317,14 @@ func GetConfigDir() string {
logger.Verboseln("use config dir from ALIYUNPAN_CONFIG_DIR env: ", configDir)
return configDir
} else {
// 2. /etc/aliyunpan/
// 2. $XDG_CONFIG_HOME/aliyunpan
xdgConfigHome := path.Join(xdg.ConfigHome, "aliyunpan")
if IsFolderExist(xdgConfigHome) {
logger.Verboseln("use XDG home config dir: ", xdgConfigHome)
return xdgConfigHome
}
// 3. /etc/aliyunpan/
if runtime.GOOS == "linux" {
cd := "/etc/aliyunpan"
if IsFolderExist(cd) {
@@ -329,7 +333,7 @@ func GetConfigDir() string {
}
}
// 3. ~/.aliyunpan/
// 4. ~/.aliyunpan/
if runtime.GOOS == "linux" || runtime.GOOS == "windows" {
cd, er := homedir.Expand("~/.aliyunpan")
if er == nil {
@@ -341,12 +345,55 @@ func GetConfigDir() string {
}
}
// 4.当前程序所在目录
// 5.当前程序所在目录
configDir = cmdutil.ExecutablePathJoin("")
logger.Verboseln("use config dir: ", configDir)
return configDir
}
// GetDefaultDownloadDir 获取默认的下载目录
func GetDefaultDownloadDir() string {
// 按照以下顺序依次获取下载目录
// 1.环境变量ALIYUNPAN_DOWNLOAD_DIR => 2. XDG_DOWNLOAD_DIR目录 => 3. (当前程序目录)/Downloads
downloadDir := ""
// 设置默认的下载路径
switch runtime.GOOS {
case "android":
// TODO: 获取完整的的下载路径
downloadDir = "/sdcard/Download"
return downloadDir
default:
// 1. 环境变量ALIYUNPAN_DOWNLOAD_DIR
d, ok := os.LookupEnv(EnvDownloadDir)
if ok {
logger.Verboseln("use download dir from ALIYUNPAN_DOWNLOAD_DIR env: ", d)
downloadDir = d
return downloadDir
} else {
// 2. $XDG_DOWNLOAD_DIR
xdgDownloadDir := path.Join(xdg.UserDirs.Download)
if IsFolderExist(xdgDownloadDir) {
logger.Verboseln("use XDG download dir: ", xdgDownloadDir)
return xdgDownloadDir
}
}
}
// 3. (当前程序目录)/Downloads
downloadDir = cmdutil.ExecutablePathJoin("Downloads")
logger.Verboseln("use default download dir: ", downloadDir)
return downloadDir
}
// GetDownloadDir 获取当前下载目录
func GetDownloadDir() string {
if Config.SaveDir == "" {
return GetDefaultDownloadDir()
}
return Config.SaveDir
}
// GetPluginDir 获取插件文件夹路径
func GetPluginDir() string {
return strings.TrimSuffix(GetConfigDir(), "/") + "/plugin"
@@ -406,6 +453,9 @@ func (c *PanConfig) ActiveUser() *PanUser {
u.panClient = user.panClient
u.Nickname = user.Nickname
if u.ActiveDriveId == "" {
u.ActiveDriveId = user.DriveList.GetFileDriveId()
}
u.DriveList = user.DriveList
// check workdir valid or not
if user.IsFileDriveActive() {
@@ -416,6 +466,9 @@ func (c *PanConfig) ActiveUser() *PanUser {
u.WorkdirFileEntity = *aliyunpan.NewFileEntityForRootDir()
} else {
u.WorkdirFileEntity = *fe
if u.Workdir == "" {
u.Workdir = "/"
}
}
} else if user.IsResourceDriveActive() {
fe, err1 := u.PanClient().OpenapiPanClient().FileInfoByPath(u.ActiveDriveId, u.ResourceWorkdir)
@@ -425,6 +478,9 @@ func (c *PanConfig) ActiveUser() *PanUser {
u.ResourceWorkdirFileEntity = *aliyunpan.NewFileEntityForRootDir()
} else {
u.ResourceWorkdirFileEntity = *fe
if u.ResourceWorkdir == "" {
u.ResourceWorkdir = "/"
}
}
} else if user.IsAlbumDriveActive() {
fe, err1 := u.PanClient().WebapiPanClient().FileInfoByPath(u.ActiveDriveId, u.AlbumWorkdir)
@@ -434,6 +490,9 @@ func (c *PanConfig) ActiveUser() *PanUser {
u.AlbumWorkdirFileEntity = *aliyunpan.NewFileEntityForRootDir()
} else {
u.AlbumWorkdirFileEntity = *fe
if u.AlbumWorkdir == "" {
u.AlbumWorkdir = "/"
}
}
}
}
@@ -455,7 +514,9 @@ func (c *PanConfig) SetActiveUser(user *PanUser) *PanUser {
u.WebapiToken = user.WebapiToken
u.OpenapiToken = user.OpenapiToken
u.TicketId = user.TicketId
u.UpdateClient(user.PanClient().OpenapiPanClient(), user.PanClient().WebapiPanClient())
if u.PanClient() != nil && user.PanClient() != nil {
u.UpdateClient(user.PanClient().OpenapiPanClient(), user.PanClient().WebapiPanClient())
}
needToInsert = false
break
}
@@ -483,9 +544,9 @@ func (c *PanConfig) NumLogins() int {
}
// SwitchUser 切换登录用户
func (c *PanConfig) SwitchUser(uid, username string) (*PanUser, error) {
func (c *PanConfig) SwitchUser(uid string) (*PanUser, error) {
for _, u := range c.UserList {
if u.UserId == uid || u.AccountName == username {
if u.UserId == uid {
return c.SetActiveUser(u), nil
}
}
@@ -501,7 +562,7 @@ func (c *PanConfig) DeleteUser(uid string) (*PanUser, error) {
c.ActiveUID = ""
c.activeUser = nil
if len(c.UserList) > 0 {
c.SwitchUser(c.UserList[0].UserId, "")
c.SwitchUser(c.UserList[0].UserId)
}
return u, nil
}
+7 -4
View File
@@ -33,7 +33,10 @@ func (c *PanConfig) SetProxy(proxy string) {
// SetLocalAddrs 设置localAddrs
func (c *PanConfig) SetLocalAddrs(localAddrs string) {
c.LocalAddrs = localAddrs
requester.SetLocalTCPAddrList(strings.Split(localAddrs, ",")...)
ips := ParseLocalAddress(localAddrs, strings.ToLower(c.PreferIPType))
if len(ips) > 0 {
requester.SetLocalTCPAddrList(ips...)
}
}
func (c *PanConfig) SetPreferIPType(ipType string) {
@@ -106,13 +109,13 @@ func (c *PanConfig) PrintTable() {
tb.SetColumnAlignment([]int{tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT})
tb.AppendBulk([][]string{
[]string{"cache_size", converter.ConvertFileSize(int64(c.CacheSize), 2), "1KB ~ 256KB", "下载缓存, 如果硬盘占用高或下载速度慢, 请尝试调大此值"},
[]string{"max_download_parallel", strconv.Itoa(c.MaxDownloadParallel), "1 ~ 20", "最大下载并发量,即同时下载文件最大数量"},
[]string{"max_download_parallel", strconv.Itoa(c.MaxDownloadParallel), "1 ~ 3", "最大下载并发量,即同时下载文件最大数量"},
[]string{"max_upload_parallel", strconv.Itoa(c.MaxUploadParallel), "1 ~ 20", "最大上传并发量,即同时上传文件最大数量"},
[]string{"max_download_rate", showMaxRate(c.MaxDownloadRate), "", "限制单个文件最大下载速度, 0代表不限制"},
[]string{"max_upload_rate", showMaxRate(c.MaxUploadRate), "", "限制单个文件最大上传速度, 0代表不限制"},
[]string{"savedir", c.SaveDir, "", "下载文件的储存目录"},
[]string{"savedir", GetDownloadDir(), "", "下载文件的储存目录"},
[]string{"proxy", c.Proxy, "", "设置代理, 支持 http/socks5 代理,例如: http://127.0.0.1:8888 或者 socks5://127.0.0.1:8889"},
[]string{"local_addrs", c.LocalAddrs, "", "设置本地网卡地址, 多个地址用逗号隔开,例如: 127.0.0.1,192.168.100.126"},
[]string{"local_addrs", c.LocalAddrs, "", "绑定本地网卡地址, 多个地址用逗号隔开,支持网口名称,例如: 127.0.0.1,192.168.100.126,en0,eth0"},
[]string{"ip_type", c.PreferIPType, "ipv4-优先IPv4,ipv6-优先IPv6", "设置域名解析IP优先类型。修改后需要重启应用生效"},
[]string{"file_record_config", fileRecorderLabel, "1-开启,2-禁用", "设置是否开启上传、下载、同步文件的结果记录,开启后会把结果记录到CSV文件方便后期查看"},
[]string{"device_id", c.DeviceId, "", "客户端ID,用于标识登录客户端,阿里单个账号最多允许10个客户端同时在线。修改后需要重启应用生效"},
+39 -9
View File
@@ -62,6 +62,24 @@ func (d DriveInfoList) GetResourceDriveId() string {
return ""
}
func (d DriveInfoList) GetDriveIdByName(name string) string {
for _, drive := range d {
if drive.DriveTag == name {
return drive.DriveId
}
}
return ""
}
func (d DriveInfoList) GetDriveNameById(driveId string) string {
for _, drive := range d {
if drive.DriveId == driveId {
return drive.DriveName
}
}
return ""
}
// PanClientToken 授权Token
type PanClientToken struct {
// AccessToken AccessToken
@@ -119,9 +137,9 @@ func SetupUserByCookie(openapiToken, webapiToken *PanClientToken, ticketId, user
if openapiToken == nil {
return nil, apierror.NewFailedApiError("openapi token is empty")
}
if webapiToken == nil {
return nil, apierror.NewFailedApiError("webapi token is empty")
}
//if webapiToken == nil {
// return nil, apierror.NewFailedApiError("webapi token is empty")
//}
doOpenLoginAct:
// setup openapi client
@@ -134,16 +152,23 @@ doOpenLoginAct:
AccessToken: openapiToken.AccessToken,
ExpiredAt: openapiToken.Expired,
}, nil)
openPanClient.SetAccessTokenRefreshCallback(func(userId string, newToken openapi.ApiToken) error {
// api层自动刷新Token,回调通知程序
logger.Verboseln("openapi token refresh, use new token")
openapiToken.AccessToken = newToken.AccessToken
openapiToken.Expired = newToken.ExpiredAt
return nil
})
// open api token maybe expired
// check & refresh new one
openUserInfo, err := openPanClient.GetUserInfo()
if err != nil {
if err.Code == apierror.ApiCodeTokenExpiredCode && tryRefreshOpenToken {
if err.Code == apierror.ApiCodeTokenExpiredCode && tryRefreshOpenToken { // 如果是token过期,则尝试刷新
tryRefreshOpenToken = false
wt, e := loginHelper.GetOpenapiNewToken(ticketId, userId, openapiToken.AccessToken)
if e != nil {
logger.Verboseln("get openapi token from server error: ", e)
wt, re := loginHelper.GetOpenapiNewToken(ticketId, userId, openapiToken.AccessToken)
if re != nil {
logger.Verboseln("get openapi token from server error: ", re.Msg)
return nil, apierror.NewFailedApiError("get new openapi token error, try login again")
}
if wt != nil {
@@ -151,7 +176,11 @@ doOpenLoginAct:
AccessToken: wt.AccessToken,
Expired: wt.Expired,
}
// TODO: 检查token有效期
}
// 回到token检测流程,必须确保token是有效
time.Sleep(time.Duration(1) * time.Second)
goto doOpenLoginAct
}
@@ -189,7 +218,7 @@ doWebLoginAct:
tryRefreshWebToken = false
wt, e := loginHelper.GetWebapiNewToken(ticketId, userId, webapiToken.AccessToken)
if e != nil {
logger.Verboseln("get web token from server error: ", e)
logger.Verboseln("get web token from server error: ", e.Msg)
}
if wt != nil {
webapiToken = &PanClientToken{
@@ -228,6 +257,7 @@ doWebLoginAct:
WorkdirFileEntity: *aliyunpan.NewFileEntityForRootDir(),
}
u.PanClient().OpenapiPanClient().SetAccessTokenRefreshCallback(func(userId string, newToken openapi.ApiToken) error {
// api层自动刷新Token,回调通知程序
logger.Verboseln("openapi token refresh, update for user")
u.OpenapiToken = &PanClientToken{
AccessToken: newToken.AccessToken,
@@ -332,7 +362,7 @@ func (pu *PanUser) FreshWorkdirInfo() {
// GetSavePath 根据提供的网盘文件路径 panpath, 返回本地储存路径,
// 返回绝对路径, 获取绝对路径出错时才返回相对路径...
func (pu *PanUser) GetSavePath(filePanPath string) string {
dirStr := filepath.Join(Config.SaveDir, fmt.Sprintf("%s", pu.UserId), filePanPath)
dirStr := filepath.Join(GetDownloadDir(), fmt.Sprintf("%s", pu.UserId), filePanPath)
dir, err := filepath.Abs(dirStr)
if err != nil {
dir = filepath.Clean(dirStr)
+51
View File
@@ -17,11 +17,13 @@ import (
"encoding/hex"
"github.com/olekukonko/tablewriter"
"github.com/tickstep/aliyunpan/cmder/cmdtable"
"github.com/tickstep/aliyunpan/library/nets"
"github.com/tickstep/library-go/converter"
"github.com/tickstep/library-go/crypto"
"github.com/tickstep/library-go/ids"
"github.com/tickstep/library-go/logger"
"math/rand"
"net"
"os"
"strconv"
"strings"
@@ -44,6 +46,16 @@ func (pl *PanUserList) String() string {
return builder.String()
}
// GetUserByUserId 根据用户ID获取用户信息
func (pl *PanUserList) GetUserByUserId(userId string) *PanUser {
for _, u := range *pl {
if u.UserId == userId {
return u
}
}
return nil
}
// AverageParallel 返回平均的下载最大并发量
func AverageParallel(parallel, downloadLoad int) int {
if downloadLoad < 1 {
@@ -135,3 +147,42 @@ func RandomDeviceId() string {
}
return str.String()
}
// ParseLocalAddress 解析网络接口配置为对应的本地IP地址
func ParseLocalAddress(localAddrs string, filterIpType string) []string {
allLocalAddress, _ := nets.GetLocalNetInterfaceAddress()
localAddrNames := strings.Split(localAddrs, ",")
ips := []string{}
for _, addr := range localAddrNames {
if addr == "" {
continue
}
if net.ParseIP(addr) == nil {
// maybe local interface name
if localAddr := allLocalAddress.GetByName(addr); localAddr != nil {
if localAddr.IPv4 != "" {
if filterIpType == "any" || filterIpType == "" { // 不限制
ips = append(ips, localAddr.IPv4)
} else {
if filterIpType == "ipv4" {
ips = append(ips, localAddr.IPv4)
}
}
}
if localAddr.IPv6 != "" {
if filterIpType == "any" || filterIpType == "" { // 不限制
ips = append(ips, localAddr.IPv6)
} else {
if filterIpType == "ipv6" {
ips = append(ips, localAddr.IPv6)
}
}
}
}
} else {
// ip
ips = append(ips, addr)
}
}
return ips
}
+3 -2
View File
@@ -27,7 +27,7 @@ var (
MinParallelSize int64 = 10 * 1024 * 1024 // 10MB
// MaxParallelWorkerCount 单个文件下载最大并发线程数量
// 阿里云盘规定:并发下载线程数不要超过10,否则会有风控检测处罚的风险
// 阿里云盘规定:并发下载线程数不要超过3,否则会有风控检测处罚的风险
MaxParallelWorkerCount int = 3
)
@@ -35,6 +35,7 @@ var (
type Config struct {
Mode transfer.RangeGenMode // 下载Range分配模式
MaxParallel int // 最大下载并发量
SliceParallel int // 单文件下载线程数,为0代表程序自动调度
CacheSize int // 下载缓冲
BlockSize int64 // 每个Range区块的大小, RangeGenMode 为 RangeGenMode2 时才有效
MaxRate int64 // 限制最大下载速度
@@ -48,7 +49,7 @@ type Config struct {
// NewConfig 返回默认配置
func NewConfig() *Config {
return &Config{
MaxParallel: 5,
MaxParallel: 3,
CacheSize: CacheSize,
}
}
+196 -35
View File
@@ -20,6 +20,7 @@ import (
"github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
"github.com/tickstep/aliyunpan/cmder/cmdutil"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/tickstep/aliyunpan/internal/global"
"github.com/tickstep/aliyunpan/internal/waitgroup"
"github.com/tickstep/aliyunpan/library/requester/transfer"
"github.com/tickstep/library-go/cachepool"
@@ -54,6 +55,7 @@ type (
monitorCancelFunc context.CancelFunc
globalSpeedsStat *speeds.Speeds // 全局速度统计
filePanSource global.FileSourceType // 要下载的网盘文件来源
fileInfo *aliyunpan.FileEntity // 下载的文件信息
driveId string
loadBalancerCompareFunc LoadBalancerCompareFunc // 负载均衡检测函数
@@ -64,6 +66,7 @@ type (
writer io.WriterAt
client *requester.HTTPClient
panClient *config.PanClient
subPanClientList []*config.PanClient // 辅助下载子账号列表
config *Config
monitor *Monitor
instanceState *InstanceState
@@ -73,24 +76,34 @@ type (
DURLCheckFunc func(client *requester.HTTPClient, durl string) (contentLength int64, resp *http.Response, err error)
// StatusCodeBodyCheckFunc 响应状态码出错的检查函数
StatusCodeBodyCheckFunc func(respBody io.Reader) error
// panClientDownloadUrlEntity 下载url实体,和网盘(用户)客户端绑定
panClientDownloadUrlEntity struct {
PanClient *config.PanClient
FileInfo *aliyunpan.FileEntity
DriveId string
FileId string
FileUrl string
}
)
// NewDownloader 初始化Downloader
func NewDownloader(writer io.WriterAt, config *Config, p *config.PanClient, globalSpeedsStat *speeds.Speeds) (der *Downloader) {
func NewDownloader(writer io.WriterAt, config *Config, p *config.PanClient, sp []*config.PanClient, globalSpeedsStat *speeds.Speeds) (der *Downloader) {
der = &Downloader{
config: config,
writer: writer,
panClient: p,
subPanClientList: sp,
globalSpeedsStat: globalSpeedsStat,
}
return
}
// SetClient 设置http客户端
func (der *Downloader) SetFileInfo(f *aliyunpan.FileEntity) {
// SetFileInfo 设置文件信息
func (der *Downloader) SetFileInfo(source global.FileSourceType, f *aliyunpan.FileEntity) {
der.filePanSource = source
der.fileInfo = f
}
func (der *Downloader) SetDriveId(driveId string) {
der.driveId = driveId
}
@@ -130,10 +143,10 @@ func (der *Downloader) lazyInit() {
}
// SelectParallel 获取合适的 parallel
func (der *Downloader) SelectParallel(single bool, maxParallel int, totalSize int64, instanceRangeList transfer.RangeList) (parallel int) {
isRange := instanceRangeList != nil && len(instanceRangeList) > 0
if single { // 单线程下载
parallel = 1
func (der *Downloader) SelectParallel(prefParallel int, maxParallel int, totalSize int64, instanceRangeList transfer.RangeList) (parallel int) {
isRange := instanceRangeList != nil && len(instanceRangeList) > 0 // 历史下载分片记录存在
if prefParallel > 0 { // 单线程下载
parallel = prefParallel
} else if isRange {
parallel = len(instanceRangeList)
} else {
@@ -151,9 +164,9 @@ func (der *Downloader) SelectParallel(single bool, maxParallel int, totalSize in
}
// SelectBlockSizeAndInitRangeGen 获取合适的 BlockSize, 和初始化 RangeGen
func (der *Downloader) SelectBlockSizeAndInitRangeGen(single bool, status *transfer.DownloadStatus, parallel int) (blockSize int64, initErr error) {
func (der *Downloader) SelectBlockSizeAndInitRangeGen(status *transfer.DownloadStatus, parallel int) (blockSize int64, initErr error) {
// Range 生成器
if single { // 单线程
if parallel == 1 { // 单线程
blockSize = -1
return
}
@@ -309,7 +322,6 @@ func (der *Downloader) Execute() error {
var (
isInstance = bii != nil // 是否存在断点信息
status *transfer.DownloadStatus
single = false // 默认开启多线程下载,所以当前single值都为false代表不是单线程下载
)
if !isInstance {
bii = &transfer.DownloadInstanceInfo{}
@@ -331,9 +343,9 @@ func (der *Downloader) Execute() error {
defer rl.Stop()
}
// 计算文件下载的并发线程数,计单个文件下载的并发数
parallel := der.SelectParallel(single, MaxParallelWorkerCount, status.TotalSize(), bii.Ranges) // 实际的下载并行量
blockSize, err := der.SelectBlockSizeAndInitRangeGen(single, status, parallel) // 实际的BlockSize
// 计算本文件下载的并发线程数(分片数)
parallel := der.SelectParallel(der.config.SliceParallel, MaxParallelWorkerCount, status.TotalSize(), bii.Ranges) // 实际的下载并行量
blockSize, err := der.SelectBlockSizeAndInitRangeGen(status, parallel) // 实际的BlockSize
if err != nil {
return err
}
@@ -361,7 +373,7 @@ func (der *Downloader) Execute() error {
// 没有使用断点续传
// 分配线程
bii.Ranges = make(transfer.RangeList, 0, parallel)
if single { // 单线程
if parallel == 1 { // 单线程
bii.Ranges = append(bii.Ranges, &transfer.Range{Begin: 0, End: der.fileInfo.FileSize})
} else {
gen := status.RangeListGen()
@@ -379,42 +391,32 @@ func (der *Downloader) Execute() error {
writeMu = &sync.Mutex{}
)
// 获取下载链接
var apierr *apierror.ApiError
durl, apierr := der.panClient.OpenapiPanClient().GetFileDownloadUrl(&aliyunpan.GetFileDownloadUrlParam{
DriveId: der.driveId,
FileId: der.fileInfo.FileId,
})
time.Sleep(time.Duration(200) * time.Millisecond)
if apierr != nil {
// 获取各个账号网盘客户端的下载链接
panClientFileUrl, err := der.getFileAllClientDownloadUrl()
if err != nil || panClientFileUrl == nil {
logger.Verbosef("ERROR: get download url error: %s\n", der.fileInfo.FileId)
cmdutil.Trigger(der.onCancelEvent)
return apierr
}
if durl == nil || durl.Url == "" || strings.HasPrefix(durl.Url, aliyunpan.IllegalDownloadUrlPrefix) {
logger.Verbosef("无法获取有效的下载链接: %+v\n", durl)
cmdutil.Trigger(der.onCancelEvent)
der.removeInstanceState() // 移除断点续传文件
cmdutil.Trigger(der.onFailedEvent)
return ErrFileDownloadForbidden
return err
}
// 初始化下载worker
factorNum := len(bii.Ranges) / len(panClientFileUrl)
for k, r := range bii.Ranges {
panClientUrl := panClientFileUrl[int(k/factorNum)]
loadBalancer := loadBalancerResponseList.SequentialGet()
if loadBalancer == nil {
continue
}
logger.Verbosef("work id: %d, download url: %v\n", k, durl)
logger.Verbosef("work id: %d, download url: %v\n", k, panClientUrl.FileUrl)
client := requester.NewHTTPClient()
client.SetKeepAlive(true)
client.SetTimeout(10 * time.Minute)
realUrl := durl.Url
worker := NewWorker(k, der.driveId, der.fileInfo.FileId, realUrl, writer, der.globalSpeedsStat)
realUrl := panClientUrl.FileUrl
worker := NewWorker(k, panClientUrl.DriveId, panClientUrl.FileInfo.FileId, realUrl, writer, der.globalSpeedsStat)
worker.SetClient(client)
worker.SetPanClient(der.panClient)
worker.SetPanClient(panClientUrl.PanClient)
worker.SetWriteMutex(writeMu)
worker.SetTotalSize(der.fileInfo.FileSize)
@@ -456,6 +458,165 @@ func (der *Downloader) Execute() error {
return err
}
// 获取对应网盘文件的下载链接
func (der *Downloader) getFileAllClientDownloadUrl() ([]*panClientDownloadUrlEntity, error) {
if der.filePanSource == global.AlbumSource {
// 相册源只支持主账号下载
return der.getAlbumSourceDownloadUrl()
} else {
// 文件源支持多账号分流下载
return der.getFileSourceDownloadUrl()
}
}
// getFileSourceDownloadUrl 获取文件源的文件下载链接
func (der *Downloader) getFileSourceDownloadUrl() ([]*panClientDownloadUrlEntity, error) {
result := []*panClientDownloadUrlEntity{}
// 主账号(必须存在)
// 获取下载链接
var apierr *apierror.ApiError
durl, apierr := der.panClient.OpenapiPanClient().GetFileDownloadUrl(&aliyunpan.GetFileDownloadUrlParam{
DriveId: der.driveId,
FileId: der.fileInfo.FileId,
})
time.Sleep(time.Duration(200) * time.Millisecond)
if apierr != nil {
logger.Verbosef("ERROR: get download url error: %s\n", der.fileInfo.FileId)
cmdutil.Trigger(der.onCancelEvent)
return nil, apierr
}
if durl == nil || durl.Url == "" || strings.HasPrefix(durl.Url, aliyunpan.IllegalDownloadUrlPrefix) {
logger.Verbosef("无法获取有效的下载链接: %+v\n", durl)
cmdutil.Trigger(der.onCancelEvent)
der.removeInstanceState() // 移除断点续传文件
cmdutil.Trigger(der.onFailedEvent)
return nil, ErrFileDownloadForbidden
}
result = append(result, &panClientDownloadUrlEntity{
PanClient: der.panClient,
FileInfo: der.fileInfo,
DriveId: der.driveId,
FileId: der.fileInfo.FileId,
FileUrl: durl.Url,
})
// 网盘名称
mainDriveName := ""
openUserInfo, err := der.panClient.OpenapiPanClient().GetUserInfo()
if err != nil || openUserInfo == nil {
return nil, err
}
if openUserInfo.FileDriveId == der.driveId {
mainDriveName = "File"
} else if openUserInfo.ResourceDriveId == der.driveId {
mainDriveName = "Resource"
}
// 网盘文件路径
mainFileFullPath := der.fileInfo.Path
// 铺助账号的下载链接,只支持文件源
if der.subPanClientList != nil {
for _, spc := range der.subPanClientList {
driveId := ""
userInfo, err1 := spc.OpenapiPanClient().GetUserInfo()
if err1 != nil {
continue
}
if mainDriveName == "File" {
driveId = userInfo.FileDriveId
} else if mainDriveName == "Resource" {
driveId = userInfo.ResourceDriveId
}
// 文件信息
panfileInfo, err2 := spc.OpenapiPanClient().FileInfoByPath(driveId, mainFileFullPath)
if err2 != nil || panfileInfo == nil {
continue
}
// 下载链接
durl2, apierr := spc.OpenapiPanClient().GetFileDownloadUrl(&aliyunpan.GetFileDownloadUrlParam{
DriveId: driveId,
FileId: panfileInfo.FileId,
})
time.Sleep(time.Duration(200) * time.Millisecond)
if apierr != nil {
logger.Verbosef("ERROR: get download url error: %s\n", der.fileInfo.FileId)
continue
}
if durl2 == nil || durl2.Url == "" || strings.HasPrefix(durl2.Url, aliyunpan.IllegalDownloadUrlPrefix) {
logger.Verbosef("无法获取有效的下载链接: %+v\n", durl2)
continue
}
result = append(result, &panClientDownloadUrlEntity{
PanClient: spc,
FileInfo: panfileInfo,
DriveId: driveId,
FileId: panfileInfo.FileId,
FileUrl: durl2.Url,
})
}
}
return result, nil
}
// getAlbumSourceDownloadUrl 获取相册源的文件下载链接
func (der *Downloader) getAlbumSourceDownloadUrl() ([]*panClientDownloadUrlEntity, error) {
result := []*panClientDownloadUrlEntity{}
// 相册源只能使用主账号
// 获取下载链接
var apierr *apierror.ApiError
durl, apierr := der.panClient.OpenapiPanClient().ShareAlbumGetFileDownloadUrl(&aliyunpan.ShareAlbumGetFileUrlParam{
AlbumId: der.fileInfo.AlbumId,
DriveId: der.fileInfo.DriveId,
FileId: der.fileInfo.FileId,
})
time.Sleep(time.Duration(200) * time.Millisecond)
if apierr != nil {
logger.Verbosef("ERROR: get album file download url error: %s\n", der.fileInfo.FileId)
cmdutil.Trigger(der.onCancelEvent)
return nil, apierr
}
realUrl := ""
if durl.StreamsUrl != nil { // 实况图片
if strings.ToLower(der.fileInfo.FileExtension) == "mov" {
// 视频文件
if durl.StreamsUrl.Mov != "" {
realUrl = durl.StreamsUrl.Mov
}
} else {
// 照片文件
if durl.StreamsUrl.Heic != "" {
realUrl = durl.StreamsUrl.Heic
} else if durl.StreamsUrl.Jpeg != "" {
realUrl = durl.StreamsUrl.Jpeg
}
}
} else if durl.Url != "" { // 普通图片
realUrl = durl.Url
}
if realUrl == "" {
logger.Verbosef("无法获取有效的相册文件下载链接: %+v\n", durl)
cmdutil.Trigger(der.onCancelEvent)
der.removeInstanceState() // 移除断点续传文件
cmdutil.Trigger(der.onFailedEvent)
return nil, ErrFileDownloadForbidden
}
// 返回下载链接
result = append(result, &panClientDownloadUrlEntity{
PanClient: der.panClient,
FileInfo: der.fileInfo,
DriveId: der.driveId,
FileId: der.fileInfo.FileId,
FileUrl: realUrl,
})
return result, nil
}
// downloadStatusEvent 执行状态处理事件
func (der *Downloader) downloadStatusEvent() {
if der.onDownloadStatusEvent == nil {
+4 -1
View File
@@ -346,8 +346,11 @@ func (mt *Monitor) ResetWorker(worker *Worker) {
// 忽略, 返回
return
case StatusCodeDownloadUrlExpired: // 下载链接已经过期
logger.Verbosef("download url expired, reset worker: %d\n", worker.ID())
worker.RefreshDownloadUrl()
break
case StatusCodeDownloadUrlExceedMaxConcurrency: // 下载遇到限流报错
break
}
mt.resetController.AddResetNum()
@@ -406,7 +409,7 @@ func (mt *Monitor) Execute(cancelCtx context.Context) {
// 是否有失败的worker
for _, w := range mt.workers {
if w.status.statusCode == StatusCodeDownloadUrlExpired {
if w.status.statusCode == StatusCodeDownloadUrlExpired || w.status.statusCode == StatusCodeDownloadUrlExceedMaxConcurrency {
mt.ResetWorker(w)
}
}
+12 -6
View File
@@ -4,7 +4,7 @@
// 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
// 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,
@@ -63,11 +63,13 @@ const (
StatusCodeCanceled
//StatusCodeDownloadUrlExpired 下载链接已过期
StatusCodeDownloadUrlExpired
// StatusCodeDownloadUrlExceedMaxConcurrency 限流报错,下载链接超过最大并发数
StatusCodeDownloadUrlExceedMaxConcurrency
//StatusCodeIllegalDownloadFile 文件非法,不允许下载
StatusCodeIllegalDownloadFile
)
//GetStatusText 根据状态码获取状态信息
// GetStatusText 根据状态码获取状态信息
func GetStatusText(sc StatusCode) string {
switch sc {
case StatusCodeInit:
@@ -94,29 +96,33 @@ func GetStatusText(sc StatusCode) string {
return "已重设连接"
case StatusCodeCanceled:
return "已取消"
case StatusCodeDownloadUrlExpired:
return "链接已过期"
case StatusCodeDownloadUrlExceedMaxConcurrency:
return "遇到限流报错"
default:
return "未知状态码"
}
}
//NewWorkerStatus 初始化WorkerStatus
// NewWorkerStatus 初始化WorkerStatus
func NewWorkerStatus() *WorkerStatus {
return &WorkerStatus{
statusCode: StatusCodeInit,
}
}
//SetStatusCode 设置worker状态码
// SetStatusCode 设置worker状态码
func (ws *WorkerStatus) SetStatusCode(sc StatusCode) {
ws.statusCode = sc
}
//StatusCode 返回状态码
// StatusCode 返回状态码
func (ws *WorkerStatus) StatusCode() StatusCode {
return ws.statusCode
}
//StatusText 返回状态信息
// StatusText 返回状态信息
func (ws *WorkerStatus) StatusText() string {
return GetStatusText(ws.statusCode)
}
+7 -5
View File
@@ -15,8 +15,6 @@ package downloader
import (
"errors"
"github.com/tickstep/library-go/logger"
"github.com/tickstep/library-go/requester"
mathrand "math/rand"
"mime"
"net/url"
@@ -24,6 +22,10 @@ import (
"regexp"
"strconv"
"time"
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/library-go/logger"
"github.com/tickstep/library-go/requester"
)
var (
@@ -102,15 +104,15 @@ func fixCacheSize(size *int) {
// IsUrlExpired 下载链接是否已过期。过期返回True
func IsUrlExpired(urlStr string) bool {
u, err := url.Parse(urlStr)
expiredTimeSec, err := utils.GetExpiredTimeSecFromOSSUrl(urlStr)
if err != nil {
// 解析错误,默认过期
return true
}
expiredTimeSecStr := u.Query().Get("x-oss-expires")
expiredTimeSec, _ := strconv.ParseInt(expiredTimeSecStr, 10, 64)
if (expiredTimeSec - time.Now().Unix()) <= 5 { // 小于5秒钟
// expired
return true
}
// 返回未过期
return false
}
+24 -1
View File
@@ -30,6 +30,7 @@ import (
"net/http"
"strings"
"sync"
"time"
)
type (
@@ -204,6 +205,7 @@ func (wer *Worker) Cancel() error {
// Reset 重设连接
func (wer *Worker) Reset() {
logger.Verbosef("Reset worker: %d\n", wer.ID())
if wer.resetFunc == nil {
logger.Verbosef("DEBUG: worker: resetFunc not set")
return
@@ -219,13 +221,15 @@ func (wer *Worker) Reset() {
// RefreshDownloadUrl 重新刷新下载链接
func (wer *Worker) RefreshDownloadUrl() {
var apierr *apierror.ApiError
logger.Verbosef("get new download url for worker: %d\n", wer.ID())
durl, apierr := wer.panClient.OpenapiPanClient().GetFileDownloadUrl(&aliyunpan.GetFileDownloadUrlParam{DriveId: wer.driveId, FileId: wer.fileId})
if apierr != nil {
logger.Verbosef("get new download url for worker: %d, error: %+v\n", wer.ID(), apierr)
wer.status.statusCode = StatusCodeTooManyConnections
return
}
wer.url = durl.Url
logger.Verbosef("get new download url for worker: %d, new url: %s\n", wer.ID(), wer.url)
}
// Canceled 是否已经取消
@@ -265,6 +269,7 @@ func (wer *Worker) Err() error {
// Execute 执行任务
func (wer *Worker) Execute() {
logger.Verbosef("Execute worker: %d\n", wer.ID())
wer.lazyInit()
wer.execMu.Lock()
@@ -351,6 +356,24 @@ func (wer *Worker) Execute() {
logger.Verboseln("download url return 403 error and expired url response")
wer.status.statusCode = StatusCodeDownloadUrlExpired
wer.err = errors.New(resp.Status)
} else if strings.Contains(respBodyStr, "ExceedMaxConcurrency") { // 遇到限流报错
// 普通应用:文件分片下载的并发数为 3,即某用户使用 App 时,可以同时下载 1 个文件的 3 个分片,或者同时下载 3 个文件的各 1 个分片。
// 超过并发,再次调用接口,报错 http status:403。示例报错信息如下:
// <?xml version="1.0" encoding="UTF-8"?>
//<Error>
// <Code>RequestDeniedByCallback</Code>
// <Message>Callback deny this request reason: ExceedMaxConcurrency</Message>
// <RequestId>66B5720887CECD32333EB8C1</RequestId>
// <HostId>cn-beijing-data.aliyundrive.net</HostId>
// <EC>0007-00000209</EC>
// <RecommendDoc>https://api.aliyun.com/troubleshoot?q=0007-00000209</RecommendDoc>
//</Error>
logger.Verboseln("download url return 403 error and exceed max concurrency response")
wer.status.statusCode = StatusCodeDownloadUrlExceedMaxConcurrency
wer.err = errors.New(resp.Status)
// 遇到限流,本线程延迟后,再重试
time.Sleep(10 * time.Second)
}
}
return
+7 -1
View File
@@ -4,7 +4,7 @@
// 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
// 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,
@@ -30,6 +30,7 @@ type (
io.Seeker
Range() transfer.Range
Left() int64
ResetReader(readerAt io.ReaderAt)
}
fileBlock struct {
@@ -147,3 +148,8 @@ func (fb *fileBlock) Range() transfer.Range {
func (fb *fileBlock) Readed() int64 {
return fb.readed
}
func (fb *fileBlock) ResetReader(readerAt io.ReaderAt) {
fb.readerAt = readerAt
fb.readed = 0
}
+12 -6
View File
@@ -4,7 +4,7 @@
// 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
// 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,
@@ -16,10 +16,13 @@ package uploader
import "fmt"
var (
UploadUrlExpired = fmt.Errorf("UrlExpired")
UploadPartNotSeq = fmt.Errorf("PartNotSequential")
UploadTerminate = fmt.Errorf("UploadErrorTerminate")
UploadPartAlreadyExist = fmt.Errorf("PartAlreadyExist")
UploadUrlExpired = fmt.Errorf("UrlExpired")
UploadPartNotSeq = fmt.Errorf("PartNotSequential")
UploadNoSuchUpload = fmt.Errorf("NoSuchUpload")
UploadTerminate = fmt.Errorf("UploadErrorTerminate")
UploadPartAlreadyExist = fmt.Errorf("PartAlreadyExist")
UploadHttpError = fmt.Errorf("HttpError")
UploadLocalFileAlreadyClosedError = fmt.Errorf("LocalFileAlreadyClosedError")
)
type (
@@ -33,5 +36,8 @@ type (
)
func (me *MultiError) Error() string {
return me.Err.Error()
if me.Err != nil {
return me.Err.Error()
}
return ""
}
+7 -11
View File
@@ -4,7 +4,7 @@
// 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
// 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,
@@ -20,9 +20,9 @@ import (
type (
// BlockState 文件区块信息
BlockState struct {
ID int `json:"id"`
Range transfer.Range `json:"range"`
UploadDone bool `json:"upload_done"`
ID int `json:"id"`
Range transfer.Range `json:"range"`
UploadDone bool `json:"upload_done"`
}
// InstanceState 上传断点续传信息
@@ -39,18 +39,14 @@ func (muer *MultiUploader) getWorkerListByInstanceState(is *InstanceState) worke
id: blockState.ID,
partOffset: blockState.Range.Begin,
splitUnit: NewBufioSplitUnit(muer.file, blockState.Range, muer.speedsStat, muer.rateLimit, muer.globalSpeedsStat),
uploadDone: false,
uploadDone: false,
})
} else {
// 已经完成的, 也要加入 (可继续优化)
// 已经完成的, 也要加入
workers = append(workers, &worker{
id: blockState.ID,
partOffset: blockState.Range.Begin,
splitUnit: &fileBlock{
readRange: blockState.Range,
readed: blockState.Range.End - blockState.Range.Begin,
readerAt: muer.file,
},
splitUnit: NewBufioSplitUnit(muer.file, blockState.Range, muer.speedsStat, muer.rateLimit, muer.globalSpeedsStat),
uploadDone: true,
})
}
+13 -10
View File
@@ -4,7 +4,7 @@
// 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
// 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,
@@ -16,6 +16,7 @@ package uploader
import (
"context"
"github.com/tickstep/aliyunpan-api/aliyunpan"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/library-go/converter"
"github.com/tickstep/library-go/logger"
@@ -61,6 +62,7 @@ type (
// 网盘上传参数
UploadOpEntity *aliyunpan.CreateFileUploadResult `json:"uploadOpEntity"`
panClient *config.PanClient
}
// MultiUploaderConfig 多线程上传配置
@@ -72,12 +74,13 @@ type (
)
// NewMultiUploader 初始化上传
func NewMultiUploader(multiUpload MultiUpload, file rio.ReaderAtLen64, config *MultiUploaderConfig, uploadOpEntity *aliyunpan.CreateFileUploadResult, globalSpeedsStat *speeds.Speeds) *MultiUploader {
func NewMultiUploader(multiUpload MultiUpload, file rio.ReaderAtLen64, config *MultiUploaderConfig, uploadOpEntity *aliyunpan.CreateFileUploadResult, panClient *config.PanClient, globalSpeedsStat *speeds.Speeds) *MultiUploader {
return &MultiUploader{
multiUpload: multiUpload,
file: file,
config: config,
UploadOpEntity: uploadOpEntity,
panClient: panClient,
globalSpeedsStat: globalSpeedsStat,
}
}
@@ -137,13 +140,13 @@ func (muer *MultiUploader) Execute() error {
// 分配任务
if muer.instanceState != nil {
muer.workers = muer.getWorkerListByInstanceState(muer.instanceState)
logger.Verboseln("upload task CREATED from instance state\n")
logger.Verbosef("upload task CREATED from instance state\n")
} else {
muer.workers = muer.getWorkerListByInstanceState(&InstanceState{
BlockList: SplitBlock(muer.file.Len(), muer.config.BlockSize),
})
logger.Verboseln("upload task CREATED: block size: %d, num: %d\n", muer.config.BlockSize, len(muer.workers))
logger.Verbosef("upload task CREATED: block size: %d, worker num: %d\n", muer.config.BlockSize, len(muer.workers))
}
// 开始上传
@@ -195,32 +198,32 @@ func (muer *MultiUploader) Cancel() {
close(muer.canceled)
}
//OnExecute 设置开始上传事件
// OnExecute 设置开始上传事件
func (muer *MultiUploader) OnExecute(onExecuteEvent requester.Event) {
muer.onExecuteEvent = onExecuteEvent
}
//OnSuccess 设置成功上传事件
// OnSuccess 设置成功上传事件
func (muer *MultiUploader) OnSuccess(onSuccessEvent requester.Event) {
muer.onSuccessEvent = onSuccessEvent
}
//OnFinish 设置结束上传事件
// OnFinish 设置结束上传事件
func (muer *MultiUploader) OnFinish(onFinishEvent requester.Event) {
muer.onFinishEvent = onFinishEvent
}
//OnCancel 设置取消上传事件
// OnCancel 设置取消上传事件
func (muer *MultiUploader) OnCancel(onCancelEvent requester.Event) {
muer.onCancelEvent = onCancelEvent
}
//OnError 设置上传发生错误事件
// OnError 设置上传发生错误事件
func (muer *MultiUploader) OnError(onErrorEvent requester.EventOnError) {
muer.onErrorEvent = onErrorEvent
}
//OnUploadStatusEvent 设置上传状态事件
// OnUploadStatusEvent 设置上传状态事件
func (muer *MultiUploader) OnUploadStatusEvent(f UploadStatusFunc) {
muer.onUploadStatusEvent = f
}
+33 -13
View File
@@ -4,7 +4,7 @@
// 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
// 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,
@@ -15,17 +15,18 @@ package uploader
import (
"context"
"errors"
"github.com/oleiade/lane"
"github.com/tickstep/aliyunpan/internal/waitgroup"
"github.com/tickstep/library-go/logger"
"github.com/tickstep/library-go/requester"
"os"
"io"
"strconv"
)
type (
worker struct {
id int
id int // ID从0开始计数
partOffset int64
splitUnit SplitUnit
uploadDone bool
@@ -34,10 +35,17 @@ type (
workerList []*worker
)
// Readed 获取已上传大小
func (werl *workerList) Readed() int64 {
var readed int64
for _, wer := range *werl {
readed += wer.splitUnit.Readed()
if wer.uploadDone {
// 已经上传完成的分片
readed += wer.splitUnit.Range().End - wer.splitUnit.Range().Begin
} else {
// 正在上传中的分片
readed += wer.splitUnit.Readed()
}
}
return readed
}
@@ -89,7 +97,7 @@ func (muer *MultiUploader) upload() (uperr error) {
)
go func() {
if !wer.uploadDone {
logger.Verboseln("begin to upload part: " + strconv.Itoa(wer.id))
logger.Verboseln("begin to upload part num: " + strconv.Itoa(wer.id+1))
uploadDone, terr = muer.multiUpload.UploadFile(ctx, int(wer.id), wer.partOffset, wer.splitUnit.Range().End, wer.splitUnit, uploadClient)
} else {
uploadDone = true
@@ -102,11 +110,11 @@ func (muer *MultiUploader) upload() (uperr error) {
return
case <-doneChan:
// continue
logger.Verboseln("multiUpload worker upload file done")
logger.Verboseln("multiUpload worker upload file http action done")
}
cancel()
if terr != nil {
logger.Verboseln("upload file part err: %+v", terr)
logger.Verbosef("upload file part err: %+v\n", terr)
if me, ok := terr.(*MultiError); ok {
if me.Terminated { // 终止
muer.closeCanceledOnce.Do(func() { // 只关闭一次
@@ -115,18 +123,21 @@ func (muer *MultiUploader) upload() (uperr error) {
uperr = me.Err
return
} else if me.NeedStartOver {
logger.Verboseln("upload start over: %d\n", wer.id)
logger.Verbosef("upload start over: %d\n", wer.id)
// 从头开始上传
muer.closeCanceledOnce.Do(func() { // 只关闭一次
close(muer.canceled)
})
uperr = me.Err
return
} else {
uperr = me.Err
return
}
}
logger.Verboseln("upload err: %s, id: %d\n", terr, wer.id)
wer.splitUnit.Seek(0, os.SEEK_SET)
logger.Verbosef("upload worker err: %s, id: %d\n", terr, wer.id)
wer.splitUnit.Seek(0, io.SeekStart)
uploadDeque.Prepend(wer) // 放回上传队列首位
return
}
@@ -139,9 +150,11 @@ func (muer *MultiUploader) upload() (uperr error) {
}()
wg.Wait()
if uperr != nil {
if uperr == UploadPartNotSeq {
// 分片出现乱序,需要重新上传,取消本次所有剩余的分片的上传
break
if errors.Is(uperr, UploadPartNotSeq) ||
errors.Is(uperr, UploadNoSuchUpload) ||
errors.Is(uperr, UploadLocalFileAlreadyClosedError) {
// 清空数据,停止上传
uploadDeque = lane.NewDeque() // 清空待上传列表
}
}
// 没有任务了
@@ -153,6 +166,13 @@ func (muer *MultiUploader) upload() (uperr error) {
// 释放链路
uploadClient.CloseIdleConnections()
// 返回错误,通知上层客户端进行处理
if errors.Is(uperr, UploadPartNotSeq) ||
errors.Is(uperr, UploadNoSuchUpload) ||
errors.Is(uperr, UploadLocalFileAlreadyClosedError) {
return uperr
}
select {
case <-muer.canceled:
if uperr != nil {
+14
View File
@@ -14,6 +14,7 @@
package uploader
import (
"sync/atomic"
"time"
)
@@ -24,6 +25,7 @@ type (
Uploaded() int64 // 已上传数据
SpeedsPerSecond() int64 // 每秒的上传速度
TimeElapsed() time.Duration // 上传时间
TimeLeft() time.Duration // 预计剩余时间, 负数代表未知
}
// UploadStatus 上传状态
@@ -67,6 +69,18 @@ func (us *UploadStatus) TimeElapsed() time.Duration {
return us.timeElapsed
}
// TimeLeft 返回预计剩余时间, 负数代表未知
func (us *UploadStatus) TimeLeft() time.Duration {
var left time.Duration
speeds := atomic.LoadInt64(&us.speedsPerSecond)
if speeds <= 0 {
left = -1
} else {
left = time.Duration((us.totalSize-us.uploaded)/(speeds)) * time.Second
}
return left
}
// GetStatusChan 获取上传状态
func (u *Uploader) GetStatusChan() <-chan Status {
c := make(chan Status)
@@ -15,39 +15,44 @@ package pandownload
import (
"fmt"
"io"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/tickstep/aliyunpan-api/aliyunpan"
"github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
"github.com/tickstep/aliyunpan/cmder/cmdtable"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/tickstep/aliyunpan/internal/file/downloader"
"github.com/tickstep/aliyunpan/internal/functions"
"github.com/tickstep/aliyunpan/internal/global"
"github.com/tickstep/aliyunpan/internal/localfile"
"github.com/tickstep/aliyunpan/internal/log"
"github.com/tickstep/aliyunpan/internal/plugins"
"github.com/tickstep/aliyunpan/internal/taskframework"
"github.com/tickstep/aliyunpan/internal/ui"
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/aliyunpan/library/requester/transfer"
"github.com/tickstep/library-go/converter"
"github.com/tickstep/library-go/logger"
"github.com/tickstep/library-go/requester/rio/speeds"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
)
type (
FileSourceType string
// DownloadTaskUnit 下载的任务单元
DownloadTaskUnit struct {
taskInfo *taskframework.TaskInfo // 任务信息
// DownloadActionId 下载动作ID,唯一标识一次下载动作的ID。这个ID每次下载任务启动的时候会自动生成,同一次下载任务下载的文件,这个ID都是同一个值。
DownloadActionId string
taskInfo *taskframework.TaskInfo // 任务信息
Cfg *downloader.Config
PanClient *config.PanClient
SubPanClientList []*config.PanClient // 辅助下载子账号列表
ParentTaskExecutor *taskframework.TaskExecutor
DownloadStatistic *DownloadStatistic // 下载统计
@@ -55,21 +60,23 @@ type (
// 可选项
VerbosePrinter *logger.CmdVerbose
PrintFormat string
IsPrintStatus bool // 是否输出各个下载线程的详细信息
IsExecutedPermission bool // 下载成功后是否加上执行权限
IsOverwrite bool // 是否覆盖已存在的文件
NoCheck bool // 不校验文件
FilePanPath string // 要下载的网盘文件路径
SavePath string // 文件保存在本地的路径
OriginSaveRootPath string // 文件保存在本地的根目录路径
DriveId string
FilePanSource global.FileSourceType // 要下载的网盘文件来源
FilePanPath string // 要下载的网盘文件路径
SavePath string // 文件保存在本地的路径
OriginSaveRootPath string // 文件保存在本地的根目录路径
DriveId string // 网盘ID
fileInfo *aliyunpan.FileEntity // 文件或目录详情
// 下载文件记录器
FileRecorder *log.FileRecorder
UI *ui.DashboardPanel // 下载统计面板UI
}
)
@@ -88,15 +95,14 @@ const (
StrDownloadChecksumFailed = "检测文件有效性失败"
// DefaultDownloadMaxRetry 默认下载失败最大重试次数
DefaultDownloadMaxRetry = 3
// BackupFileSource 备份盘文件
BackupFileSource FileSourceType = "backup"
// ResourceFileSource 资源库文件
ResourceFileSource FileSourceType = "resource"
// AlbumFileSource 相册文件
AlbumFileSource FileSourceType = "album"
)
// SetFileInfo 设置文件信息
func (dtu *DownloadTaskUnit) SetFileInfo(source global.FileSourceType, f *aliyunpan.FileEntity) {
dtu.FilePanSource = source
dtu.fileInfo = f
}
func (dtu *DownloadTaskUnit) SetTaskInfo(info *taskframework.TaskInfo) {
dtu.taskInfo = info
}
@@ -107,6 +113,26 @@ func (dtu *DownloadTaskUnit) verboseInfof(format string, a ...interface{}) {
}
}
// logf 打印文本日志
func (dtu *DownloadTaskUnit) logf(format string, a ...interface{}) {
if dtu.UI != nil {
// 使用UI面板显示下载日志
dtu.UI.Logf(format, a...)
return
}
// 使用传统单行文本显示下载日志,直接输出到控制台
fmt.Printf(format, a...)
}
// updateUITaskState 更新UI下载面板的任务状态
func (dtu *DownloadTaskUnit) updateUITaskState(state ui.TaskState, message string) {
if dtu.UI == nil {
return
}
dtu.UI.MarkTaskState(dtu.taskInfo.Id(), state, message)
}
// download 执行下载文件(非目录)
func (dtu *DownloadTaskUnit) download() (err error) {
var (
@@ -170,22 +196,25 @@ func (dtu *DownloadTaskUnit) download() (err error) {
}
defer file.Close()
der := downloader.NewDownloader(writer, dtu.Cfg, dtu.PanClient, dtu.GlobalSpeedsStat)
der.SetFileInfo(dtu.fileInfo)
der := downloader.NewDownloader(writer, dtu.Cfg, dtu.PanClient, dtu.SubPanClientList, dtu.GlobalSpeedsStat)
der.SetFileInfo(dtu.FilePanSource, dtu.fileInfo)
der.SetDriveId(dtu.DriveId)
der.SetStatusCodeBodyCheckFunc(func(respBody io.Reader) error {
// 解析错误
return apierror.NewFailedApiError("")
})
// 检查输出格式
if dtu.PrintFormat == "" {
dtu.PrintFormat = DefaultPrintFormat
}
// 这里用共享变量的方式
isComplete := false
der.OnDownloadStatusEvent(func(status transfer.DownloadStatuser, workersCallback func(downloader.RangeWorkerFunc)) {
// 方式1:使用UI统计面板显示下载进度
if dtu.UI != nil {
// 更新UI面板任务状态
dtu.UI.UpdateTaskProgress(dtu.taskInfo.Id(), status.Downloaded(), status.TotalSize(), status.SpeedsPerSecond(), status.TimeLeft())
return
}
// 方式2:使用传统单行显示下载进度
// 这里可能会下载结束了, 还会输出内容
builder := &strings.Builder{}
if dtu.IsPrintStatus {
@@ -233,7 +262,7 @@ func (dtu *DownloadTaskUnit) download() (err error) {
})
der.OnExecute(func() {
fmt.Printf("[%s] 下载开始\n", dtu.taskInfo.Id())
dtu.logf("[%s] 下载开始\n", dtu.taskInfo.Id())
})
err = der.Execute()
@@ -250,7 +279,7 @@ func (dtu *DownloadTaskUnit) download() (err error) {
if removeErr != nil {
dtu.verboseInfof("[%s] remove file error: %s\n", dtu.taskInfo.Id(), removeErr)
}
fmt.Printf("[%s] 下载失败,文件不合法或者被禁止下载: %s\n", dtu.taskInfo.Id(), dtu.SavePath)
dtu.logf("[%s] 下载失败,文件不合法或者被禁止下载: %s\n", dtu.taskInfo.Id(), dtu.SavePath)
return err
} else {
// 下载发生错误
@@ -275,10 +304,10 @@ func (dtu *DownloadTaskUnit) download() (err error) {
if dtu.IsExecutedPermission {
err = file.Chmod(0766)
if err != nil {
fmt.Printf("[%s] 警告, 加执行权限错误: %s\n", dtu.taskInfo.Id(), err)
dtu.logf("[%s] 警告, 加执行权限错误: %s\n", dtu.taskInfo.Id(), err)
}
}
fmt.Printf("\n[%s] 下载完成, 保存位置: %s\n", dtu.taskInfo.Id(), dtu.SavePath)
dtu.logf("\n[%s] 下载完成, 保存位置: %s\n", dtu.taskInfo.Id(), dtu.SavePath)
return nil
}
@@ -318,7 +347,7 @@ func (dtu *DownloadTaskUnit) checkFileValid(result *taskframework.TaskUnitRunRes
if dtu.fileInfo.FileSize >= 128*converter.MB {
// 大文件, 输出一句提示消息
fmt.Printf("[%s] 开始检验文件有效性, 请稍候...\n", dtu.taskInfo.Id())
dtu.logf("[%s] 开始检验文件有效性, 请稍候...\n", dtu.taskInfo.Id())
}
// 就在这里处理校验出错
@@ -331,7 +360,7 @@ func (dtu *DownloadTaskUnit) checkFileValid(result *taskframework.TaskUnitRunRes
// 文件不支持校验
result.ResultMessage = "检验文件有效性"
result.Err = err
fmt.Printf("[%s] 检验文件有效性: %s\n", dtu.taskInfo.Id(), err)
dtu.logf("[%s] 检验文件有效性: %s\n", dtu.taskInfo.Id(), err)
return true
case ErrDownloadFileBanned:
// 违规文件
@@ -349,7 +378,7 @@ func (dtu *DownloadTaskUnit) checkFileValid(result *taskframework.TaskUnitRunRes
}
}
fmt.Printf("[%s] 检验文件有效性成功: %s\n", dtu.taskInfo.Id(), dtu.SavePath)
dtu.logf("[%s] 检验文件有效性成功: %s\n", dtu.taskInfo.Id(), dtu.SavePath)
return true
}
@@ -357,16 +386,19 @@ func (dtu *DownloadTaskUnit) OnRetry(lastRunResult *taskframework.TaskUnitRunRes
// 输出错误信息
if lastRunResult.Err == nil {
// result中不包含Err, 忽略输出
fmt.Printf("[%s] %s, 重试 %d/%d\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage, dtu.taskInfo.Retry(), dtu.taskInfo.MaxRetry())
dtu.logf("[%s] %s, 重试 %d/%d\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage, dtu.taskInfo.Retry(), dtu.taskInfo.MaxRetry())
return
}
fmt.Printf("[%s] %s, %s, 重试 %d/%d\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage, lastRunResult.Err, dtu.taskInfo.Retry(), dtu.taskInfo.MaxRetry())
dtu.logf("[%s] %s, %s, 重试 %d/%d\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage, lastRunResult.Err, dtu.taskInfo.Retry(), dtu.taskInfo.MaxRetry())
}
func (dtu *DownloadTaskUnit) OnSuccess(lastRunResult *taskframework.TaskUnitRunResult) {
// 执行插件
dtu.pluginCallback("success")
// 更新UI面板任务状态
dtu.updateUITaskState(ui.TaskSuccess, "")
// 下载文件数据记录
if config.Config.FileRecordConfig == "1" {
if dtu.fileInfo.IsFile() {
@@ -386,13 +418,20 @@ func (dtu *DownloadTaskUnit) OnFailed(lastRunResult *taskframework.TaskUnitRunRe
// 失败
dtu.pluginCallback("fail")
// 更新UI面板
failedMessage := lastRunResult.ResultMessage
if lastRunResult.Err != nil {
failedMessage = fmt.Sprintf("%s, %s", lastRunResult.ResultMessage, lastRunResult.Err)
}
dtu.updateUITaskState(ui.TaskFailed, failedMessage)
// 失败
if lastRunResult.Err == nil {
// result中不包含Err, 忽略输出
fmt.Printf("[%s] %s\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage)
dtu.logf("[%s] %s\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage)
return
}
fmt.Printf("[%s] %s, %s\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage, lastRunResult.Err)
dtu.logf("[%s] %s, %s\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage, lastRunResult.Err)
}
func (dtu *DownloadTaskUnit) pluginCallback(result string) {
@@ -402,7 +441,9 @@ func (dtu *DownloadTaskUnit) pluginCallback(result string) {
pluginManger := plugins.NewPluginManager(config.GetPluginDir())
plugin, _ := pluginManger.GetPlugin()
pluginParam := &plugins.DownloadFileFinishParams{
DownloadActionId: dtu.DownloadActionId,
DriveId: dtu.fileInfo.DriveId,
DriveFileId: dtu.fileInfo.FileId,
DriveFilePath: dtu.fileInfo.Path,
DriveFileName: dtu.fileInfo.FileName,
DriveFileSize: dtu.fileInfo.FileSize,
@@ -423,7 +464,7 @@ func (dtu *DownloadTaskUnit) OnComplete(lastRunResult *taskframework.TaskUnitRun
}
func (dtu *DownloadTaskUnit) OnCancel(lastRunResult *taskframework.TaskUnitRunResult) {
dtu.updateUITaskState(ui.TaskCanceled, "取消: "+dtu.FilePanPath)
}
func (dtu *DownloadTaskUnit) RetryWait() time.Duration {
@@ -450,8 +491,8 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) {
}
// 输出文件信息
fmt.Print("\n")
fmt.Printf("[%s] ----\n%s\n", dtu.taskInfo.Id(), dtu.fileInfo.String())
dtu.logf("\n")
dtu.logf("[%s] ----\n%s\n", dtu.taskInfo.Id(), dtu.fileInfo.String())
// 调用插件
ft := "file"
@@ -463,6 +504,7 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) {
localFilePath := strings.TrimPrefix(dtu.SavePath, dtu.OriginSaveRootPath)
localFilePath = strings.TrimPrefix(strings.TrimPrefix(localFilePath, "\\"), "/")
pluginParam := &plugins.DownloadFilePrepareParams{
DownloadActionId: dtu.DownloadActionId,
DriveId: dtu.fileInfo.DriveId,
DriveFilePath: dtu.fileInfo.Path,
DriveFileName: dtu.fileInfo.FileName,
@@ -475,7 +517,8 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) {
if downloadFilePrepareResult, er := plugin.DownloadFilePrepareCallback(plugins.GetContext(config.Config.ActiveUser()), pluginParam); er == nil && downloadFilePrepareResult != nil {
if strings.Compare("yes", downloadFilePrepareResult.DownloadApproved) != 0 {
// skip download this file
fmt.Printf("插件取消了该文件下载: %s\n", dtu.fileInfo.Path)
dtu.logf("插件取消了该文件下载: %s\n", dtu.fileInfo.Path)
dtu.updateUITaskState(ui.TaskCanceled, "插件取消了该文件下载: "+dtu.fileInfo.Path)
result.Succeed = false
result.Cancel = true
return
@@ -484,7 +527,7 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) {
targetSaveRelativePath := strings.TrimPrefix(downloadFilePrepareResult.LocalFilePath, "/")
targetSaveRelativePath = strings.TrimPrefix(targetSaveRelativePath, "\\")
dtu.SavePath = path.Clean(dtu.OriginSaveRootPath + string(os.PathSeparator) + targetSaveRelativePath)
fmt.Printf("插件修改文件下载保存路径为: %s\n", dtu.SavePath)
dtu.logf("插件修改文件下载保存路径为: %s\n", dtu.SavePath)
}
}
@@ -547,13 +590,17 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) {
}
time.Sleep(1 * time.Second)
// 排序,按名称排序,从小到大
sort.Slice(fileList, func(i, j int) bool {
return fileList[i].FileName < fileList[j].FileName
})
// 创建对应的任务进行下载
for k := range fileList {
fileList[k].Path = path.Join(dtu.FilePanPath, fileList[k].FileName)
// 是否排除下载
if utils.IsExcludeFile(fileList[k].Path, &dtu.Cfg.ExcludeNames) {
fmt.Printf("排除文件: %s\n", fileList[k].Path)
dtu.logf("排除文件: %s\n", fileList[k].Path)
continue
}
@@ -567,12 +614,17 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) {
newCfg := *dtu.Cfg
subUnit.Cfg = &newCfg
subUnit.fileInfo = fileList[k] // 保存文件信息
subUnit.FilePanSource = dtu.FilePanSource
subUnit.FilePanPath = fileList[k].Path
subUnit.SavePath = filepath.Join(dtu.OriginSaveRootPath, fileList[k].Path) // 保存位置
// 加入父队列,按照队列调度进行下载
info := dtu.ParentTaskExecutor.Append(&subUnit, dtu.taskInfo.MaxRetry())
fmt.Printf("[%s] 加入下载队列: %s\n", info.Id(), fileList[k].Path)
dtu.logf("[%s] 加入下载队列: %s\n", info.Id(), fileList[k].Path)
// UI面板注册任务
if dtu.UI != nil {
dtu.UI.RegisterTask(info.Id(), fileList[k].Path, fileList[k].FileSize, fileList[k].IsFile())
}
}
// 本下载任务执行成功
@@ -580,7 +632,7 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) {
return
}
fmt.Printf("[%s] 准备下载: %s\n", dtu.taskInfo.Id(), dtu.FilePanPath)
dtu.logf("[%s] 准备下载: %s\n", dtu.taskInfo.Id(), dtu.FilePanPath)
//if !dtu.IsOverwrite && FileExist(dtu.SavePath) {
// fmt.Printf("[%s] 文件已经存在: %s, 跳过...\n", dtu.taskInfo.Id(), dtu.SavePath)
@@ -589,12 +641,15 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) {
//}
// 支持符号文件,逻辑和注释代码一致
if !dtu.IsOverwrite && SymlinkFileExist(dtu.SavePath, dtu.OriginSaveRootPath) {
fmt.Printf("[%s] 文件已经存在: %s, 跳过...\n", dtu.taskInfo.Id(), dtu.SavePath)
dtu.updateUITaskState(ui.TaskSkipped, "文件已存在,跳过: "+dtu.SavePath)
dtu.logf("[%s] 文件已经存在: %s, 跳过...\n", dtu.taskInfo.Id(), dtu.SavePath)
result.Succeed = true // 执行成功
return
}
fmt.Printf("[%s] 将会下载到路径: %s\n", dtu.taskInfo.Id(), dtu.SavePath)
dtu.logf("[%s] 将会下载到路径: %s\n", dtu.taskInfo.Id(), dtu.SavePath)
// 更新UI面板任务状态
dtu.updateUITaskState(ui.TaskRunning, "")
var ok bool
er := dtu.download()
+108 -17
View File
@@ -4,23 +4,40 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"github.com/tickstep/aliyunpan/internal/global"
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/library-go/crypto"
"github.com/tickstep/library-go/getip"
"github.com/tickstep/library-go/logger"
"github.com/tickstep/library-go/requester"
"net/url"
"regexp"
"runtime"
"strings"
"time"
"github.com/tickstep/aliyunpan/internal/global"
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/library-go/crypto"
"github.com/tickstep/library-go/logger"
"github.com/tickstep/library-go/requester"
)
type LoginHelper struct {
webHost string
}
const (
// SUCCESS 成功
SUCCESS = 0
// ERROR 失败
ERROR = 500
// ERROR_TOO_MANY_REQUESTS 请求太频繁,已被限流
ERROR_TOO_MANY_REQUESTS = 429
// ERROR_NEED_LOGIN_AGAIN Token已经过期,需要重新登录
ERROR_NEED_LOGIN_AGAIN = 100004
)
// CommonResultError 通用接口错误
type CommonResultError struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
type LoginHttpResult struct {
Code int `json:"code"`
Msg string `json:"msg"`
@@ -51,6 +68,13 @@ type CommonTokenEntity struct {
Webapi *LoginTokenResult `json:"webapi"`
}
func NewCommonResultError(msg string) *CommonResultError {
return &CommonResultError{
Code: 500,
Msg: msg,
}
}
func NewLoginHelper(webHost string) *LoginHelper {
return &LoginHelper{
webHost: webHost,
@@ -63,7 +87,7 @@ func (h *LoginHelper) GetQRCodeLoginUrl(keyStr string) (*QRCodeUrlResult, error)
keyStr = utils.GetUniqueKeyStr()
}
fullUrl := strings.Builder{}
ipAddr, err := getip.IPInfoFromTechainBaidu()
ipAddr, err := utils.GetPublicIP()
if err != nil {
ipAddr = "127.0.0.1"
}
@@ -86,6 +110,16 @@ func (h *LoginHelper) GetQRCodeLoginUrl(keyStr string) (*QRCodeUrlResult, error)
return nil, err
}
// 通用接口错误
commonResult := &CommonResultError{}
if err1 := json.Unmarshal(body, commonResult); err1 != nil {
logger.Verboseln("parse openapi result json error ", err1)
return nil, err1
}
if commonResult.Code != 0 {
return nil, fmt.Errorf(commonResult.Msg)
}
errResp := &LoginHttpResult{}
if err1 := json.Unmarshal(body, errResp); err1 != nil {
logger.Verboseln("parse qrcode result json error ", err1)
@@ -137,6 +171,16 @@ func (h *LoginHelper) GetQRCodeLoginResult(tokenId string) (*QRCodeLoginResult,
return nil, err
}
// 通用接口错误
commonResult := &CommonResultError{}
if err1 := json.Unmarshal(body, commonResult); err1 != nil {
logger.Verboseln("parse openapi result json error ", err1)
return nil, err1
}
if commonResult.Code != 0 {
return nil, fmt.Errorf(commonResult.Msg)
}
errResp := &LoginHttpResult{}
if err1 := json.Unmarshal(body, errResp); err1 != nil {
logger.Verboseln("parse qrcode result json error ", err1)
@@ -177,6 +221,16 @@ func (h *LoginHelper) GetRefreshToken(tokenId string) (*QRCodeLoginResult, error
return nil, err
}
// 通用接口错误
commonResult := &CommonResultError{}
if err1 := json.Unmarshal(body, commonResult); err1 != nil {
logger.Verboseln("parse openapi result json error ", err1)
return nil, err1
}
if commonResult.Code != 0 {
return nil, fmt.Errorf(commonResult.Msg)
}
errResp := &LoginHttpResult{}
if err1 := json.Unmarshal(body, errResp); err1 != nil {
logger.Verboseln("parse qrcode result json error ", err1)
@@ -233,7 +287,7 @@ func (h *LoginHelper) ParseSecureRefreshToken(keyStr, secureRefreshToken string)
}
// GetWebapiNewToken 获取Webapi Token
func (h *LoginHelper) GetWebapiNewToken(ticketId, userId, oldAccessToken string) (*LoginTokenResult, error) {
func (h *LoginHelper) GetWebapiNewToken(ticketId, userId, oldAccessToken string) (*LoginTokenResult, *CommonResultError) {
fullUrl := strings.Builder{}
fmt.Fprintf(&fullUrl, "%s/auth/tickstep/aliyunpan/token/webapi/%s/refresh?userId=%s",
h.webHost, ticketId, userId)
@@ -251,16 +305,29 @@ func (h *LoginHelper) GetWebapiNewToken(ticketId, userId, oldAccessToken string)
body, err := client.Fetch("GET", fullUrl.String(), nil, header)
if err != nil {
logger.Verboseln("get web token result error ", err)
return nil, err
return nil, NewCommonResultError(err.Error())
}
// 通用接口错误
commonResult := &CommonResultError{}
if err1 := json.Unmarshal(body, commonResult); err1 != nil {
logger.Verboseln("parse openapi result json error ", err1)
return nil, NewCommonResultError(err1.Error())
}
if commonResult.Code != 0 {
return nil, commonResult
}
errResp := &LoginHttpResult{}
if err1 := json.Unmarshal(body, errResp); err1 != nil {
logger.Verboseln("parse result json error ", err1)
return nil, err1
return nil, NewCommonResultError(err1.Error())
}
if errResp.Code != 0 {
return nil, fmt.Errorf(errResp.Msg)
return nil, &CommonResultError{
Code: errResp.Code,
Msg: errResp.Msg,
}
}
// parse result
@@ -268,13 +335,13 @@ func (h *LoginHelper) GetWebapiNewToken(ticketId, userId, oldAccessToken string)
r.Data = &LoginTokenResult{}
if err2 := json.Unmarshal(body, r); err2 != nil {
logger.Verboseln("parse web token result json error ", err2)
return nil, err2
return nil, NewCommonResultError(err2.Error())
}
return r.Data.(*LoginTokenResult), nil
}
// GetOpenapiNewToken 获取Openapi Token
func (h *LoginHelper) GetOpenapiNewToken(ticketId, userId, oldAccessToken string) (*LoginTokenResult, error) {
func (h *LoginHelper) GetOpenapiNewToken(ticketId, userId, oldAccessToken string) (*LoginTokenResult, *CommonResultError) {
fullUrl := strings.Builder{}
fmt.Fprintf(&fullUrl, "%s/auth/tickstep/aliyunpan/token/openapi/%s/refresh?userId=%s",
h.webHost, ticketId, userId)
@@ -292,16 +359,30 @@ func (h *LoginHelper) GetOpenapiNewToken(ticketId, userId, oldAccessToken string
body, err := client.Fetch("GET", fullUrl.String(), nil, header)
if err != nil {
logger.Verboseln("get openapi token result error ", err)
return nil, err
return nil, NewCommonResultError(err.Error())
}
// 通用接口错误
commonResult := &CommonResultError{}
if err1 := json.Unmarshal(body, commonResult); err1 != nil {
logger.Verboseln("parse openapi result json error ", err1)
return nil, NewCommonResultError(err1.Error())
}
if commonResult.Code != 0 {
return nil, commonResult
}
// 解析结果
errResp := &LoginHttpResult{}
if err1 := json.Unmarshal(body, errResp); err1 != nil {
logger.Verboseln("parse result json error ", err1)
return nil, err1
return nil, NewCommonResultError(err1.Error())
}
if errResp.Code != 0 {
return nil, fmt.Errorf(errResp.Msg)
return nil, &CommonResultError{
Code: errResp.Code,
Msg: errResp.Msg,
}
}
// parse result
@@ -309,7 +390,7 @@ func (h *LoginHelper) GetOpenapiNewToken(ticketId, userId, oldAccessToken string
r.Data = &LoginTokenResult{}
if err2 := json.Unmarshal(body, r); err2 != nil {
logger.Verboseln("parse openapi token result json error ", err2)
return nil, err2
return nil, NewCommonResultError(err2.Error())
}
return r.Data.(*LoginTokenResult), nil
}
@@ -335,6 +416,16 @@ func (h *LoginHelper) GetLoginToken(ticketId string) (*CommonTokenEntity, error)
return nil, err
}
// 通用接口错误
commonResult := &CommonResultError{}
if err1 := json.Unmarshal(body, commonResult); err1 != nil {
logger.Verboseln("parse openapi result json error ", err1)
return nil, err1
}
if commonResult.Code != 0 {
return nil, fmt.Errorf(commonResult.Msg)
}
errResp := &LoginHttpResult{}
if err1 := json.Unmarshal(body, errResp); err1 != nil {
logger.Verboseln("parse result json error ", err1)
+68 -17
View File
@@ -16,13 +16,18 @@ package panupload
import (
"context"
"encoding/xml"
"errors"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/tickstep/library-go/logger"
"github.com/tickstep/library-go/requester"
"io"
"net/http"
"strconv"
"time"
"github.com/tickstep/aliyunpan-api/aliyunpan"
"github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
@@ -72,6 +77,11 @@ func (pu *PanUpload) Precreate() (err error) {
}
func (pu *PanUpload) UploadFile(ctx context.Context, partseq int, partOffset int64, partEnd int64, r rio.ReaderLen64, uploadClient *requester.HTTPClient) (uploadDone bool, uperr error) {
if len(pu.uploadOpEntity.PartInfoList) == 0 {
logger.Verbosef("网盘上传参数错误,分片%d => uploadOpEntity.PartInfoList为空\n", partseq+1)
return true, nil
}
pu.lazyInit()
// check url expired or not
@@ -102,6 +112,7 @@ func (pu *PanUpload) UploadFile(ctx context.Context, partseq int, partOffset int
}
var respErr *uploader.MultiError
// 分片上传回调函数,供阿里云盘API接口使用
uploadFunc := func(httpMethod, fullUrl string, headers map[string]string) (*http.Response, error) {
var resp *http.Response
var respError error = nil
@@ -116,7 +127,7 @@ func (pu *PanUpload) UploadFile(ctx context.Context, partseq int, partOffset int
}
resp, err = uploadClient.Req(httpMethod, fullUrl, r, headers)
if err != nil {
logger.Verbosef("分片上传出错: 分片%d => %s\n", partseq, err)
logger.Verbosef("分片上传出错: 分片%d => %s\n", partseq+1, err)
}
if resp != nil {
@@ -124,17 +135,25 @@ func (pu *PanUpload) UploadFile(ctx context.Context, partseq int, partOffset int
if blen > 0 {
buf := make([]byte, blen)
resp.Body.Read(buf)
logger.Verbosef("分片上传出错: 分片%d => %s\n", partseq, string(buf))
logger.Verbosef("分片上传出错: 分片%d, 错误信息: %s\n", partseq+1, string(buf))
errResp := &apierror.ErrorXmlResp{}
if err := xml.Unmarshal(buf, errResp); err == nil {
if errXml := xml.Unmarshal(buf, errResp); errXml == nil {
if errResp.Code != "" {
if "PartNotSequential" == errResp.Code || "NoSuchUpload" == errResp.Code {
if "PartNotSequential" == errResp.Code {
respError = uploader.UploadPartNotSeq
respErr = &uploader.MultiError{
Err: uploader.UploadPartNotSeq,
Terminated: false,
NeedStartOver: true,
NeedStartOver: false,
}
return resp, respError
} else if "NoSuchUpload" == errResp.Code {
respError = uploader.UploadNoSuchUpload
respErr = &uploader.MultiError{
Err: uploader.UploadNoSuchUpload,
Terminated: true,
NeedStartOver: false,
}
return resp, respError
} else if "AccessDenied" == errResp.Code && "Request has expired." == errResp.Message {
@@ -153,10 +172,18 @@ func (pu *PanUpload) UploadFile(ctx context.Context, partseq int, partOffset int
return resp, respError
}
}
} else {
respError = uploader.UploadHttpError
respErr = &uploader.MultiError{
Err: uploader.UploadHttpError,
Terminated: false,
NeedStartOver: false,
}
return resp, respError
}
}
} else {
logger.Verbosef("分片上传出错: %d分片 => 原因未知\n", partseq)
logger.Verbosef("分片上传出错: %d分片 => 原因未知\n", partseq+1)
}
// 不可恢复的错误
@@ -168,10 +195,31 @@ func (pu *PanUpload) UploadFile(ctx context.Context, partseq int, partOffset int
}
}
} else {
// 默认错误
respError = uploader.UploadTerminate
respErr = &uploader.MultiError{
Terminated: true,
}
// 解析HTTP请求出现的错误信息
if err != nil {
var netErr *url.Error
if errors.As(err, &netErr) {
var pathErr *os.PathError
if errors.As(netErr.Err, &pathErr) { // 本地文件读取异常
if strings.Contains(pathErr.Err.Error(), "file already closed") {
// 本地文件流触发"file already closed"错误
// 一般是文件上传过程中,文件被删除了,或者文件所在的硬盘异常导致文件流被系统关闭
respError = uploader.UploadLocalFileAlreadyClosedError
respErr = &uploader.MultiError{
Terminated: true,
NeedStartOver: false,
Err: uploader.UploadLocalFileAlreadyClosedError,
}
}
}
}
}
}
return resp, respError
}
@@ -181,13 +229,13 @@ func (pu *PanUpload) UploadFile(ctx context.Context, partseq int, partOffset int
apiError := pu.panClient.OpenapiPanClient().UploadFileData(uploadUrl, uploadFunc)
if respErr != nil {
if respErr.Err == uploader.UploadUrlExpired {
if errors.Is(respErr.Err, uploader.UploadUrlExpired) {
// URL过期,获取新的URL
guur, er := pu.panClient.OpenapiPanClient().GetUploadUrl(&aliyunpan.GetUploadUrlParam{
DriveId: pu.driveId,
FileId: pu.uploadOpEntity.FileId,
UploadId: pu.uploadOpEntity.UploadId,
PartInfoList: []aliyunpan.FileUploadPartInfoParam{{PartNumber: (partseq + 1)}}, // 阿里云盘partNum从1开始计数,partSeq从0开始
PartInfoList: []aliyunpan.FileUploadPartInfoParam{{PartNumber: partseq + 1}}, // 阿里云盘partNum从1开始计数,partSeq从0开始
})
if er != nil {
return false, &uploader.MultiError{
@@ -197,15 +245,18 @@ func (pu *PanUpload) UploadFile(ctx context.Context, partseq int, partOffset int
// 获取新的上传URL重试一次
pu.uploadOpEntity.PartInfoList[partseq] = guur.PartInfoList[0]
uploadUrl := pu.uploadOpEntity.PartInfoList[partseq].UploadURL
uploadUrl = pu.uploadOpEntity.PartInfoList[partseq].UploadURL
apiError = pu.panClient.OpenapiPanClient().UploadFileData(uploadUrl, uploadFunc)
} else if respErr.Err == uploader.UploadPartAlreadyExist {
} else if errors.Is(respErr.Err, uploader.UploadPartAlreadyExist) {
// already upload
// success
return true, nil
} else if respErr.Err == uploader.UploadPartNotSeq {
// 上传分片乱序了,需要重新从0分片开始上传
// 先直接返回,后续再优化
} else if errors.Is(respErr.Err, uploader.UploadPartNotSeq) {
// 上传分片乱序了
// 返回错误,然后重传
return false, respErr
} else if errors.Is(respErr.Err, uploader.UploadLocalFileAlreadyClosedError) {
// 本地文件读取错误
return false, respErr
} else {
return false, respErr
+60 -10
View File
@@ -4,7 +4,7 @@
// 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
// 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,
@@ -15,6 +15,8 @@ package panupload
import (
"errors"
"github.com/tickstep/library-go/logger"
"io"
"os"
"path/filepath"
"strings"
@@ -47,6 +49,7 @@ type (
func NewUploadingDatabase() (ud *UploadingDatabase, err error) {
file, err := os.OpenFile(filepath.Join(config.GetConfigDir(), UploadingFileName), os.O_CREATE|os.O_RDWR, 0777)
if err != nil {
// 打开文件错误,一般是文件权限问题
return nil, err
}
@@ -64,7 +67,20 @@ func NewUploadingDatabase() (ud *UploadingDatabase, err error) {
err = jsonhelper.UnmarshalData(file, ud)
if err != nil {
return nil, err
// 上传数据库文件内容解析错误
// 尝试从备份的文件读取数据
bakFile, err1 := os.OpenFile(filepath.Join(config.GetConfigDir(), UploadingBackupFileName), os.O_CREATE|os.O_RDONLY, 0777)
if err1 != nil {
return nil, err
}
err2 := jsonhelper.UnmarshalData(bakFile, ud)
if err2 != nil {
return nil, err
}
bakFile.Close()
// 旧的备份文件可以正常使用,复制备份的数据文件到当前数据文件中
ud.copyFile(filepath.Join(config.GetConfigDir(), UploadingFileName), filepath.Join(config.GetConfigDir(), UploadingBackupFileName))
return ud, nil
}
return ud, nil
@@ -86,11 +102,23 @@ func (ud *UploadingDatabase) Save() error {
panic(err)
}
// 备份旧的数据库文件
// 因为下面有文件内容清空、写入新内容的操作。有小概率出现文件保存没有完成程序就退出的问题,这会导致数据库内容丢失。所以这里必须备份一下旧文件
err1 := ud.copyFile(filepath.Join(config.GetConfigDir(), UploadingBackupFileName), filepath.Join(config.GetConfigDir(), UploadingFileName))
if err1 != nil {
logger.Verboseln("备份上传数据库文件出错: {}", err1)
} else {
logger.Verboseln("成功备份旧的上传数据库文件")
}
logger.Verboseln("保存最新上传数据库内容")
// 清空文件旧内容
err = ud.dataFile.Truncate(int64(builder.Len()))
if err != nil {
return err
}
// 写入新内容
str := builder.String()
_, err = ud.dataFile.WriteAt(converter.ToBytes(str), 0)
if err != nil {
@@ -100,6 +128,26 @@ func (ud *UploadingDatabase) Save() error {
return nil
}
// copyFile 复制文件
func (ud *UploadingDatabase) copyFile(dstFilePath string, srcFilePath string) error {
os.Remove(dstFilePath)
dstFile, err1 := os.OpenFile(dstFilePath, os.O_CREATE|os.O_WRONLY, 0777)
if err1 != nil {
return err1
}
srcFile, err2 := os.OpenFile(srcFilePath, os.O_CREATE|os.O_RDONLY, 0777)
if err2 != nil {
return err2
}
_, err3 := io.Copy(dstFile, srcFile)
if err3 != nil {
return err3
}
dstFile.Close()
srcFile.Close()
return nil
}
// UpdateUploading 更新正在上传
func (ud *UploadingDatabase) UpdateUploading(meta *localfile.LocalFileMeta, state *uploader.InstanceState) {
if meta == nil {
@@ -158,18 +206,20 @@ func (ud *UploadingDatabase) Search(meta *localfile.LocalFileMeta) *uploader.Ins
if uploading.LocalFileMeta == nil {
continue
}
if uploading.LocalFileMeta.EqualLengthSHA1(meta) {
return uploading.State
}
if uploading.LocalFileMeta.Path.LogicPath == meta.Path.LogicPath {
// 移除旧的信息
// 目前只是比较了文件大小
if meta.Length != uploading.LocalFileMeta.Length {
// 优选通过SHA1进行匹配,如果匹配失败则通过文件路径进行匹配
// 因为部分上传文件是直接上传的,没有计算SHA1
if uploading.LocalFileMeta.EqualLengthSHA1(meta) ||
uploading.LocalFileMeta.Path.LogicPath == meta.Path.LogicPath {
// 文件大小或者修改日期不一致,代表本地文件已经更改了,保存的旧的上传文件信息已经无用
// 移除旧的信息,客户端需要重新上传该文件
if meta.Length != uploading.LocalFileMeta.Length ||
meta.ModTime != uploading.LocalFileMeta.ModTime {
logger.Verboseln("本地文件已经被修改,上传数据记录需要移除,文件需要重新从0开始上传: {}", meta.Path.LogicPath)
ud.Delete(meta)
return nil
}
// 覆盖数据
// 从上传数据库补全信息并返回
meta.SHA1 = uploading.LocalFileMeta.SHA1
meta.ParentFolderId = uploading.LocalFileMeta.ParentFolderId
meta.UploadOpEntity = uploading.LocalFileMeta.UploadOpEntity
+193 -33
View File
@@ -14,10 +14,8 @@
package panupload
import (
"errors"
"fmt"
"github.com/tickstep/aliyunpan/internal/log"
"github.com/tickstep/aliyunpan/internal/plugins"
"github.com/tickstep/library-go/logger"
"os"
"path"
"path/filepath"
@@ -25,6 +23,11 @@ import (
"sync"
"time"
"github.com/tickstep/aliyunpan/internal/log"
"github.com/tickstep/aliyunpan/internal/plugins"
"github.com/tickstep/aliyunpan/internal/ui"
"github.com/tickstep/library-go/logger"
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/library-go/requester/rio/speeds"
@@ -78,6 +81,8 @@ type (
// 上传文件记录器
FileRecorder *log.FileRecorder
UI *ui.DashboardPanel // 上传统计面板UI
}
)
@@ -96,6 +101,26 @@ const (
StrUploadFailed = "上传文件失败"
)
// logf 打印文本日志
func (utu *UploadTaskUnit) logf(format string, a ...interface{}) {
if utu.UI != nil {
// 使用UI面板显示日志
utu.UI.Logf(format, a...)
return
}
// 使用传统单行文本显示下载日志,直接输出到控制台
fmt.Printf(format, a...)
}
// updateUITaskState 更新UI面板的任务状态
func (utu *UploadTaskUnit) updateUITaskState(state ui.TaskState, message string) {
if utu.UI == nil {
return
}
utu.UI.MarkTaskState(utu.taskInfo.Id(), state, message)
}
func (utu *UploadTaskUnit) SetTaskInfo(taskInfo *taskframework.TaskInfo) {
utu.taskInfo = taskInfo
}
@@ -126,7 +151,7 @@ func (utu *UploadTaskUnit) prepareFile() {
}
//if utu.LocalFileChecksum.Length > MaxRapidUploadSize {
// fmt.Printf("[%s] 文件超过20GB, 无法使用秒传功能, 跳过秒传...\n", utu.taskInfo.Id())
// utu.logf("[%s] 文件超过20GB, 无法使用秒传功能, 跳过秒传...\n", utu.taskInfo.Id())
// utu.Step = StepUploadUpload
// return
//}
@@ -141,13 +166,13 @@ func (utu *UploadTaskUnit) rapidUpload() (isContinue bool, result *taskframework
// 是否可以秒传
result = &taskframework.TaskUnitRunResult{}
fmt.Printf("[%s] %s 检测秒传中, 请稍候...\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"))
utu.logf("[%s] %s 检测秒传中, 请稍候...\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"))
if utu.LocalFileChecksum.UploadOpEntity.RapidUpload {
fmt.Printf("[%s] %s 秒传成功, 保存到网盘路径: %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), utu.SavePath)
utu.logf("[%s] %s 秒传成功, 保存到网盘路径: %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), utu.SavePath)
result.Succeed = true
return false, result
} else {
fmt.Printf("[%s] %s 秒传失败,开始正常上传文件\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"))
utu.logf("[%s] %s 秒传失败,开始正常上传文件\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"))
result.Succeed = false
result.ResultMessage = "文件未曾上传,无法秒传"
return true, result
@@ -167,7 +192,14 @@ func (utu *UploadTaskUnit) upload() (result *taskframework.TaskUnitRunResult) {
Parallel: utu.Parallel,
BlockSize: utu.BlockSize,
MaxRate: config.Config.MaxUploadRate,
}, utu.LocalFileChecksum.UploadOpEntity, utu.GlobalSpeedsStat)
}, utu.LocalFileChecksum.UploadOpEntity, utu.PanClient, utu.GlobalSpeedsStat)
// 模拟 file already closed 错误
//go func(f *os.File) {
// time.Sleep(10 * time.Second)
// f.Close()
// utu.logf("关闭文件流: %s\n", f.Name())
//}(utu.LocalFileChecksum.GetFile())
// 设置断点续传
if utu.state != nil {
@@ -182,15 +214,35 @@ func (utu *UploadTaskUnit) upload() (result *taskframework.TaskUnitRunResult) {
default:
}
// 方式1:使用UI统计面板显示进度
if utu.UI != nil {
// 更新UI面板任务状态
utu.UI.UpdateTaskProgress(utu.taskInfo.Id(), status.Uploaded(), status.TotalSize(), status.SpeedsPerSecond(), status.TimeLeft())
return
}
// 方式2:使用传统单行显示下载进度
if utu.ShowProgress {
uploadedPercentage := fmt.Sprintf("%.2f%%", float64(status.Uploaded())/float64(status.TotalSize())*100)
fmt.Printf("\r[%s] ↑ %s/%s(%s) %s/s(%s/s) in %s ............", utu.taskInfo.Id(),
// 如果上传速度为0, 剩余时间未知, 则用 - 代替
var leftStr string
left := status.TimeLeft()
if left < 0 {
leftStr = "-"
} else {
leftStr = left.String()
}
uploadedPercentage := "0.00%"
if status.TotalSize() > 0 {
uploadedPercentage = fmt.Sprintf("%.2f%%", float64(status.Uploaded())/float64(status.TotalSize())*100)
}
utu.logf("\r[%s] ↑ %s/%s(%s) %s/s(%s/s) in %s, left %s ............", utu.taskInfo.Id(),
converter.ConvertFileSize(status.Uploaded(), 2),
converter.ConvertFileSize(status.TotalSize(), 2),
uploadedPercentage,
converter.ConvertFileSize(status.SpeedsPerSecond(), 2),
converter.ConvertFileSize(utu.GlobalSpeedsStat.GetSpeeds(), 2),
status.TimeElapsed(),
leftStr,
)
}
})
@@ -198,8 +250,8 @@ func (utu *UploadTaskUnit) upload() (result *taskframework.TaskUnitRunResult) {
// result
result = &taskframework.TaskUnitRunResult{}
muer.OnSuccess(func() {
fmt.Printf("\n")
fmt.Printf("[%s] %s 上传文件成功, 保存到网盘路径: %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), utu.SavePath)
utu.logf("\n")
utu.logf("[%s] %s 上传文件成功, 保存到网盘路径: %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), utu.SavePath)
// 统计
utu.UploadStatistic.AddTotalSize(utu.LocalFileChecksum.Length)
utu.UploadingDatabase.Delete(&utu.LocalFileChecksum.LocalFileMeta) // 删除
@@ -231,6 +283,10 @@ func (utu *UploadTaskUnit) upload() (result *taskframework.TaskUnitRunResult) {
if er != nil {
result.ResultMessage = StrUploadFailed
result.NeedRetry = true
if errors.Is(er, uploader.UploadNoSuchUpload) {
// do not need retry
result.NeedRetry = false
}
result.Err = er
}
return
@@ -240,16 +296,19 @@ func (utu *UploadTaskUnit) OnRetry(lastRunResult *taskframework.TaskUnitRunResul
// 输出错误信息
if lastRunResult.Err == nil {
// result中不包含Err, 忽略输出
fmt.Printf("[%s] %s, 重试 %d/%d\n", utu.taskInfo.Id(), lastRunResult.ResultMessage, utu.taskInfo.Retry(), utu.taskInfo.MaxRetry())
utu.logf("[%s] %s, 重试 %d/%d\n", utu.taskInfo.Id(), lastRunResult.ResultMessage, utu.taskInfo.Retry(), utu.taskInfo.MaxRetry())
return
}
fmt.Printf("[%s] %s, %s, 重试 %d/%d\n", utu.taskInfo.Id(), lastRunResult.ResultMessage, lastRunResult.Err, utu.taskInfo.Retry(), utu.taskInfo.MaxRetry())
utu.logf("[%s] %s, %s, 重试 %d/%d\n", utu.taskInfo.Id(), lastRunResult.ResultMessage, lastRunResult.Err, utu.taskInfo.Retry(), utu.taskInfo.MaxRetry())
}
func (utu *UploadTaskUnit) OnSuccess(lastRunResult *taskframework.TaskUnitRunResult) {
// 执行插件
utu.pluginCallback("success")
// 更新UI面板状态
utu.updateUITaskState(ui.TaskSuccess, "")
// 上传文件数据记录
if config.Config.FileRecordConfig == "1" {
utu.FileRecorder.Append(&log.FileRecordItem{
@@ -264,6 +323,7 @@ func (utu *UploadTaskUnit) OnSuccess(lastRunResult *taskframework.TaskUnitRunRes
func (utu *UploadTaskUnit) OnFailed(lastRunResult *taskframework.TaskUnitRunResult) {
// 失败
utu.pluginCallback("fail")
utu.updateUITaskState(ui.TaskCanceled, "取消: "+utu.LocalFileChecksum.Path.LogicPath)
}
func (utu *UploadTaskUnit) pluginCallback(result string) {
@@ -295,7 +355,12 @@ func (utu *UploadTaskUnit) OnComplete(lastRunResult *taskframework.TaskUnitRunRe
// 任务结束,可能成功也可能失败
}
func (utu *UploadTaskUnit) OnCancel(lastRunResult *taskframework.TaskUnitRunResult) {
// 更新UI面板
failedMessage := lastRunResult.ResultMessage
if lastRunResult.Err != nil {
failedMessage = fmt.Sprintf("%s, %s", lastRunResult.ResultMessage, lastRunResult.Err)
}
utu.updateUITaskState(ui.TaskCanceled, failedMessage)
}
func (utu *UploadTaskUnit) RetryWait() time.Duration {
return functions.RetryWait(utu.taskInfo.Retry())
@@ -304,7 +369,7 @@ func (utu *UploadTaskUnit) RetryWait() time.Duration {
func (utu *UploadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) {
err := utu.LocalFileChecksum.OpenPath()
if err != nil {
fmt.Printf("[%s] 文件不可读, 错误信息: %s, 跳过...\n", utu.taskInfo.Id(), err)
utu.logf("[%s] 文件不可读, 错误信息: %s, 跳过...\n", utu.taskInfo.Id(), err)
return
}
defer utu.LocalFileChecksum.Close() // 关闭文件
@@ -312,7 +377,8 @@ func (utu *UploadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) {
timeStart := time.Now()
result = &taskframework.TaskUnitRunResult{}
fmt.Printf("[%s] %s 准备上传: %s => %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), utu.LocalFileChecksum.Path.LogicPath, utu.SavePath)
utu.logf("[%s] %s 准备上传: %s => %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), utu.LocalFileChecksum.Path.LogicPath, utu.SavePath)
utu.updateUITaskState(ui.TaskRunning, "")
defer func() {
var msg string
@@ -323,7 +389,7 @@ func (utu *UploadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) {
} else {
msg = result.ResultMessage
}
fmt.Printf("[%s] %s 文件上传结果: %s 耗时 %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), msg, utils.ConvertTime(time.Now().Sub(timeStart)))
utu.logf("[%s] %s 文件上传结果: %s 耗时 %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), msg, utils.ConvertTime(time.Now().Sub(timeStart)))
}()
// 准备文件
@@ -359,7 +425,7 @@ StepUploadPrepareUpload:
saveFilePath = path.Dir(utu.SavePath)
if saveFilePath != "/" {
utu.FolderCreateMutex.Lock()
fmt.Printf("[%s] %s 正在检测和创建云盘文件夹: %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), saveFilePath)
utu.logf("[%s] %s 正在检测和创建云盘文件夹: %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), saveFilePath)
fe, apierr1 := utu.PanClient.OpenapiPanClient().FileInfoByPath(utu.DriveId, saveFilePath)
time.Sleep(1 * time.Second)
needToCreateFolder := false
@@ -408,7 +474,8 @@ StepUploadPrepareUpload:
if efi != nil && efi.FileId != "" {
result.Succeed = true
result.Extra = efi
fmt.Printf("[%s] %s 检测到同名文件,跳过上传: %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), utu.SavePath)
utu.logf("[%s] %s 检测到同名文件,跳过上传: %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), utu.SavePath)
utu.updateUITaskState(ui.TaskSkipped, "")
return
}
}
@@ -431,9 +498,9 @@ StepUploadPrepareUpload:
}
}
if preHashMatch {
if preHashMatch { // preHashMatch为true,代表该文件可能已经被上传过,能够支持秒传,所以需要进一步计算完整SHA1进行检测是否能秒传
// 计算完整文件SHA1
fmt.Printf("[%s] %s 正在计算文件SHA1: %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), utu.LocalFileChecksum.Path.LogicPath)
utu.logf("[%s] %s 正在计算文件SHA1: %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), utu.LocalFileChecksum.Path.LogicPath)
utu.LocalFileChecksum.Sum(localfile.CHECKSUM_SHA1)
sha1Str = utu.LocalFileChecksum.SHA1
if utu.LocalFileChecksum.Length == 0 {
@@ -453,7 +520,7 @@ StepUploadPrepareUpload:
checkNameMode = "auto_rename"
}
} else {
fmt.Printf("[%s] %s 已经禁用秒传检测,直接上传\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"))
utu.logf("[%s] %s 已经禁用秒传检测,直接上传\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"))
sha1Str = ""
contentHashName = ""
checkNameMode = "auto_rename"
@@ -466,7 +533,7 @@ StepUploadPrepareUpload:
if strings.ToUpper(efi.ContentHash) == strings.ToUpper(sha1Str) {
result.Succeed = true
result.Extra = efi
fmt.Printf("[%s] %s 检测到同名文件,文件内容完全一致,无需重复上传: %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), utu.SavePath)
utu.logf("[%s] %s 检测到同名文件,文件内容完全一致,无需重复上传: %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), utu.SavePath)
return
}
// existed, delete it
@@ -479,7 +546,7 @@ StepUploadPrepareUpload:
return
}
time.Sleep(time.Duration(500) * time.Millisecond)
fmt.Printf("[%s] %s 检测到同名文件,文件内容不一致,已将旧文件移动到回收站: %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), utu.SavePath)
utu.logf("[%s] %s 检测到同名文件,文件内容不一致,已将旧文件移动到回收站: %s\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"), utu.SavePath)
}
}
@@ -504,16 +571,20 @@ StepUploadPrepareUpload:
ContentHashName: contentHashName,
ProofCode: proofCode,
ProofVersion: "v1",
LocalCreatedAt: utils.UnixTime2LocalFormatStr(utu.LocalFileChecksum.ModTime),
LocalModifiedAt: utils.UnixTime2LocalFormatStr(utu.LocalFileChecksum.ModTime),
}
} else {
// 不支持秒传,不计算SHA1,直接上传文件
appCreateUploadFileParam = &aliyunpan.CreateFileUploadParam{
DriveId: utu.DriveId,
Name: filepath.Base(utu.SavePath),
Size: utu.LocalFileChecksum.Length,
CheckNameMode: checkNameMode,
ParentFileId: rs.FileId,
BlockSize: utu.BlockSize,
DriveId: utu.DriveId,
Name: filepath.Base(utu.SavePath),
Size: utu.LocalFileChecksum.Length,
CheckNameMode: checkNameMode,
ParentFileId: rs.FileId,
BlockSize: utu.BlockSize,
LocalCreatedAt: utils.UnixTime2LocalFormatStr(utu.LocalFileChecksum.ModTime),
LocalModifiedAt: utils.UnixTime2LocalFormatStr(utu.LocalFileChecksum.ModTime),
}
}
@@ -525,6 +596,8 @@ StepUploadPrepareUpload:
logger.Verboseln("create upload file error: " + result.ResultMessage)
// 重试
result.NeedRetry = true
} else if apierr.Code == apierror.ApiCodeUploadPayloadTooLarge {
utu.logf("[%s] %s 上传文件的大小超出限制,你可能需要开通阿里云盘三方权益包以便上传大文件\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"))
}
return
}
@@ -546,14 +619,101 @@ stepUploadUpload:
// 正常上传流程
uploadResult := utu.upload()
if uploadResult != nil && uploadResult.Err != nil {
if uploadResult.Err == uploader.UploadPartNotSeq {
fmt.Printf("[%s] %s 文件分片上传顺序错误,开始重新上传文件\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"))
// 处理上传错误
if errors.Is(uploadResult.Err, uploader.UploadPartNotSeq) {
// 分片乱序错误
if ee := utu.amendFileUploadPartNum(); ee != nil {
// 修正分片乱序失败,先令上传任务直接失败
logger.Verboseln("WARNING! amend uploaded parts num failed")
utu.logf("[%s] %s 无法修正上传分片乱序的错误,建议重新上传\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"))
uploadResult = &taskframework.TaskUnitRunResult{
Succeed: false,
NeedRetry: false,
Cancel: false,
Err: ee,
ResultCode: 0,
ResultMessage: "",
Extra: nil,
}
return uploadResult
}
goto stepUploadUpload
}
if errors.Is(uploadResult.Err, uploader.UploadNoSuchUpload) {
// 上传任务过期
utu.logf("[%s] %s 网盘上传任务不存在,创建新任务重新上传文件\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"))
// 需要重新从0开始上传
uploadResult = nil
utu.LocalFileChecksum.UploadOpEntity = nil
utu.state = nil
goto StepUploadPrepareUpload
}
if errors.Is(uploadResult.Err, uploader.UploadLocalFileAlreadyClosedError) {
// 本地文件读取错误
utu.logf("[%s] %s 本地文件读取错误,建议检查文件是否存在,是否有文件读取权限,或者硬盘是否有异常\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"))
uploadResult = &taskframework.TaskUnitRunResult{
Succeed: false,
NeedRetry: false,
Cancel: false,
Err: uploadResult.Err,
ResultCode: 0,
ResultMessage: "",
Extra: nil,
}
return uploadResult
}
// 处理阿里云盘API接口错误
var apier *apierror.ApiError
if errors.As(uploadResult.Err, &apier) {
// 上传任务过期
if apier.Code == apierror.ApiCodeUploadIdNotFound {
utu.logf("[%s] %s 网盘上传任务已失效,创建新任务重新上传文件\n", utu.taskInfo.Id(), time.Now().Format("2006-01-02 15:04:06"))
uploadResult = nil
utu.LocalFileChecksum.UploadOpEntity = nil
utu.state = nil
goto StepUploadPrepareUpload
}
}
}
return uploadResult
}
// amendFileUploadPartNum 修正文件分片上传顺序错误
func (utu *UploadTaskUnit) amendFileUploadPartNum() error {
if utu.LocalFileChecksum.LocalFileMeta.UploadOpEntity == nil || utu.state == nil {
return nil
}
logger.Verbosef("adjust the uploaded parts num error\n")
// 分片出现乱序
// 获取的已上传分片信息,修正正确的分片顺序
uploadedParts, uper := utu.PanClient.OpenapiPanClient().GetUploadedPartInfoAllItem(&aliyunpan.GetUploadedPartsParam{
DriveId: utu.LocalFileChecksum.LocalFileMeta.UploadOpEntity.DriveId,
FileId: utu.LocalFileChecksum.LocalFileMeta.UploadOpEntity.FileId,
UploadId: utu.LocalFileChecksum.LocalFileMeta.UploadOpEntity.UploadId,
})
if uper != nil {
logger.Verbosef("get uploaded parts info error: %+v\n", uper)
return uper
}
// 获取最后上传的分片编号
lastUploadedPartNum := -1
if len(uploadedParts.UploadedParts) > 0 {
lastUploadedPartNum = uploadedParts.UploadedParts[len(uploadedParts.UploadedParts)-1].PartNumber
} else {
logger.Verbosef("get uploaded parts list is empty\n")
return errors.New("uploaded parts list is empty")
}
// 修正分片上传的标识
if lastUploadedPartNum > 0 {
logger.Verbosef("get the right uploaded parts num: %d\n", lastUploadedPartNum)
for _, w := range utu.state.BlockList {
if (w.ID + 1) <= lastUploadedPartNum { // 分片的编号从1开始,BlockList的id是从0开始
w.UploadDone = true
} else {
w.UploadDone = false
}
}
}
return nil
}
+12 -9
View File
@@ -16,15 +16,15 @@ package panupload
import (
"crypto/sha1"
"encoding/hex"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/tickstep/library-go/converter"
"github.com/tickstep/library-go/logger"
"net/url"
"os"
"path"
"strconv"
"strings"
"time"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/library-go/converter"
"github.com/tickstep/library-go/logger"
)
const (
@@ -35,7 +35,10 @@ const (
// MaxRapidUploadSize 秒传文件支持的最大文件大小
MaxRapidUploadSize = 20 * converter.GB
// UploadingFileName 上传文件上传状态的文件名
UploadingFileName = "aliyunpan_uploading.json"
// UploadingBackupFileName 上传文件上传状态的副本
UploadingBackupFileName = "aliyunpan_uploading.json.bak"
)
var (
@@ -52,16 +55,16 @@ func getBlockSize(fileSize int64) int64 {
// IsUrlExpired 上传链接是否已过期。过期返回True
func IsUrlExpired(urlStr string) bool {
u, err := url.Parse(urlStr)
expiredTimeSec, err := utils.GetExpiredTimeSecFromOSSUrl(urlStr)
if err != nil {
// 解析错误,默认过期
return true
}
expiredTimeSecStr := u.Query().Get("x-oss-expires")
expiredTimeSec, _ := strconv.ParseInt(expiredTimeSecStr, 10, 64)
if (expiredTimeSec - time.Now().Unix()) <= 300 { // 小于5分钟
// expired
return true
}
// 返回未过期
return false
}
@@ -84,7 +87,7 @@ func IsVideoFile(fileName string) bool {
// CalcFilePreHash 计算文件 PreHash
func CalcFilePreHash(filePath string) string {
localFile, _ := os.Open(filePath)
localFile, _ := os.OpenFile(filePath, os.O_RDONLY, 0)
defer localFile.Close()
bytes := make([]byte, 1024)
localFile.ReadAt(bytes, 0)
+12
View File
@@ -0,0 +1,12 @@
package global
type (
FileSourceType string
)
const (
// FileSource 文件,包括:资源盘文件、备份盘文件
FileSource FileSourceType = "file"
// AlbumSource 相册
AlbumSource FileSourceType = "album"
)
+3
View File
@@ -6,4 +6,7 @@ var (
// IsAppInCliMode 是否在交互模式
IsAppInCliMode = false
// IsSupportNoneOpenApiCommands 是否开启非OpenAPI的命令
IsSupportNoneOpenApiCommands = false
)
+49 -51
View File
@@ -17,6 +17,16 @@ import (
"archive/zip"
"bytes"
"fmt"
"io/ioutil"
"net/http"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"time"
jsoniter "github.com/json-iterator/go"
"github.com/tickstep/aliyunpan/cmder/cmdliner"
"github.com/tickstep/aliyunpan/cmder/cmdutil"
@@ -27,19 +37,9 @@ import (
"github.com/tickstep/library-go/cachepool"
"github.com/tickstep/library-go/checkaccess"
"github.com/tickstep/library-go/converter"
"github.com/tickstep/library-go/getip"
"github.com/tickstep/library-go/jsonhelper"
"github.com/tickstep/library-go/logger"
"github.com/tickstep/library-go/requester"
"io/ioutil"
"net/http"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
const (
@@ -58,16 +58,16 @@ type tsResp struct {
Msg string `json:"msg"`
}
func getReleaseFromTicstep(client *requester.HTTPClient, showPrompt bool) *ReleaseInfo {
func getReleaseFromTickstep(client *requester.HTTPClient, showPrompt bool) *ReleaseInfo {
tsReleaseInfo := &ReleaseInfo{}
tsRespObj := &tsResp{Data: tsReleaseInfo}
fullUrl := strings.Builder{}
ipAddr, err := getip.IPInfoFromTechainBaidu()
ipAddr, err := utils.GetPublicIP()
if err != nil {
ipAddr = "127.0.0.1"
}
fmt.Fprintf(&fullUrl, "http://api.tickstep.com/update/tickstep/aliyunpan/releases/latest?ip=%s&os=%s&arch=%s&version=%s",
ipAddr, runtime.GOOS, runtime.GOARCH, global.AppVersion)
fmt.Fprintf(&fullUrl, "http://api.tickstep.com/update/tickstep/aliyunpan/releases/latest?ip=%s&os=%s&arch=%s&version=%s", ipAddr, runtime.GOOS, runtime.GOARCH, global.AppVersion)
//fmt.Fprintf(&fullUrl, "http://localhost:8997/update/tickstep/aliyunpan/releases/latest?ip=%s&os=%s&arch=%s&version=%s", ipAddr, runtime.GOOS, runtime.GOARCH, global.AppVersion)
resp, err := client.Req(http.MethodGet, fullUrl.String(), nil, nil)
if resp != nil {
defer resp.Body.Close()
@@ -136,19 +136,12 @@ func GetLatestReleaseInfo(showPrompt bool) *ReleaseInfo {
client.SetTimeout(time.Duration(0) * time.Second)
client.SetKeepAlive(true)
// check tickstep srv
var tsReleaseInfo *ReleaseInfo = nil
for idx := 0; idx < 3; idx++ {
tsReleaseInfo = getReleaseFromTicstep(client, showPrompt)
if tsReleaseInfo != nil {
break
}
time.Sleep(time.Duration(5) * time.Second)
}
// tickstep server
var tsReleaseInfo = getReleaseFromTickstep(client, showPrompt)
// github
// github server
var ghReleaseInfo *ReleaseInfo = nil
for idx := 0; idx < 3; idx++ {
for idx := 0; idx < 2; idx++ {
ghReleaseInfo = getReleaseFromGithub(client, showPrompt)
if ghReleaseInfo != nil {
break
@@ -158,10 +151,11 @@ func GetLatestReleaseInfo(showPrompt bool) *ReleaseInfo {
var releaseInfo *ReleaseInfo = nil
if config.Config.UpdateCheckInfo.PreferUpdateSrv == "tickstep" {
// theoretically, tickstep server will be more faster at mainland
// theoretically, tickstep server will be faster in China mainland
releaseInfo = tsReleaseInfo
} else {
releaseInfo = ghReleaseInfo
// 如果github的版本信息获取不到(例如网络不稳定),可以尝试使用tickstep服务进行升级
if ghReleaseInfo == nil || ghReleaseInfo.TagName == "" {
releaseInfo = tsReleaseInfo
}
@@ -212,29 +206,25 @@ func CheckUpdate(version string, yes bool) {
builder := &strings.Builder{}
builder.WriteString(ReleaseName + "-" + releaseInfo.TagName + "-" + runtime.GOOS + "-.*?")
if runtime.GOOS == "darwin" && (runtime.GOARCH == "arm" || runtime.GOARCH == "arm64") {
builder.WriteString("arm")
} else {
switch runtime.GOARCH {
case "amd64":
builder.WriteString("(amd64|x86_64|x64)")
case "386":
builder.WriteString("(386|x86)")
case "arm":
builder.WriteString("(armv5|armv7|arm)")
case "arm64":
builder.WriteString("arm64")
case "mips":
builder.WriteString("mips")
case "mips64":
builder.WriteString("mips64")
case "mipsle":
builder.WriteString("(mipsle|mipsel)")
case "mips64le":
builder.WriteString("(mips64le|mips64el)")
default:
builder.WriteString(runtime.GOARCH)
}
switch runtime.GOARCH {
case "amd64":
builder.WriteString("(amd64|x86_64|x64)")
case "386":
builder.WriteString("(386|x86)")
case "arm":
builder.WriteString("(armv5|armv7|arm)")
case "arm64":
builder.WriteString("arm64")
case "mips":
builder.WriteString("mips")
case "mips64":
builder.WriteString("mips64")
case "mipsle":
builder.WriteString("(mipsle|mipsel)")
case "mips64le":
builder.WriteString("(mips64le|mips64el)")
default:
builder.WriteString(runtime.GOARCH)
}
builder.WriteString("\\.zip")
@@ -385,7 +375,8 @@ func CheckUpdate(version string, yes bool) {
return
}
execPath := cmdutil.ExecutablePath()
//execPath := cmdutil.ExecutablePath()
configPath := configurationPath()
var fileNum, errTimes int
for _, zipFile := range reader.File {
@@ -409,9 +400,11 @@ func CheckUpdate(version string, yes bool) {
name := zipFile.Name[strings.Index(zipFile.Name, "/")+1:]
if name == ReleaseName {
// 执行文件
err = update(cmdutil.Executable(), rc)
} else {
err = update(filepath.Join(execPath, name), rc)
// 配置文件
err = update(filepath.Join(configPath, name), rc)
}
if err != nil {
@@ -428,3 +421,8 @@ func CheckUpdate(version string, yes bool) {
fmt.Printf("更新完毕, 请重启程序\n")
}
// ConfigurationPath 获取程序配置所在目录
func configurationPath() string {
return config.GetConfigDir()
}
+7
View File
@@ -44,10 +44,17 @@ func (p *IdlePlugin) SyncFileFinishCallback(context *Context, params *SyncFileFi
return nil
}
func (p *IdlePlugin) SyncAllFileFinishCallback(context *Context, params *SyncAllFileFinishParams) error {
return nil
}
func (p *IdlePlugin) UserTokenRefreshFinishCallback(context *Context, params *UserTokenRefreshFinishParams) error {
return nil
}
func (p *IdlePlugin) RemoveFilePrepareCallback(context *Context, params *RemoveFilePrepareParams) (*RemoveFilePrepareResult, error) {
return nil, nil
}
func (p *IdlePlugin) Stop() error {
return nil
}
+61 -2
View File
@@ -31,6 +31,15 @@ func jsLog(call goja.FunctionCall) goja.Value {
return str
}
// jsLog 支持js中的console.log方法
func jsPrintln(call goja.FunctionCall) goja.Value {
str := call.Argument(0)
buf := &strings.Builder{}
fmt.Fprintf(buf, "[PLUGIN] %+v\n", str.Export())
fmt.Println(buf.String())
return str
}
func (js *JsPlugin) Start() error {
js.Name = "JsPlugin"
js.vm = goja.New()
@@ -38,8 +47,9 @@ func (js *JsPlugin) Start() error {
// 内置log
console := js.vm.NewObject()
console.Set("log", jsLog)
js.vm.Set("console", console) // console.log()
js.vm.Set("console", console)
console.Set("log", jsLog) // console.log()
console.Set("println", jsPrintln) // console.println()
// 内置系统函数PluginUtil
pluginObj := js.vm.NewObject()
@@ -56,6 +66,11 @@ func (js *JsPlugin) Start() error {
pluginObj.Set("LocalFS", localFS)
localFS.Set("deleteFile", DeleteLocalFile) // PluginUtil.LocalFS.deleteFile()
// PluginUtil.PanFS
panFS := js.vm.NewObject()
pluginObj.Set("PanFS", panFS)
panFS.Set("deleteFile", DeletePanFile) // PluginUtil.PanFS.deleteFile()
// PluginUtil.Email
emailObj := js.vm.NewObject()
pluginObj.Set("Email", emailObj)
@@ -69,6 +84,11 @@ func (js *JsPlugin) Start() error {
kvObj.Set("getString", GetString) // PluginUtil.KV.getString()
kvObj.Set("putString", PutString) // PluginUtil.KV.putString()
// PluginUtil.HashTool
hashTool := js.vm.NewObject()
pluginObj.Set("HashTool", hashTool)
hashTool.Set("md5Hex", Md5Hex) // PluginUtil.HashTool.md5Hex()
return nil
}
@@ -223,6 +243,26 @@ func (js *JsPlugin) SyncFileFinishCallback(context *Context, params *SyncFileFin
return nil
}
// SyncAllFileFinishCallback 同步备份-同步全部文件完成时的回调函数
func (js *JsPlugin) SyncAllFileFinishCallback(context *Context, params *SyncAllFileFinishParams) error {
var fn func(*Context, *SyncAllFileFinishParams) error
if !js.isHandlerFuncExisted("syncAllFileFinishCallback") {
return nil
}
err := js.vm.ExportTo(js.vm.Get("syncAllFileFinishCallback"), &fn)
if err != nil {
logger.Verboseln("Js函数映射到 Go 函数失败!")
return nil
}
er := fn(context, params)
if er != nil {
logger.Verboseln(er)
return nil
}
return nil
}
// UserTokenRefreshFinishCallback 用户Token刷新完成后回调函数
func (js *JsPlugin) UserTokenRefreshFinishCallback(context *Context, params *UserTokenRefreshFinishParams) error {
var fn func(*Context, *UserTokenRefreshFinishParams) error
if !js.isHandlerFuncExisted("userTokenRefreshFinishCallback") {
@@ -241,6 +281,25 @@ func (js *JsPlugin) UserTokenRefreshFinishCallback(context *Context, params *Use
return nil
}
// RemoveFilePrepareCallback 删除文件前的回调函数
func (js *JsPlugin) RemoveFilePrepareCallback(context *Context, params *RemoveFilePrepareParams) (*RemoveFilePrepareResult, error) {
var fn func(*Context, *RemoveFilePrepareParams) (*RemoveFilePrepareResult, error)
if !js.isHandlerFuncExisted("removeFilePrepareCallback") {
return nil, nil
}
err := js.vm.ExportTo(js.vm.Get("removeFilePrepareCallback"), &fn)
if err != nil {
logger.Verboseln("Js函数映射到 Go 函数失败!")
return nil, nil
}
r, er := fn(context, params)
if er != nil {
logger.Verboseln(er)
return nil, er
}
return r, nil
}
func (js *JsPlugin) Stop() error {
return nil
}
+81 -11
View File
@@ -4,7 +4,7 @@
// 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
// 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,
@@ -16,12 +16,16 @@ package plugins
type (
// Context 插件回调函数上下文信息
Context struct {
AppName string `json:"appName"`
Version string `json:"version"`
UserId string `json:"userId"`
Nickname string `json:"nickname"`
FileDriveId string `json:"fileDriveId"`
AppName string `json:"appName"`
Version string `json:"version"`
UserId string `json:"userId"`
Nickname string `json:"nickname"`
// FileDriveId 备份盘
FileDriveId string `json:"fileDriveId"`
// AlbumDriveId 相册盘
AlbumDriveId string `json:"albumDriveId"`
// ResourceDriveId 资源盘
ResourceDriveId string `json:"resourceDriveId"`
}
// UploadFilePrepareParams 上传文件前的回调函数-参数
@@ -66,6 +70,7 @@ type (
DriveFileType string `json:"driveFileType"`
DriveFileUpdatedAt string `json:"driveFileUpdatedAt"`
LocalFilePath string `json:"localFilePath"`
DownloadActionId string `json:"downloadActionId"`
}
// DownloadFilePrepareResult 上传文件前的回调函数-返回结果
@@ -79,6 +84,7 @@ type (
// DownloadFileFinishParams 下载文件结束的回调函数-参数
DownloadFileFinishParams struct {
DriveId string `json:"driveId"`
DriveFileId string `json:"driveFileId"`
DriveFileName string `json:"driveFileName"`
DriveFilePath string `json:"driveFilePath"`
DriveFileSha1 string `json:"driveFileSha1"`
@@ -87,6 +93,7 @@ type (
DriveFileUpdatedAt string `json:"driveFileUpdatedAt"`
DownloadResult string `json:"downloadResult"`
LocalFilePath string `json:"localFilePath"`
DownloadActionId string `json:"downloadActionId"`
}
// SyncScanLocalFilePrepareParams 同步备份-扫描本地文件前参数
@@ -122,11 +129,12 @@ type (
SyncScanPanApproved string `json:"syncScanPanApproved"`
}
// SyncFileFinishParams 同步备份-文件同步结束-回调参数
// SyncFileFinishParams 同步备份-一个文件同步结束-回调参数
SyncFileFinishParams struct {
Action string `json:"action"`
ActionResult string `json:"actionResult"`
DriveId string `json:"driveId"`
DriveFileId string `json:"driveFileId"`
FileName string `json:"fileName"`
FilePath string `json:"filePath"`
FileSha1 string `json:"fileSha1"`
@@ -135,6 +143,28 @@ type (
FileUpdatedAt string `json:"fileUpdatedAt"`
}
// SyncAllFileFinishParams 同步备份-全部文件同步结束-回调参数
SyncAllFileFinishParams struct {
// Name 任务名称
Name string `json:"name"`
// Id 任务ID
Id string `json:"id"`
// UserId 账号ID
UserId string `json:"userId"`
// DriveName 网盘名称,backup-备份盘,resource-资源盘
DriveName string `json:"driveName"`
// DriveId 网盘ID,目前支持文件网盘
DriveId string `json:"driveId"`
// LocalFolderPath 本地目录
LocalFolderPath string `json:"localFolderPath"`
// PanFolderPath 云盘目录
PanFolderPath string `json:"panFolderPath"`
// Mode 备份模式
Mode string `json:"mode"`
// Policy 备份策略
Policy string `json:"policy"`
}
// UserTokenRefreshFinishParams 用户Token刷新完成后回调函数
UserTokenRefreshFinishParams struct {
Result string `json:"result"`
@@ -144,6 +174,42 @@ type (
UpdatedAt string `json:"updatedAt"`
}
// RemoveFilePrepareParams 删除文件前的回调函数-参数
RemoveFilePrepareParams struct {
Count int `json:"count"`
Items []*RemoveFilePrepareItem `json:"items"`
}
RemoveFilePrepareItem struct {
// DriveId 网盘ID
DriveId string `json:"driveId"`
// DriveFileId 网盘文件的ID
DriveFileId string `json:"driveFileId"`
// DriveFileName 网盘文件名
DriveFileName string `json:"driveFileName"`
// DriveFilePath 网盘文件路径
DriveFilePath string `json:"driveFilePath"`
// DriveFileSize 网盘文件大小
DriveFileSize int64 `json:"driveFileSize"`
// DriveFileType 网盘文件类型,file-文件,folder-文件夹
DriveFileType string `json:"driveFileType"`
// DriveFileUpdatedAt 网盘文件修改时间,格式:2025-03-03 10:39:14
DriveFileUpdatedAt string `json:"driveFileUpdatedAt"`
// DriveFileCreatedAt 网盘文件创建时间,格式:2025-03-03 10:39:14
DriveFileCreatedAt string `json:"driveFileCreatedAt"`
}
// RemoveFilePrepareResult 删除文件前的回调函数-返回结果
RemoveFilePrepareResult struct {
Result []*RemoveFilePrepareResultItem `json:"result"`
}
RemoveFilePrepareResultItem struct {
// DriveId 网盘ID
DriveId string `json:"driveId"`
// DriveFileId 网盘文件的ID
DriveFileId string `json:"driveFileId"`
// RemoveApproved 确认该文件是否删除。yes-删除 no-不删除
RemoveApproved string `json:"removeApproved"`
}
// Plugin 插件接口
Plugin interface {
// Start 启动
@@ -164,18 +230,22 @@ type (
// SyncScanLocalFilePrepareCallback 同步备份-扫描本地文件的回调函数
SyncScanLocalFilePrepareCallback(context *Context, params *SyncScanLocalFilePrepareParams) (*SyncScanLocalFilePrepareResult, error)
// SyncScanPanFilePrepareCallback 同步备份-扫描本地文件的回调函数
// SyncScanPanFilePrepareCallback 同步备份-扫描云盘文件的回调函数
SyncScanPanFilePrepareCallback(context *Context, params *SyncScanPanFilePrepareParams) (*SyncScanPanFilePrepareResult, error)
// SyncFileFinishCallback 同步备份-同步文件完成的回调函数
// SyncFileFinishCallback 同步备份-同步一个文件完成时的回调函数
SyncFileFinishCallback(context *Context, params *SyncFileFinishParams) error
// SyncAllFileFinishCallback 同步备份-同步全部文件完成时的回调函数
SyncAllFileFinishCallback(context *Context, params *SyncAllFileFinishParams) error
// UserTokenRefreshFinishCallback 用户Token刷新完成后回调函数
UserTokenRefreshFinishCallback(context *Context, params *UserTokenRefreshFinishParams) error
// RemoveFilePrepareCallback 删除文件前的回调函数
RemoveFilePrepareCallback(context *Context, params *RemoveFilePrepareParams) (*RemoveFilePrepareResult, error)
// Stop 停止
Stop() error
}
)
var ()
+7 -6
View File
@@ -30,12 +30,13 @@ func GetContext(user *config.PanUser) *Context {
}
}
return &Context{
AppName: "aliyunpan",
Version: global.AppVersion,
UserId: user.UserId,
Nickname: user.Nickname,
FileDriveId: user.DriveList.GetFileDriveId(),
AlbumDriveId: user.DriveList.GetFileDriveId(),
AppName: "aliyunpan",
Version: global.AppVersion,
UserId: user.UserId,
Nickname: user.Nickname,
FileDriveId: user.DriveList.GetFileDriveId(), // 备份盘
AlbumDriveId: user.DriveList.GetAlbumDriveId(), // 相册盘,在OpenAPI接口中,这个相册盘ID是获取不到的
ResourceDriveId: user.DriveList.GetResourceDriveId(), // 资源盘
}
}
+34
View File
@@ -1,9 +1,13 @@
package plugins
import (
"crypto/md5"
"crypto/tls"
"fmt"
"github.com/jordan-wright/email"
jsoniter "github.com/json-iterator/go"
"github.com/tickstep/aliyunpan-api/aliyunpan"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/tickstep/bolt"
"github.com/tickstep/library-go/logger"
"github.com/tickstep/library-go/requester"
@@ -62,6 +66,30 @@ func DeleteLocalFile(localFilePath string) bool {
return false
}
// DeletePanFile 删除云盘文件,支持文件和文件夹
func DeletePanFile(userId, driveId, panFileId string) bool {
logger.Verboseln("[Plugin] try to delete pan file: userId=" + userId + ", driveId=" + driveId + ", panFileId=" + panFileId)
if userId == "" || driveId == "" || panFileId == "" {
return false
}
user := config.Config.UserList.GetUserByUserId(userId)
if user == nil {
logger.Verboseln("[Plugin] user not existed: ", userId)
return false
}
fdr, err := user.PanClient().OpenapiPanClient().FileDelete(&aliyunpan.FileBatchActionParam{
DriveId: driveId,
FileId: panFileId,
})
if err != nil || !fdr.Success {
logger.Verboseln("[Plugin] delete pan file error: ", err)
return false
} else {
logger.Verboseln("[Plugin] delete pan file success: ", panFileId)
return true
}
}
// SendTextMail 发送文本邮件
func SendTextMail(mailServer, userName, password, to, subject, body string) bool {
if e := sendEmail(mailServer, userName, password, to, subject, body, "text", true); e != nil {
@@ -166,3 +194,9 @@ func PutString(key, value string) bool {
}
return true
}
// Md5Hex md5加密
func Md5Hex(text string) string {
hash := md5.Sum([]byte(text))
return fmt.Sprintf("%x", hash[:])
}
+15 -206
View File
@@ -161,85 +161,6 @@ func (f *FileActionTask) DoAction(ctx context.Context) error {
})
}
}
//if f.syncItem.Action == SyncFileActionDeleteLocal {
// f.prompt("删除本地文件:" + f.syncItem.getLocalFileFullPath())
// if e := f.deleteLocalFile(ctx); e != nil {
// // TODO: retry
// return e
// } else {
// // clear DB
// f.localFileDb.Delete(f.syncItem.getLocalFileFullPath())
// f.panFileDb.Delete(f.syncItem.getPanFileFullPath())
//
// // recorder
// f.appendRecord(&log.FileRecordItem{
// Status: "成功-删除本地文件",
// TimeStr: utils.NowTimeStr(),
// FileSize: 0,
// FilePath: f.syncItem.getLocalFileFullPath(),
// })
// }
//}
//
//if f.syncItem.Action == SyncFileActionDeletePan {
// f.prompt("删除云盘文件:" + f.syncItem.getPanFileFullPath())
// if e := f.deletePanFile(ctx); e != nil {
// // TODO: retry
// return e
// } else {
// // clear DB
// f.localFileDb.Delete(f.syncItem.getLocalFileFullPath())
// f.panFileDb.Delete(f.syncItem.getPanFileFullPath())
//
// // recorder
// f.appendRecord(&log.FileRecordItem{
// Status: "成功-删除云盘文件",
// TimeStr: utils.NowTimeStr(),
// FileSize: 0,
// FilePath: f.syncItem.getPanFileFullPath(),
// })
// }
//}
//
//if f.syncItem.Action == SyncFileActionCreateLocalFolder {
// f.prompt("创建本地文件夹:" + f.syncItem.getLocalFileFullPath())
// if e := f.createLocalFolder(ctx); e != nil {
// // TODO: retry
// return e
// } else {
// if file, er := os.Stat(f.syncItem.getLocalFileFullPath()); er == nil {
// f.localFileDb.Add(&LocalFileItem{
// FileName: file.Name(),
// FileSize: file.Size(),
// FileType: "folder",
// CreatedAt: file.ModTime().Format("2006-01-02 15:04:05"),
// UpdatedAt: file.ModTime().Format("2006-01-02 15:04:05"),
// FileExtension: "",
// Sha1Hash: f.syncItem.PanFile.Sha1Hash,
// Path: f.syncItem.getLocalFileFullPath(),
// ScanTimeAt: utils.NowTimeStr(),
// ScanStatus: ScanStatusNormal,
// })
// }
// }
//}
//
//if f.syncItem.Action == SyncFileActionCreatePanFolder {
// f.prompt("创建云盘文件夹:" + f.syncItem.getPanFileFullPath())
// if e := f.createPanFolder(ctx); e != nil {
// // TODO: retry
// return e
// } else {
// if file, er := f.panClient.OpenapiPanClient().FileInfoByPath(f.syncItem.DriveId, f.syncItem.getPanFileFullPath()); er == nil {
// file.Path = f.syncItem.getPanFileFullPath()
// fItem := NewPanFileItem(file)
// fItem.ScanTimeAt = utils.NowTimeStr()
// f.panFileDb.Add(fItem)
// }
// }
//}
return nil
}
@@ -426,6 +347,9 @@ func (f *FileActionTask) downloadFile(ctx context.Context) error {
}
logger.Verboseln("query new download url: ", newUrl.Url)
worker.SetUrl(newUrl.Url)
} else if worker.GetStatus().StatusCode() == downloader.StatusCodeDownloadUrlExceedMaxConcurrency {
logger.Verboseln("download url exceed max concurrency: ", f.syncItem.PanFile.Path)
// 下载遇到限流了,下一次重试
}
}
}
@@ -554,7 +478,7 @@ func (f *FileActionTask) uploadFile(ctx context.Context) error {
DriveId: f.syncItem.DriveId,
FileId: panFileId,
}
if _, e := f.panClient.OpenapiPanClient().FileDelete(dp); e != nil {
if _, e := f.panClient.OpenapiPanClient().FileDeleteCompletely(dp); e != nil {
logger.Verbosef(" 删除云盘旧文件失败: %s\n", targetPanFilePath)
return e
}
@@ -582,6 +506,8 @@ func (f *FileActionTask) uploadFile(ctx context.Context) error {
BlockSize: f.syncItem.UploadBlockSize,
ProofCode: proofCode,
ProofVersion: "v1",
LocalCreatedAt: utils.UnixTime2LocalFormatStr(localFile.ModTime),
LocalModifiedAt: utils.UnixTime2LocalFormatStr(localFile.ModTime),
}
if uploadOpEntity, err := f.panClient.OpenapiPanClient().CreateUploadFile(appCreateUploadFileParam); err != nil {
logger.Verbosef("创建云盘上传任务失败: %s\n", targetPanFilePath)
@@ -602,6 +528,15 @@ func (f *FileActionTask) uploadFile(ctx context.Context) error {
return nil
}
} else {
if len(f.syncItem.UploadEntity.PartInfoList) == 0 {
// finished
f.syncItem.Status = SyncFileStatusSuccess
f.syncItem.StatusUpdateTime = utils.NowTimeStr()
f.syncFileDb.Update(f.syncItem)
PromptPrintln("上传完毕:" + f.syncItem.getPanFileFullPath())
return nil
}
// 检测链接是否过期
// check url expired or not
uploadUrl := f.syncItem.UploadEntity.PartInfoList[f.syncItem.UploadPartSeq].UploadURL
@@ -738,132 +673,6 @@ func (f *FileActionTask) uploadFile(ctx context.Context) error {
}
}
// deleteLocalFile 删除本地文件
func (f *FileActionTask) deleteLocalFile(ctx context.Context) error {
isFolder := f.syncItem.PanFile.IsFolder()
localFilePath := f.syncItem.getLocalFileFullPath()
if b, e := utils.PathExists(localFilePath); e == nil {
if !b {
// 本地文件已经不存在
f.syncItem.Status = SyncFileStatusSuccess
f.syncItem.StatusUpdateTime = utils.NowTimeStr()
f.syncFileDb.Update(f.syncItem)
return nil
}
}
// 删除
var e error
if isFolder {
e = os.RemoveAll(localFilePath)
} else {
e = os.Remove(localFilePath)
}
if e == nil {
f.syncItem.Status = SyncFileStatusSuccess
} else {
f.syncItem.Status = SyncFileStatusFailed
}
f.syncItem.StatusUpdateTime = utils.NowTimeStr()
f.syncFileDb.Update(f.syncItem)
return e
}
// deletePanFile 删除云盘文件
func (f *FileActionTask) deletePanFile(ctx context.Context) error {
panFilePath := f.syncItem.getPanFileFullPath()
driveId := f.syncItem.DriveId
panFileId := ""
if f.syncItem.PanFile != nil {
panFileId = f.syncItem.PanFile.FileId
} else {
fi, er := f.panClient.OpenapiPanClient().FileInfoByPath(f.syncItem.DriveId, panFilePath)
time.Sleep(1 * time.Second)
if er != nil {
if er.Code == apierror.ApiCodeFileNotFoundCode {
// 云盘文件已经不存在
f.syncItem.Status = SyncFileStatusSuccess
f.syncItem.StatusUpdateTime = utils.NowTimeStr()
f.syncFileDb.Update(f.syncItem)
return nil
} else {
// error
return er
}
}
panFileId = fi.FileId
}
// 删除
var fileDeleteResult *aliyunpan.FileBatchActionResult
var err *apierror.ApiError = nil
fileDeleteResult, err = f.panClient.OpenapiPanClient().FileDelete(&aliyunpan.FileBatchActionParam{DriveId: driveId, FileId: panFileId})
time.Sleep(1 * time.Second)
if err != nil || !fileDeleteResult.Success {
f.syncItem.Status = SyncFileStatusFailed
} else {
f.syncItem.Status = SyncFileStatusSuccess
}
f.syncItem.StatusUpdateTime = utils.NowTimeStr()
f.syncFileDb.Update(f.syncItem)
if err == nil {
return nil
}
return err
}
func (f *FileActionTask) createLocalFolder(ctx context.Context) error {
localFilePath := f.syncItem.getLocalFileFullPath()
if b, e := utils.PathExists(localFilePath); e == nil && b {
// 本地文件夹已经存在
f.syncItem.Status = SyncFileStatusSuccess
f.syncItem.StatusUpdateTime = utils.NowTimeStr()
f.syncFileDb.Update(f.syncItem)
return nil
}
// 创建
var er error
if b, e := utils.PathExists(localFilePath); e == nil && !b {
f.localFolderCreateMutex.Lock()
er = os.MkdirAll(localFilePath, 0755)
f.localFolderCreateMutex.Unlock()
time.Sleep(200 * time.Millisecond)
}
if er == nil {
f.syncItem.Status = SyncFileStatusSuccess
} else {
f.syncItem.Status = SyncFileStatusFailed
}
f.syncItem.StatusUpdateTime = utils.NowTimeStr()
f.syncFileDb.Update(f.syncItem)
return er
}
func (f *FileActionTask) createPanFolder(ctx context.Context) error {
panDirPath := f.syncItem.getPanFileFullPath()
// 创建文件夹
logger.Verbosef("创建云盘文件夹: %s\n", panDirPath)
f.panFolderCreateMutex.Lock()
_, apierr1 := f.panClient.OpenapiPanClient().MkdirByFullPath(f.syncItem.DriveId, panDirPath)
f.panFolderCreateMutex.Unlock()
if apierr1 == nil {
logger.Verbosef("创建云盘文件夹成功: %s\n", panDirPath)
f.syncItem.Status = SyncFileStatusSuccess
f.syncItem.StatusUpdateTime = utils.NowTimeStr()
f.syncFileDb.Update(f.syncItem)
return nil
} else {
f.syncItem.Status = SyncFileStatusFailed
f.syncItem.StatusUpdateTime = utils.NowTimeStr()
f.syncFileDb.Update(f.syncItem)
return apierr1
}
}
func (f *FileActionTask) appendRecord(item *log.FileRecordItem) error {
if item == nil {
return nil
+56 -8
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
mapset "github.com/deckarep/golang-set"
"github.com/tickstep/aliyunpan-api/aliyunpan"
"github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/tickstep/aliyunpan/internal/plugins"
"github.com/tickstep/aliyunpan/internal/utils"
@@ -171,7 +173,7 @@ func (f *FileActionTaskManager) doFileDiffRoutine(localFiles LocalFileList, panF
// download file from pan drive
if panFilesNeedToDownload != nil {
for _, file := range panFilesNeedToDownload {
if f.task.Mode == DownloadOnly {
if f.task.Mode == Download {
syncItem := &SyncFileItem{
Action: SyncFileActionDownload,
Status: SyncFileStatusCreate,
@@ -195,6 +197,13 @@ func (f *FileActionTaskManager) doFileDiffRoutine(localFiles LocalFileList, panF
}
f.addToSyncDb(fileActionTask)
}
} else if f.task.Mode == Upload {
if f.task.Policy == SyncPolicyExclusive {
// 需要删除云盘多余的文件
if f.deletePanFile(file) == nil {
PromptPrintln("成功删除云盘多余文件:" + file.Path)
}
}
}
}
}
@@ -202,7 +211,7 @@ func (f *FileActionTaskManager) doFileDiffRoutine(localFiles LocalFileList, panF
// upload file to pan drive
if localFilesNeedToUpload != nil {
for _, file := range localFilesNeedToUpload {
if f.task.Mode == UploadOnly {
if f.task.Mode == Upload {
// check local file modified or not
if file.IsFile() {
if f.syncOption.LocalFileModifiedCheckIntervalSec > 0 {
@@ -238,6 +247,13 @@ func (f *FileActionTaskManager) doFileDiffRoutine(localFiles LocalFileList, panF
}
f.addToSyncDb(fileActionTask)
}
} else if f.task.Mode == Download {
if f.task.Policy == SyncPolicyExclusive {
// 需要删除云盘多余的文件
if f.deleteLocalFile(file) == nil {
PromptPrintln("成功删除本地多余文件:" + file.Path)
}
}
}
}
}
@@ -254,8 +270,7 @@ func (f *FileActionTaskManager) doFileDiffRoutine(localFiles LocalFileList, panF
// 本地文件和云盘文件SHA1不一样
// 不同模式同步策略不一样
if f.task.Mode == UploadOnly {
if f.task.Mode == Upload {
// 不再这里计算SHA1,待到上传的时候再计算
//if localFile.Sha1Hash == "" {
// // 计算本地文件SHA1
@@ -298,7 +313,7 @@ func (f *FileActionTaskManager) doFileDiffRoutine(localFiles LocalFileList, panF
},
}
f.addToSyncDb(uploadLocalFile)
} else if f.task.Mode == DownloadOnly {
} else if f.task.Mode == Download {
// 校验SHA1是否相同
if strings.ToLower(panFile.Sha1Hash) == strings.ToLower(localFile.Sha1Hash) {
// do nothing
@@ -327,7 +342,7 @@ func (f *FileActionTaskManager) doFileDiffRoutine(localFiles LocalFileList, panF
}
}
// 创建本地文件夹
// createLocalFolder 创建本地文件夹
func (f *FileActionTaskManager) createLocalFolder(panFileItem *PanFileItem) error {
panPath := panFileItem.Path
panPath = strings.ReplaceAll(panPath, "\\", "/")
@@ -368,6 +383,37 @@ func (f *FileActionTaskManager) createPanFolder(localFileItem *LocalFileItem) er
}
}
// deleteLocalFile 删除本地文件
func (f *FileActionTaskManager) deleteLocalFile(localFileItem *LocalFileItem) error {
localFilePath := localFileItem.Path
logger.Verbosef("正在删除本地文件: %s\n", localFilePath)
var e error
if localFileItem.IsFolder() {
e = os.RemoveAll(localFilePath)
} else {
e = os.Remove(localFilePath)
}
if e == nil {
logger.Verbosef("删除本地文件成功: %s\n", localFilePath)
return nil
}
return e
}
// deletePanFile 删除云盘文件
func (f *FileActionTaskManager) deletePanFile(panFileItem *PanFileItem) error {
logger.Verbosef("正在删除云盘文件: %s\n", panFileItem.Path)
var fileDeleteResult *aliyunpan.FileBatchActionResult
var err *apierror.ApiError = nil
fileDeleteResult, err = f.task.panClient.OpenapiPanClient().FileDeleteCompletely(&aliyunpan.FileBatchActionParam{DriveId: panFileItem.DriveId, FileId: panFileItem.FileId})
time.Sleep(1 * time.Second)
if err == nil && fileDeleteResult.Success {
logger.Verbosef("删除云盘文件成功: %s\n", panFileItem.Path)
return nil
}
return err
}
func (f *FileActionTaskManager) addToSyncDb(fileTask *FileActionTask) {
f.mutex.Lock()
defer f.mutex.Unlock()
@@ -553,9 +599,9 @@ func (f *FileActionTaskManager) fileActionTaskExecutor(ctx context.Context) {
f.setExecuteLoopFlag(true)
logger.Verboseln("file execute task is finish, exit normally")
prompt := ""
if f.task.Mode == UploadOnly {
if f.task.Mode == Upload {
prompt = "完成全部文件的同步上传,等待下一次扫描"
} else if f.task.Mode == DownloadOnly {
} else if f.task.Mode == Download {
prompt = "完成全部文件的同步下载,等待下一次扫描"
} else {
prompt = "完成全部文件的同步,等待下一次扫描"
@@ -583,6 +629,7 @@ func (f *FileActionTaskManager) doPluginCallback(syncFile *SyncFileItem, actionR
Action: string(syncFile.Action),
ActionResult: actionResult,
DriveId: syncFile.DriveId,
DriveFileId: "",
FileName: file.FileName,
FilePath: syncFile.getPanFileFullPath(),
FileSha1: file.Sha1Hash,
@@ -598,6 +645,7 @@ func (f *FileActionTaskManager) doPluginCallback(syncFile *SyncFileItem, actionR
Action: string(syncFile.Action),
ActionResult: actionResult,
DriveId: syncFile.DriveId,
DriveFileId: file.FileId,
FileName: file.FileName,
FilePath: syncFile.getLocalFileFullPath(),
FileSha1: file.Sha1Hash,
+84 -27
View File
@@ -20,9 +20,9 @@ import (
)
type (
TaskStep string
SyncMode string
CycleMode string
SyncMode string
SyncPolicy string
CycleMode string
// SyncTask 同步任务
SyncTask struct {
@@ -40,14 +40,18 @@ type (
LocalFolderPath string `json:"localFolderPath"`
// PanFolderPath 云盘目录
PanFolderPath string `json:"panFolderPath"`
// Mode 同步模式
// Mode 备份模式
Mode SyncMode `json:"mode"`
// Policy 备份策略
Policy SyncPolicy `json:"policy"`
// CycleMode 循环模式,OneTime-运行一次,InfiniteLoop-无限循环模式
CycleModeType CycleMode `json:"-"`
// Priority 优先级选项
Priority SyncPriorityOption `json:"-"`
// LastSyncTime 上一次同步时间
LastSyncTime string `json:"lastSyncTime"`
// ScanTimeInterval 扫描文件时间间隔,单位秒
ScanTimeInterval int64 `json:"-"`
syncDbFolderPath string
localFileDb LocalSyncDb
@@ -73,24 +77,22 @@ type (
)
const (
// UploadOnly 单向上传,即备份本地文件到云盘
UploadOnly SyncMode = "upload"
// DownloadOnly 只下载,即备份云盘文件到本地
DownloadOnly SyncMode = "download"
// Upload 上传,即备份本地文件到云盘
Upload SyncMode = "upload"
// Download 下载,即备份云盘文件到本地
Download SyncMode = "download"
// SyncTwoWay 双向同步,本地和云盘文件完全保持一致
SyncTwoWay SyncMode = "sync"
// SyncPolicyExclusive 备份策略,排他备份,保证本地和云盘一比一备份,目标目录多余的文件会被删除
SyncPolicyExclusive SyncPolicy = "exclusive"
// SyncPolicyIncrement 备份策略,增量备份,只会增量备份文件,目标目录多余的(旧的)文件不会被删除
SyncPolicyIncrement SyncPolicy = "increment"
// CycleOneTime 只运行一次
CycleOneTime CycleMode = "OneTime"
// CycleInfiniteLoop 无限循环模式
CycleInfiniteLoop CycleMode = "InfiniteLoop"
// StepScanFile 任务步骤,扫描文件建立同步数据库
StepScanFile TaskStep = "scan"
// StepDiffFile 任务步骤,对比文件
StepDiffFile TaskStep = "diff"
// StepSyncFile 任务步骤,同步文件
StepSyncFile TaskStep = "sync"
)
func (t *SyncTask) NameLabel() string {
@@ -101,13 +103,30 @@ func (t *SyncTask) String() string {
builder := &strings.Builder{}
builder.WriteString("任务: " + t.NameLabel() + "\n")
mode := "双向备份"
if t.Mode == UploadOnly {
mode = "备份本地文件(只上传)"
if t.Mode == Upload {
mode = "备份本地文件(上传)"
}
if t.Mode == DownloadOnly {
mode = "备份云盘文件(只下载)"
if t.Mode == Download {
mode = "备份云盘文件(下载)"
}
builder.WriteString("同步模式: " + mode + "\n")
policy := ""
if t.Policy == SyncPolicyExclusive {
if t.Mode == Upload {
policy = "排他备份(上传&删除)"
} else if t.Mode == Download {
policy = "排他备份(下载&删除)"
}
}
if t.Policy == SyncPolicyIncrement {
if t.Mode == Upload {
policy = "增量备份(只上传)"
} else if t.Mode == Download {
policy = "增量备份(只下载)"
}
}
if t.Mode == SyncTwoWay {
priority := "时间优先"
if t.syncOption.SyncPriority == SyncPriorityLocalFirst {
@@ -117,8 +136,15 @@ func (t *SyncTask) String() string {
} else {
priority = "时间优先"
}
builder.WriteString("优先选项: " + priority + "\n")
builder.WriteString("同步策略: " + priority + "\n")
} else {
builder.WriteString("同步策略: " + policy + "\n")
}
cycleModeStr := "永久循环"
if t.CycleModeType == CycleOneTime {
cycleModeStr = "运行一次"
}
builder.WriteString("运行周期: " + cycleModeStr + "\n")
builder.WriteString("本地目录: " + t.LocalFolderPath + "\n")
builder.WriteString("云盘目录: " + t.PanFolderPath + "\n")
driveName := "备份盘"
@@ -182,7 +208,6 @@ func (t *SyncTask) Start() error {
// setup sync db file
t.setupDb()
if t.fileActionTaskManager == nil {
t.fileActionTaskManager = NewFileActionTaskManager(t)
}
@@ -209,11 +234,16 @@ func (t *SyncTask) Start() error {
return e
}
// 策略
if t.Policy == "" {
t.Policy = SyncPolicyIncrement
}
// 启动文件扫描进程
t.SetScanLoopFlag(false)
if t.Mode == UploadOnly {
if t.Mode == Upload {
go t.scanLocalFile(t.ctx)
} else if t.Mode == DownloadOnly {
} else if t.Mode == Download {
go t.scanPanFile(t.ctx)
} else {
return fmt.Errorf("异常:暂不支持该模式。")
@@ -278,6 +308,12 @@ func (t *SyncTask) SetScanLoopFlag(done bool) {
t.scanLoopIsDone = done
}
// IsTaskCompletely 任务是否已经完成
func (t *SyncTask) IsTaskCompletely() bool {
// 扫描完成+执行完成+一次运行模式
return t.IsScanLoopDone() && t.fileActionTaskManager.IsExecuteLoopIsDone() && t.CycleModeType == CycleOneTime
}
// panSyncDbFullPath 云盘文件数据库
func (t *SyncTask) panSyncDbFullPath() string {
dir := path.Join(t.syncDbFolderPath, t.Id)
@@ -333,7 +369,7 @@ func (t *SyncTask) discardLocalFileDb(filePath string, startTimeUnix int64) bool
}
for _, file := range files {
if file.ScanTimeAt == "" || file.ScanTimeUnix() < startTimeUnix {
if t.Mode == DownloadOnly {
if t.Mode == Download {
// delete discard local file info directly
t.localFileDb.Delete(file.Path)
logger.Verboseln("label discard local file from DB: ", utils.ObjectToJsonStr(file, false))
@@ -461,7 +497,7 @@ func (t *SyncTask) scanLocalFile(ctx context.Context) {
fileInfo: rootFolder,
path: t.LocalFolderPath,
})
delayTimeCount = TimeSecondsOfOneMinute
delayTimeCount = t.ScanTimeInterval
continue
}
item := obj.(*folderItem)
@@ -572,7 +608,7 @@ func (t *SyncTask) discardPanFileDb(filePath string, startTimeUnix int64) bool {
}
for _, file := range files {
if file.ScanTimeUnix() < startTimeUnix {
if t.Mode == UploadOnly {
if t.Mode == Upload {
// delete discard pan file info directly
t.panFileDb.Delete(file.Path)
logger.Verboseln("delete discard pan file from DB: ", utils.ObjectToJsonStr(file, false))
@@ -682,7 +718,7 @@ func (t *SyncTask) scanPanFile(ctx context.Context) {
// 无限循环模式,继续下一次扫描
folderQueue.Push(rootPanFile)
delayTimeCount = TimeSecondsOfOneMinute
delayTimeCount = t.ScanTimeInterval
continue
}
item := obj.(*aliyunpan.FileEntity)
@@ -767,3 +803,24 @@ func (t *SyncTask) scanPanFile(ctx context.Context) {
}
}
}
// doAllFileSyncPluginCallback 全部文件同步完成回调方法
func (t *SyncTask) doAllFileSyncPluginCallback() {
// 插件回调
pluginParam := &plugins.SyncAllFileFinishParams{
Name: t.Name,
Id: t.Id,
UserId: t.UserId,
DriveName: t.DriveName,
DriveId: t.DriveId,
LocalFolderPath: t.LocalFolderPath,
PanFolderPath: t.PanFolderPath,
Mode: string(t.Mode),
Policy: string(t.Policy),
}
t.pluginMutex.Lock()
defer t.pluginMutex.Unlock()
if er := t.plugin.SyncAllFileFinishCallback(plugins.GetContext(t.panUser), pluginParam); er == nil {
logger.Verboseln("成功调用同步任务完成插件回调方法:" + t.Id)
}
}
+27 -1
View File
@@ -116,7 +116,7 @@ func (m *SyncTaskManager) ConfigFilePath() string {
}
// Start 启动同步进程
func (m *SyncTaskManager) Start(tasks []*SyncTask) (bool, error) {
func (m *SyncTaskManager) Start(tasks []*SyncTask, cycleMode CycleMode, scanTimeInterval int64) (bool, error) {
if tasks != nil && len(tasks) > 0 {
m.syncDriveConfig = &SyncDriveConfig{
ConfigVer: "1.0",
@@ -139,6 +139,11 @@ func (m *SyncTaskManager) Start(tasks []*SyncTask) (bool, error) {
if len(task.Id) == 0 {
task.Id = utils.UuidStr()
}
// cycle mode
task.CycleModeType = cycleMode
// scan time
task.ScanTimeInterval = scanTimeInterval
// check driveId
if strings.ToLower(task.DriveName) == "backup" {
task.DriveId = m.PanUser.DriveList.GetFileDriveId()
@@ -180,6 +185,9 @@ func (m *SyncTaskManager) Start(tasks []*SyncTask) (bool, error) {
task.Priority = SyncPriorityTimestampFirst
task.syncOption.SyncPriority = SyncPriorityTimestampFirst
}
if task.Policy == "" {
task.Policy = SyncPolicyIncrement
}
task.LocalFolderPath = path.Clean(task.LocalFolderPath)
task.PanFolderPath = path.Clean(task.PanFolderPath)
if e := task.Start(); e != nil {
@@ -218,3 +226,21 @@ func (m *SyncTaskManager) Stop() (bool, error) {
}
return true, nil
}
func (m *SyncTaskManager) IsAllTaskCompletely() bool {
for _, task := range m.syncDriveConfig.SyncTaskList {
if !task.IsTaskCompletely() {
return false
}
}
return true
}
// DoTaskSyncCompletelyPluginCallback 调用任务同步完成的回调函数
func (m *SyncTaskManager) DoTaskSyncCompletelyPluginCallback() {
for _, task := range m.syncDriveConfig.SyncTaskList {
if task.IsTaskCompletely() {
task.doAllFileSyncPluginCallback()
}
}
}
+833
View File
@@ -0,0 +1,833 @@
// Copyright (c) 2020 tickstep.
//
// 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.
package ui
import (
"fmt"
"io"
"math"
"os"
"strings"
"sync"
"time"
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/library-go/converter"
"github.com/tickstep/library-go/requester/rio/speeds"
)
type TaskState int
type DashboardType int
const (
TaskQueued TaskState = iota
TaskRunning
TaskSuccess
TaskFailed
TaskSkipped
TaskCanceled
// DashboardPanelDownload 下载面板类型
DashboardPanelDownload DashboardType = 1
// DashboardPanelUpload 上传面板类型
DashboardPanelUpload DashboardType = 2
)
type DashboardOptions struct {
Title string
MaxHistory int
ActiveSlots int
Output io.Writer
}
// DashboardPanel 统计面板使用了很多ANSI转义序列。并不是所有的终端都支持ANSI转义序列,所以需要做兼容性处理。
type DashboardPanel struct {
dashboardType DashboardType
mu sync.Mutex
tasks map[string]*dashboardTask
order []string
history []string
maxHistory int
parallel int
activeSlots int
globalSpeeds *speeds.Speeds
title string
startTime time.Time
renderEvery time.Duration
stopCh chan struct{}
doneCh chan struct{}
startOnce sync.Once
closeOnce sync.Once
out io.Writer
}
type dashboardTask struct {
id string
path string
name string
total int64
downloaded int64
speed int64
eta time.Duration
state TaskState
message string
isFile bool
}
// NewDashboardPanel 创建下载任务统计面板
func NewDashboardPanel(dashboardType DashboardType, parallel int, globalSpeeds *speeds.Speeds, opts *DashboardOptions) *DashboardPanel {
if parallel < 1 {
parallel = 1
}
db := &DashboardPanel{
dashboardType: dashboardType,
tasks: make(map[string]*dashboardTask),
order: make([]string, 0, 128),
history: make([]string, 0, 64),
maxHistory: 8,
parallel: parallel,
activeSlots: 5,
globalSpeeds: globalSpeeds,
title: "aliyunpan统计面板",
renderEvery: 500 * time.Millisecond,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
out: os.Stdout,
}
if opts != nil {
if opts.Title != "" {
db.title = opts.Title
}
if opts.MaxHistory > 0 {
db.maxHistory = opts.MaxHistory
}
if opts.ActiveSlots > 0 {
db.activeSlots = opts.ActiveSlots
}
if opts.Output != nil {
db.out = opts.Output
}
}
if db.activeSlots < 1 {
db.activeSlots = 1
}
return db
}
// Start 启动面板
func (db *DashboardPanel) Start() {
if db == nil {
return
}
db.startOnce.Do(func() {
db.startTime = time.Now()
enableVirtualTerminalProcessing()
fmt.Fprint(db.out, "\x1b[?25l") // 隐藏光标
db.render()
ticker := time.NewTicker(db.renderEvery)
go func() {
defer close(db.doneCh)
defer ticker.Stop()
for {
select {
case <-db.stopCh:
// 延迟1秒
time.Sleep(1 * time.Second)
// 渲染
db.render()
return
case <-ticker.C:
db.render()
}
}
}()
})
}
// Close 关闭面板
func (db *DashboardPanel) Close() {
if db == nil {
return
}
db.closeOnce.Do(func() {
close(db.stopCh)
<-db.doneCh
fmt.Fprint(db.out, "\x1b[?25h") // 显示光标
})
}
// RegisterTask 注册任务信息
func (db *DashboardPanel) RegisterTask(id, path string, total int64, isFile bool) {
if db == nil || id == "" {
return
}
db.mu.Lock()
defer db.mu.Unlock()
task := db.getOrCreateTaskLocked(id)
if path != "" {
task.path = path
}
if task.name == "" {
task.name = baseName(path)
}
if total > 0 {
task.total = total
}
task.isFile = isFile
}
// UpdateTaskInfo 更新任务信息
func (db *DashboardPanel) UpdateTaskInfo(id, name string, total int64, isFile bool) {
if db == nil || id == "" {
return
}
db.mu.Lock()
defer db.mu.Unlock()
task := db.getOrCreateTaskLocked(id)
if name != "" {
task.name = name
}
if total > 0 {
task.total = total
}
task.isFile = isFile
}
// UpdateTaskProgress 更新任务进度
func (db *DashboardPanel) UpdateTaskProgress(id string, downloaded, total, speed int64, eta time.Duration) {
if db == nil || id == "" {
return
}
db.mu.Lock()
defer db.mu.Unlock()
task := db.getOrCreateTaskLocked(id)
task.downloaded = downloaded
if total > 0 {
task.total = total
}
task.speed = speed
task.eta = eta
}
// MarkTaskState 标记任务状态
func (db *DashboardPanel) MarkTaskState(id string, state TaskState, message string) {
if db == nil || id == "" {
return
}
db.mu.Lock()
defer db.mu.Unlock()
task := db.getOrCreateTaskLocked(id)
task.state = state
if message != "" {
task.message = message
}
switch state {
case TaskSuccess, TaskFailed, TaskSkipped, TaskCanceled:
db.appendHistoryLocked(db.defaultHistoryMessage(task, message))
}
}
// Logf 增加日志
func (db *DashboardPanel) Logf(format string, a ...interface{}) {
if db == nil {
return
}
msg := fmt.Sprintf(format, a...)
msg = strings.TrimRight(msg, "\r\n")
if msg == "" {
return
}
// 去掉开头的换行符
msg = strings.TrimPrefix(msg, "\n")
// 去掉回车换行符,UI面板的日志只能单行显示
msg = strings.ReplaceAll(msg, "\n", " ")
db.mu.Lock()
defer db.mu.Unlock()
db.appendHistoryLocked(msg)
}
func (db *DashboardPanel) getOrCreateTaskLocked(id string) *dashboardTask {
if task, ok := db.tasks[id]; ok {
return task
}
task := &dashboardTask{
id: id,
state: TaskQueued,
}
db.tasks[id] = task
db.order = append(db.order, id)
return task
}
func (db *DashboardPanel) appendHistoryLocked(message string) {
if message == "" {
return
}
timestamp := time.Now().Format("15:04:05")
db.history = append(db.history, fmt.Sprintf("[%s] %s", timestamp, message))
if len(db.history) > db.maxHistory*3 {
db.history = db.history[len(db.history)-db.maxHistory*2:]
}
}
func (db *DashboardPanel) defaultHistoryMessage(task *dashboardTask, message string) string {
if message != "" {
return message
}
name := task.path
if name == "" {
name = task.name
}
switch task.state {
case TaskSuccess:
return "完成: " + name
case TaskFailed:
return "失败: " + name
case TaskSkipped:
return "跳过: " + name
case TaskCanceled:
return "取消: " + name
default:
return name
}
}
// render 渲染面板
func (db *DashboardPanel) render() {
if db == nil {
return
}
width, height := terminalSize()
if width < 60 {
width = 60
}
innerWidth := width - 2
snapshot := db.snapshot()
// 统计总览
headerLines := db.headerLines(snapshot, innerWidth)
// 下载任务队列详情
//activeTitle := db.activeTitle(snapshot)
activeLines := db.activeLines(snapshot, innerWidth)
// 下载日志
headerHeight := len(headerLines) + 2
activeHeight := len(activeLines) + 1
historyBase := 3
minHistory := 1
minHeight := headerHeight + activeHeight + historyBase + minHistory
if height < minHeight {
height = minHeight
}
historyLines := height - (headerHeight + activeHeight + historyBase)
if historyLines < minHistory {
historyLines = minHistory
}
historyBody := db.historyLines(snapshot, innerWidth, historyLines)
// 拼接并格式化输出
builder := &strings.Builder{}
builder.WriteString(renderBox(headerLines, width))
builder.WriteString(renderTitledBox("ActiveDownloads", activeLines, width))
builder.WriteString(renderTitledBox("History", historyBody, width))
content := builder.String()
// 使用双缓冲技术
var buf strings.Builder
// 构建完整内容
buf.WriteString("\x1b[H\x1b[2J") // 清屏操作
buf.WriteString(content)
// 一次性输出
fmt.Fprint(db.out, buf.String())
}
type dashboardSnapshot struct {
tasks []*dashboardTask
history []string
}
func (db *DashboardPanel) snapshot() dashboardSnapshot {
db.mu.Lock()
defer db.mu.Unlock()
tasks := make([]*dashboardTask, 0, len(db.order))
for _, id := range db.order {
if task, ok := db.tasks[id]; ok {
copied := *task
tasks = append(tasks, &copied)
}
}
history := append([]string(nil), db.history...)
return dashboardSnapshot{tasks: tasks, history: history}
}
func renderBox(lines []string, width int) string {
if width < 4 {
width = 4
}
innerWidth := width - 2
builder := &strings.Builder{}
builder.WriteString("\u250c")
builder.WriteString(strings.Repeat("\u2500", innerWidth))
builder.WriteString("\u2510")
for _, line := range lines {
builder.WriteString("\n")
builder.WriteString("\u2502") // 左边框
builder.WriteString(fitLine(line, innerWidth))
builder.WriteString("\u2502") // 右边框
}
builder.WriteString("\n")
builder.WriteString("\u2502") // 左边框
//builder.WriteString("\u2514") // 左下角
builder.WriteString(strings.Repeat("\u2500", innerWidth))
builder.WriteString("\u2502") // 右边框
//builder.WriteString("\u2518") // 右下角
return builder.String()
}
func renderTitledBox(title string, body []string, width int) string {
if width < 4 {
width = 4
}
innerWidth := width - 2
builder := &strings.Builder{}
for _, line := range body {
builder.WriteString("\n")
builder.WriteString("\u2502") // 左边框
builder.WriteString(fitLine(line, innerWidth)) // 内容,使用空格补足宽度
builder.WriteString("\u2502") // 右边框
}
builder.WriteString("\n")
if strings.HasPrefix(title, "History") {
builder.WriteString("\u2514") // 左下角
} else {
builder.WriteString("\u2502") // 左边框
}
builder.WriteString(strings.Repeat("\u2500", innerWidth)) // 表格横线
if strings.HasPrefix(title, "History") {
builder.WriteString("\u2518") // 右下角
} else {
builder.WriteString("\u2502") // 右边框
}
return builder.String()
}
func (db *DashboardPanel) headerLines(snapshot dashboardSnapshot, innerWidth int) []string {
totalFiles, doneFiles, failedFiles := db.fileCounts(snapshot.tasks)
downloaded, total := db.totalProgress(snapshot.tasks)
speed := db.totalSpeed(snapshot.tasks)
elapsed := time.Since(db.startTime)
left := "-"
if speed > 0 && total > downloaded {
left = formatDurationShort(time.Duration((total-downloaded)/speed) * time.Second)
}
status := "进行中"
if db.dashboardType == DashboardPanelDownload {
status = "下载中"
} else if db.dashboardType == DashboardPanelUpload {
status = "上传中"
}
if totalFiles > 0 && doneFiles >= totalFiles {
if failedFiles > 0 {
status = "已完成 (有错误)"
} else {
status = "已完成"
}
}
line1 := fmt.Sprintf("总速度: %s | 文件: %d/%d | 失败: %d | 状态: %s | 已用时间: %s | 剩余时间: %s",
utils.FormatSpeedFixedWidth(speed, 11), doneFiles, totalFiles, failedFiles, status, formatDurationShort(elapsed), left)
line1 = fitLine(line1, innerWidth)
progressPct := 0.0
if total > 0 {
progressPct = float64(downloaded) / float64(total)
}
progress := fmt.Sprintf("%s/%s", converter.ConvertFileSize(downloaded, 2), converter.ConvertFileSize(total, 2))
label := "总进度: "
suffix := fmt.Sprintf(" %5.1f%% (%s)", progressPct*100, progress)
barWidth := innerWidth - displayWidth(label) - displayWidth(suffix) - 2
if barWidth < 10 {
barWidth = 10
}
bar := renderBar(progressPct, barWidth)
line2 := fmt.Sprintf("%s[%s]%s", label, bar, suffix)
line2 = fitLine(line2, innerWidth)
return []string{line1, line2}
}
func (db *DashboardPanel) activeTitle(snapshot dashboardSnapshot) string {
running := 0
queued := 0
for _, task := range snapshot.tasks {
if !task.isFile {
continue
}
switch task.state {
case TaskRunning:
running++
case TaskQueued:
queued++
}
}
return fmt.Sprintf("Active Downloads (slots %d, running %d, queued %d)", db.activeSlots, running, queued)
}
func (db *DashboardPanel) activeLines(snapshot dashboardSnapshot, innerWidth int) []string {
active := db.pickActiveTasks(snapshot.tasks)
lines := make([]string, 0, db.activeSlots)
for i := 0; i < db.activeSlots; i++ {
if i < len(active) {
lines = append(lines, renderTaskLine(active[i], i, innerWidth))
continue
}
placeholder := &dashboardTask{name: "[空闲...]", state: TaskQueued}
lines = append(lines, renderTaskLine(placeholder, i, innerWidth))
}
return lines
}
func (db *DashboardPanel) historyLines(snapshot dashboardSnapshot, innerWidth int, lines int) []string {
if lines < 1 {
lines = 1
}
maxLines := lines
if db.maxHistory > 0 && maxLines > db.maxHistory {
maxLines = db.maxHistory
}
if maxLines > len(snapshot.history) {
maxLines = len(snapshot.history)
}
result := make([]string, 0, lines)
start := len(snapshot.history) - maxLines
if start < 0 {
start = 0
}
for i := len(snapshot.history) - 1; i >= start && len(result) < maxLines; i-- {
result = append(result, truncateText(snapshot.history[i], innerWidth))
}
if len(result) == 0 {
result = append(result, truncateText("No recent events.", innerWidth))
}
// 倒序数组
reversedResult := make([]string, 0, len(result))
for i := len(result) - 1; i >= 0; i-- {
reversedResult = append(reversedResult, result[i])
}
result = reversedResult
// 补全空行
for len(result) < lines {
result = append(result, "")
}
return result[:lines]
}
func (db *DashboardPanel) fileCounts(tasks []*dashboardTask) (total int, done int, failed int) {
for _, task := range tasks {
if !task.isFile {
continue
}
total++
switch task.state {
case TaskSuccess, TaskSkipped, TaskFailed, TaskCanceled:
done++
}
if task.state == TaskFailed {
failed++
}
}
return
}
func (db *DashboardPanel) totalProgress(tasks []*dashboardTask) (downloaded int64, total int64) {
for _, task := range tasks {
if !task.isFile {
continue
}
if task.total > 0 {
total += task.total
if task.state == TaskSuccess || task.state == TaskSkipped {
// 成功、跳过的文件,统计上当做已经下载完成
downloaded += task.total
} else {
if task.downloaded >= task.total {
downloaded += task.total
} else {
downloaded += task.downloaded
}
}
}
}
return
}
func (db *DashboardPanel) totalSpeed(tasks []*dashboardTask) int64 {
if db.globalSpeeds != nil {
return db.globalSpeeds.GetSpeeds()
}
var sum int64
for _, task := range tasks {
sum += task.speed
}
return sum
}
func (db *DashboardPanel) pickActiveTasks(tasks []*dashboardTask) []*dashboardTask {
slotCap := db.activeSlots
if slotCap < 1 {
slotCap = 1
}
active := make([]*dashboardTask, 0, slotCap)
queued := make([]*dashboardTask, 0, slotCap)
for _, task := range tasks {
if !task.isFile {
continue
}
switch task.state {
case TaskRunning:
active = append(active, task)
case TaskQueued:
queued = append(queued, task)
}
}
active = append(active, queued...)
if len(active) > slotCap {
active = active[:slotCap]
}
return active
}
func renderTaskLine(task *dashboardTask, index int, width int) string {
if width < 20 {
width = 20
}
prefix := fmt.Sprintf("%2d. ", index+1)
name := task.name
if name == "" {
name = baseName(task.path)
}
// 百分比
progressPct := 0.0
percent := "--.-%"
if task.total > 0 {
progressPct = float64(task.downloaded) / float64(task.total)
percent = fmt.Sprintf("%5.1f%%", math.Min(100, progressPct*100)) // 将浮点数格式化为:总宽度 5 个字符,包含 1 位小数,后面加百分号
} else {
// 空闲队列左边多一个空格,对齐活跃队列
percent = " --.-%"
}
// 速度
rate := "-"
// 剩余时间
eta := "-"
switch task.state {
case TaskQueued:
rate = "等待中"
case TaskRunning:
if task.speed > 0 {
rate = converter.ConvertFileSize(task.speed, 2) + "/s"
} else {
rate = "0 B/s"
}
if task.eta > 0 {
eta = formatDurationShort(task.eta)
}
case TaskSuccess:
rate = "done"
case TaskFailed:
rate = "failed"
case TaskSkipped:
rate = "skipped"
case TaskCanceled:
rate = "canceled"
}
barWidth := 20
rateWidth := 12
etaWidth := 8
percentWidth := displayWidth(percent)
fixed := displayWidth(prefix) + 1 + 2 /*进度条左右[]括号*/ + barWidth + 1 + percentWidth + 1 + rateWidth + 1 + etaWidth
nameWidth := width - fixed
if nameWidth < 8 {
reduce := 8 - nameWidth
if barWidth > 10 {
cut := minInt(reduce, barWidth-10)
barWidth -= cut
reduce -= cut
}
if rateWidth > 8 && reduce > 0 {
cut := minInt(reduce, rateWidth-8)
rateWidth -= cut
reduce -= cut
}
if etaWidth > 5 && reduce > 0 {
cut := minInt(reduce, etaWidth-5)
etaWidth -= cut
reduce -= cut
}
fixed = displayWidth(prefix) + 1 /*1个空格分隔*/ + 2 /*进度条左右[]括号*/ + barWidth /*进度条长度*/ + 1 /*1个空格分隔*/ + percentWidth + 1 /*1个空格分隔*/ + rateWidth + 1 /*1个空格分隔*/ + etaWidth
nameWidth = width - fixed
if nameWidth < 4 {
nameWidth = 4
}
}
name = padRight(truncateText(name, nameWidth), nameWidth)
bar := renderBar(progressPct, barWidth)
rate = padRight(truncateText(rate, rateWidth), rateWidth)
eta = padRight(truncateText(eta, etaWidth), etaWidth)
line := fmt.Sprintf("%s%s [%s] %s %s %s", prefix, name, bar, percent, rate, eta)
return fitLine(line, width)
}
// renderBar 绘制进度条
func renderBar(pct float64, width int) string {
if width < 5 {
width = 5
}
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
filled := int(math.Floor(pct * float64(width)))
// 已经下载的部分使用 => 表示,没有下载的部分使用 空格 表示
if filled >= 1 {
return strings.Repeat("=", filled-1) + ">" + strings.Repeat(" ", width-filled)
}
return strings.Repeat(" ", width-filled)
}
func formatDurationShort(d time.Duration) string {
if d <= 0 {
return "-"
}
seconds := int(d.Seconds())
if seconds < 60 {
return fmt.Sprintf("%ds", seconds)
}
minutes := seconds / 60
seconds = seconds % 60
if minutes < 60 {
return fmt.Sprintf("%dm%02ds", minutes, seconds)
}
hours := minutes / 60
minutes = minutes % 60
if hours < 100 {
return fmt.Sprintf("%dh%02dm", hours, minutes)
}
return fmt.Sprintf("%dh", hours)
}
func truncateText(text string, max int) string {
if max <= 0 || text == "" {
return ""
}
if displayWidth(text) <= max {
return text
}
if max <= 3 {
return takeWidth(text, max)
}
return takeWidth(text, max-3) + "..."
}
func padRight(text string, width int) string {
if width <= 0 {
return text
}
if displayWidth(text) >= width {
return text
}
return text + strings.Repeat(" ", width-displayWidth(text))
}
func fitLine(text string, width int) string {
return padRight(truncateText(text, width), width)
}
func displayWidth(text string) int {
width := 0
for _, r := range text {
width += runeWidth(r)
}
return width
}
func takeWidth(text string, max int) string {
if max <= 0 {
return ""
}
used := 0
for idx, r := range text {
w := runeWidth(r)
if used+w > max {
return text[:idx]
}
used += w
}
return text
}
func runeWidth(r rune) int {
if r == 0 {
return 0
}
if r < 0x1100 {
return 1
}
switch {
case r >= 0x1100 && r <= 0x115f,
r == 0x2329 || r == 0x232a,
r >= 0x2e80 && r <= 0xa4cf && r != 0x303f,
r >= 0xac00 && r <= 0xd7a3,
r >= 0xf900 && r <= 0xfaff,
r >= 0xfe10 && r <= 0xfe19,
r >= 0xfe30 && r <= 0xfe6f,
r >= 0xff00 && r <= 0xff60,
r >= 0xffe0 && r <= 0xffe6,
r >= 0x20000 && r <= 0x2fffd,
r >= 0x30000 && r <= 0x3fffd:
return 2
}
return 1
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func baseName(path string) string {
if path == "" {
return ""
}
path = strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(path, "/"); idx >= 0 {
return path[idx+1:]
}
return path
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright (c) 2020 tickstep.
//
// 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.
package ui
import "os"
// IsTerminal 判断输出环境,是否是输出到终端界面,如果重定向到文件则不用进行下载统计打印输出了
func IsTerminal(file *os.File) bool {
if file == nil {
return false
}
fi, err := file.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
+32
View File
@@ -0,0 +1,32 @@
//go:build !windows
// Copyright (c) 2020 tickstep.
//
// 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.
package ui
import (
"os"
"golang.org/x/sys/unix"
)
func terminalSize() (int, int) {
ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ)
if err != nil || ws == nil || ws.Col == 0 || ws.Row == 0 {
return 80, 24
}
return int(ws.Col), int(ws.Row)
}
func enableVirtualTerminalProcessing() {}
+46
View File
@@ -0,0 +1,46 @@
//go:build windows
// Copyright (c) 2020 tickstep.
//
// 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.
package ui
import (
"os"
"golang.org/x/sys/windows"
)
func terminalSize() (int, int) {
handle := windows.Handle(os.Stdout.Fd())
var info windows.ConsoleScreenBufferInfo
if err := windows.GetConsoleScreenBufferInfo(handle, &info); err != nil {
return 80, 24
}
width := int(info.Window.Right - info.Window.Left + 1)
height := int(info.Window.Bottom - info.Window.Top + 1)
if width <= 0 || height <= 0 {
return 80, 24
}
return width, height
}
func enableVirtualTerminalProcessing() {
handle := windows.Handle(os.Stdout.Fd())
var mode uint32
if err := windows.GetConsoleMode(handle, &mode); err != nil {
return
}
mode |= windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING
_ = windows.SetConsoleMode(handle, mode)
}
+168 -5
View File
@@ -4,7 +4,7 @@
// 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
// 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,
@@ -16,15 +16,13 @@ package utils
import (
"compress/gzip"
"crypto/md5"
"encoding/json"
"flag"
"fmt"
jsoniter "github.com/json-iterator/go"
uuid "github.com/satori/go.uuid"
"github.com/tickstep/aliyunpan-api/aliyunpan"
"github.com/tickstep/library-go/ids"
"io"
"io/ioutil"
"math"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
@@ -34,6 +32,11 @@ import (
"strconv"
"strings"
"time"
jsoniter "github.com/json-iterator/go"
uuid "github.com/satori/go.uuid"
"github.com/tickstep/aliyunpan-api/aliyunpan"
"github.com/tickstep/library-go/ids"
)
// TrimPathPrefix 去除目录的前缀
@@ -111,9 +114,22 @@ func TriggerOnSync(f func()) {
}
func ParseVersionNum(versionStr string) int {
// 解析版本号字符串
// 正式版本号:v0.3.5
// 开发版本号:v0.3.5-dev
versionStr = strings.ReplaceAll(versionStr, "-dev", "")
versionStr = strings.ReplaceAll(versionStr, "v", "")
// 适配版本号:v0.3.5-1
if strings.Contains(versionStr, "-") {
versionStr = strings.Split(versionStr, "-")[0]
}
// 解析版本号数值
versionParts := strings.Split(versionStr, ".")
if len(versionParts) < 3 {
return 0
}
verNum := parseInt(versionParts[0])*1e4 + parseInt(versionParts[1])*1e2 + parseInt(versionParts[2])
return verNum
}
@@ -281,3 +297,150 @@ func ResizeUploadBlockSize(fileSize, defaultBlockSize int64) int64 {
sizeOfMB := int64(math.Ceil(float64(fileSize) / float64(aliyunpan.MaxPartNum) / 1024.0))
return sizeOfMB * 1024
}
// UnixTime2LocalFormatStr 时间戳转换为东8区时间字符串
func UnixTime2LocalFormatStr(unixTime int64) string {
t := time.Unix(unixTime, 0)
cz := time.FixedZone("CST", 8*3600) // 东8区
return t.In(cz).Format("2006-01-02T15:04:05.000Z")
}
// FormatSpeedFixedWidth 格式化速度显示
// speed: 速度值,单位 Byte/s
// 返回格式: " xxx.xx MB/s" (固定长度,左侧用空格补齐)
func FormatSpeedFixedWidth(speed int64, totalWidth int) string {
const (
kb = 1024.0
mb = kb * 1024
gb = mb * 1024
tb = gb * 1024
)
var value float64
var unit string
speedFloat := float64(speed)
switch {
case speedFloat >= tb:
value = speedFloat / tb
unit = "TB/s"
case speedFloat >= gb:
value = speedFloat / gb
unit = "GB/s"
case speedFloat >= mb:
value = speedFloat / mb
unit = "MB/s"
case speedFloat >= kb:
value = speedFloat / kb
unit = "KB/s"
default:
value = speedFloat
unit = "B/s"
}
// 格式化为两位小数
formatted := fmt.Sprintf("%.2f %s", value, unit)
// 如果不足指定宽度,左侧用空格补齐
if len(formatted) < totalWidth {
spaces := strings.Repeat(" ", totalWidth-len(formatted))
return spaces + formatted
}
return formatted
}
func GetPublicIP() (string, error) {
type IPResponse struct {
Origin string `json:"origin"`
}
resp, err := http.Get("https://httpbin.org/ip")
if err != nil {
return "", fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP 状态码异常: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取响应体失败: %w", err)
}
var ipResp IPResponse
if err := json.Unmarshal(body, &ipResp); err != nil {
return "", fmt.Errorf("解析 JSON 失败: %w", err)
}
if ipResp.Origin == "" {
return "", fmt.Errorf("响应中无 IP 信息")
}
// 取第一个 IP(处理代理/X-Forwarded-For 情况)
ip := strings.TrimSpace(strings.Split(ipResp.Origin, ",")[0])
return ip, nil
}
// GetExpiredTimeSecFromOSSUrl 从OSS URL中解析出统一的过期时间戳(秒)
func GetExpiredTimeSecFromOSSUrl(rawURL string) (int64, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return 0, fmt.Errorf("failed to parse URL: %w", err)
}
query := parsedURL.Query()
sigVersion := query.Get("x-oss-signature-version")
var expires int64 = 0
// 检测版本标识(不区分大小写)
if strings.Contains(sigVersion, "OSS4") {
// V4 签名:x-oss-expires 为相对秒数,需要结合 x-oss-date
expiresStr := query.Get("x-oss-expires")
if expiresStr == "" {
return 0, fmt.Errorf("V4 signature: 'x-oss-expires' parameter not found")
}
relSec, err := strconv.ParseInt(expiresStr, 10, 64)
if err != nil {
return 0, fmt.Errorf("V4 signature: invalid 'x-oss-expires' value: %w", err)
}
dateStr := query.Get("x-oss-date")
if dateStr == "" {
return 0, fmt.Errorf("V4 signature: 'x-oss-date' parameter not found")
}
// 解析 ISO8601 UTC 时间,例如 "20231220T083018Z"
t, err := time.Parse("20060102T150405Z", dateStr)
if err != nil {
return 0, fmt.Errorf("V4 signature: failed to parse 'x-oss-date': %w", err)
}
expires = t.Unix() + relSec
} else if strings.Contains(sigVersion, "OSS3") || strings.Contains(sigVersion, "OSS2") {
// V2 / V3:x-oss-expires 为绝对时间戳
expiresStr := query.Get("x-oss-expires")
if expiresStr == "" {
return 0, fmt.Errorf("V2/V3 signature: 'x-oss-expires' parameter not found")
}
expires, err = strconv.ParseInt(expiresStr, 10, 64)
if err != nil {
return 0, fmt.Errorf("V2/V3 signature: invalid 'x-oss-expires' value: %w", err)
}
} else {
// 默认为 V1 签名(没有 x-oss-signature-version 或为空)
expiresStr := query.Get("Expires")
if expiresStr == "" {
return 0, fmt.Errorf("V1 signature: 'Expires' parameter not found")
}
expires, err = strconv.ParseInt(expiresStr, 10, 64)
if err != nil {
return 0, fmt.Errorf("V1 signature: invalid 'Expires' value: %w", err)
}
}
return expires, nil
}
+15
View File
@@ -51,3 +51,18 @@ func TestResizeUploadBlockSize_ReturnNewBlockSize(t *testing.T) {
fileSize := int64(107374182400) // 100GB
fmt.Println(ResizeUploadBlockSize(fileSize, 10*MB)) // 10737664 = 10486KB
}
func TestParseVersionNum(t *testing.T) {
fmt.Println(ParseVersionNum("v1.3.55"))
}
func TestParseVersionNum2(t *testing.T) {
fmt.Println(ParseVersionNum("v0.3.10-dev"))
}
func TestParseVersionNum3(t *testing.T) {
fmt.Println(ParseVersionNum("v0.3.5-1"))
}
func TestParseVersionNum4(t *testing.T) {
fmt.Println(ParseVersionNum("v"))
}
+59
View File
@@ -0,0 +1,59 @@
package nets
import (
"net"
"strings"
)
type NetInterfaceInfoList []*NetInterfaceInfo
type NetInterfaceInfo struct {
Name string `json:"name"`
Mac string `json:"mac"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
}
func (n *NetInterfaceInfoList) GetByName(name string) *NetInterfaceInfo {
for _, v := range *n {
if v.Name == name {
return v
}
}
return nil
}
// GetLocalNetInterfaceAddress 获取本地接口地址信息
func GetLocalNetInterfaceAddress() (NetInterfaceInfoList, error) {
interfaces, err := net.Interfaces()
if err != nil {
return nil, err
}
netList := NetInterfaceInfoList{}
for _, inter := range interfaces {
netInfo := &NetInterfaceInfo{
Name: inter.Name,
Mac: inter.HardwareAddr.String(),
IPv4: "",
IPv6: "",
}
addrs, err2 := inter.Addrs()
if err2 != nil {
continue
}
for _, address := range addrs {
if ipnet, ok := address.(*net.IPNet); ok {
if ipnet.IP.To4() != nil { // ipv4
if ipnet.IP.String() != "" {
netInfo.IPv4 = ipnet.IP.String()
}
} else if ipnet.IP.To16() != nil && !ipnet.IP.IsLoopback() { // ipv6
if ipnet.IP.String() != "" && !strings.HasPrefix(ipnet.IP.String(), "fe80") { // 去掉本地IPv6地址
netInfo.IPv6 = ipnet.IP.String()
}
}
}
}
netList = append(netList, netInfo)
}
return netList, nil
}
+402 -176
View File
@@ -15,7 +15,6 @@ package main
import (
"fmt"
"github.com/tickstep/aliyunpan/internal/global"
"io/ioutil"
"os"
"os/exec"
@@ -37,9 +36,12 @@ import (
"github.com/tickstep/aliyunpan/cmder/cmdutil"
"github.com/tickstep/aliyunpan/cmder/cmdutil/escaper"
"github.com/tickstep/aliyunpan/internal/command"
"github.com/tickstep/aliyunpan/internal/command_local"
"github.com/tickstep/aliyunpan/internal/config"
"github.com/tickstep/aliyunpan/internal/global"
"github.com/tickstep/aliyunpan/internal/panupdate"
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/aliyunpan/library/homedir"
"github.com/tickstep/library-go/converter"
"github.com/tickstep/library-go/logger"
"github.com/urfave/cli"
@@ -52,7 +54,7 @@ const (
var (
// Version 版本号
Version = "v0.3.0"
Version = "v0.4.0"
// 命令历史文件
historyFilePath = filepath.Join(config.GetConfigDir(), "aliyunpan_command_history.txt")
@@ -62,6 +64,11 @@ var (
)
func init() {
b, ok := os.LookupEnv("ALIYUNPAN_NONE_OPENAPI")
if ok && b == "1" {
global.IsSupportNoneOpenApiCommands = true
}
global.AppVersion = Version
cmdutil.ChWorkDir()
@@ -78,16 +85,14 @@ func init() {
func checkLoginExpiredAndRelogin() {
command.ReloadConfigFunc(nil)
activeUser := config.Config.ActiveUser()
if activeUser == nil || activeUser.UserId == "" {
// maybe expired, try to login
command.TryLogin()
} else {
// 尝试登录
activeUser := command.TryLogin()
if activeUser != nil && activeUser.UserId != "" {
// 刷新过期Token并保存到配置文件
command.RefreshWebTokenInNeed(activeUser, config.Config.DeviceName)
command.RefreshWebTokenInNeed(activeUser)
command.RefreshOpenTokenInNeed(activeUser)
command.SaveConfigFunc(nil)
}
command.SaveConfigFunc(nil)
}
func main() {
@@ -97,20 +102,7 @@ func main() {
checkLoginExpiredAndRelogin()
// check token expired task
go func() {
for {
// Token刷新进程,不管是CLI命令行模式,还是直接命令模式,本刷新任务都会执行
time.Sleep(time.Duration(1) * time.Minute)
//time.Sleep(time.Duration(5) * time.Second)
for _, u := range config.Config.UserList {
if u.UserId == config.Config.ActiveUID {
if u.PanClient() != nil {
checkLoginExpiredAndRelogin()
}
}
}
}
}()
command.AutomaticallyRefreshTokenTask() // Token刷新进程,不管是CLI命令行模式,还是直接命令模式,本刷新任务都会执行
app := cli.NewApp()
cmder.SetApp(app)
@@ -118,15 +110,19 @@ func main() {
app.Name = "aliyunpan"
app.Version = Version
app.Author = "tickstep/aliyunpan: https://github.com/tickstep/aliyunpan"
app.Copyright = "(c) 2021-2024 tickstep."
app.Copyright = "(c) 2021-2026 tickstep."
app.Usage = "阿里云盘客户端 for " + runtime.GOOS + "/" + runtime.GOARCH
app.Description = `aliyunpan 是一款阿里云盘命令行客户端工具, 为操作阿里云盘, 提供实用功能。
支持同步备份功能,支持备份本地文件到云盘,备份云盘文件到本地,双向同步备份。
支持同步备份功能,支持备份本地文件到云盘,备份云盘文件到本地。
具体功能, 参见 COMMANDS 列表。
支持设置环境变量 ALIYUNPAN_CONFIG_DIR 更改配置文件存储路径:
export ALIYUNPAN_CONFIG_DIR=/etc/aliyunpan/config
支持XDG目录规范:
默认XDG配置目录:$XDG_CONFIG_HOME/aliyunpan
默认XDG下载目录:$XDG_DOWNLOAD_DIR
------------------------------------------------------------------------------
前往 https://github.com/tickstep/aliyunpan 以获取更多帮助信息!
前往 https://github.com/tickstep/aliyunpan/releases 以获取程序更新信息!
@@ -176,23 +172,33 @@ func main() {
// tab 自动补全命令
line.State.SetCompleter(func(line string) (s []string) {
var (
lineArgs = args.Parse(line)
numArgs = len(lineArgs)
acceptCompleteFileCommands = []string{
"cd", "cp", "xcp", "download", "ls", "mkdir", "mv", "pwd", "rename", "rm", "share", "save", "upload", "login", "loglist", "logout",
"clear", "quit", "exit", "quota", "who", "sign", "update", "who", "su", "config",
"drive", "export", "import", "sync", "tree",
}
closed = strings.LastIndex(line, " ") == len(line)-1
)
// 命令补全处理器
// line: 输入的命令行完整字符,包括命令名称、空格、(完整或部分)路径字符
// s: 返回的满足符合条件的提示列表,命令名称、或者文件完整路径
var (
lineArgs = args.Parse(line)
numArgs = len(lineArgs)
// 支持TAB补全文件路径的命令
acceptCompleteFilePanCommands = []string{ // 云盘命令
"cd", "cp", "xcp", "download", "ls", "mkdir", "mv", "rename", "rm", "upload", "tree",
}
acceptCompleteFileLocalCommands = []string{ // 本地命令
"lcd", "lls",
}
closed = false
)
// 检查是否是命令输入的结尾
if strings.LastIndex(line, " ") == len(line)-1 {
closed = true
}
// 开始补全命令名称
for _, cmd := range app.Commands {
for _, name := range cmd.Names() {
if !strings.HasPrefix(name, line) {
continue
}
s = append(s, name+" ")
}
}
@@ -206,108 +212,25 @@ func main() {
}
}
// 开始补全文件路径
thisCmd := app.Command(lineArgs[0])
if thisCmd == nil {
return
}
if !cmdutil.ContainsString(acceptCompleteFileCommands, thisCmd.FullName()) {
return
}
var (
activeUser = config.Config.ActiveUser()
runeFunc = unicode.IsSpace
cmdRuneFunc = func(r rune) bool {
switch r {
case '\'', '"':
return true
}
return unicode.IsSpace(r)
// 检测输入的命令是否支持tab补全路径
if cmdutil.ContainsString(acceptCompleteFilePanCommands, thisCmd.FullName()) {
// 云盘文件路径的补全
if thisCmd.FullName() == "ls" || thisCmd.FullName() == "cd" {
// 只需要补全文件夹路径
return panFilePathCompleter(line, lineArgs, numArgs, true)
} else {
// 文件夹、文件都需要
return panFilePathCompleter(line, lineArgs, numArgs, false)
}
targetPath string
)
if activeUser == nil {
return
}
if !closed {
targetPath = lineArgs[numArgs-1]
escaper.EscapeStringsByRuneFunc(lineArgs[:numArgs-1], runeFunc) // 转义
} else {
escaper.EscapeStringsByRuneFunc(lineArgs, runeFunc)
}
switch {
case targetPath == "." || strings.HasSuffix(targetPath, "/."):
s = append(s, line+"/")
return
case targetPath == ".." || strings.HasSuffix(targetPath, "/.."):
s = append(s, line+"/")
return
}
var (
targetDir string
isAbs = path.IsAbs(targetPath)
isDir = strings.LastIndex(targetPath, "/") == len(targetPath)-1
)
if isAbs {
targetDir = path.Dir(targetPath)
} else {
wd := "/"
if activeUser.IsFileDriveActive() {
wd = activeUser.Workdir
} else if activeUser.IsResourceDriveActive() {
wd = activeUser.ResourceWorkdir
} else if activeUser.IsAlbumDriveActive() {
wd = activeUser.AlbumWorkdir
}
targetDir = path.Join(wd, targetPath)
if !isDir {
targetDir = path.Dir(targetDir)
}
}
// tab键补全路径
files, err := activeUser.CacheFilesDirectoriesList(targetDir)
if err != nil {
return
}
for _, file := range files {
if file == nil {
continue
}
var (
appendLine string
)
// 已经有的情况
if !closed {
if !strings.HasPrefix(file.Path, path.Clean(path.Join(targetDir, path.Base(targetPath)))) {
if path.Base(targetDir) == path.Base(targetPath) {
appendLine = strings.Join(append(lineArgs[:numArgs-1], escaper.EscapeByRuneFunc(path.Join(targetPath, file.FileName), cmdRuneFunc)), " ")
goto handle
}
continue
}
appendLine = strings.Join(append(lineArgs[:numArgs-1], escaper.EscapeByRuneFunc(path.Clean(path.Join(path.Dir(targetPath), file.FileName)), cmdRuneFunc)), " ")
goto handle
}
// 没有的情况
appendLine = strings.Join(append(lineArgs, escaper.EscapeByRuneFunc(file.FileName, cmdRuneFunc)), " ")
goto handle
handle:
if file.IsFolder() {
s = append(s, appendLine+"/")
continue
}
s = append(s, appendLine+" ")
continue
} else if cmdutil.ContainsString(acceptCompleteFileLocalCommands, thisCmd.FullName()) {
// 本地文件路径的补全
return localFilePathCompleter(line, lineArgs, numArgs, true)
}
return
})
@@ -316,8 +239,10 @@ func main() {
fmt.Printf("提示: Ctrl + A / E 跳转命令 首 / 尾.\n")
fmt.Printf("提示: 输入 help 获取帮助.\n")
// check update
// 刷新配置文件
command.ReloadConfigFunc(c)
// check update
if config.Config.UpdateCheckInfo.LatestVer != "" {
if utils.ParseVersionNum(config.Config.UpdateCheckInfo.LatestVer) > utils.ParseVersionNum(global.AppVersion) {
fmt.Printf("\n当前的软件版本为:%s, 现在有新版本 %s 可供更新,强烈推荐进行更新!(可以输入 update 命令进行更新)\n\n",
@@ -325,6 +250,7 @@ func main() {
}
}
go func() {
// 新版本检测异步任务
latestCheckTime := config.Config.UpdateCheckInfo.CheckTime
nowTime := time.Now().Unix()
secsOf12Hour := int64(43200)
@@ -342,14 +268,22 @@ func main() {
}
}()
for {
for { // 命令行交互进程,每一次命令执行都会进入到这个for循环
var (
prompt string
activeUser = config.Config.ActiveUser()
)
if activeUser == nil {
// 尝试重新登录并获取新的token
activeUser = command.TryLogin()
// 自动登录成功
if activeUser != nil {
logger.Verboseln("自动重新登录成功")
// 保存新token到配置文件
command.SaveConfigFunc(c)
}
}
if activeUser != nil && activeUser.Nickname != "" {
@@ -372,14 +306,14 @@ func main() {
prompt = app.Name + " > "
}
commandLine, err := line.State.Prompt(prompt)
switch err {
commandLine, err1 := line.State.Prompt(prompt)
switch err1 {
case liner.ErrPromptAborted:
return
case nil:
// continue
default:
fmt.Println(err)
fmt.Println(err1)
return
}
@@ -424,9 +358,6 @@ func main() {
// 获取当前帐号空间配额 quota
command.CmdQuota(),
// Token操作
//command.CmdToken(),
// 切换工作目录 cd
command.CmdCd(),
@@ -445,12 +376,6 @@ func main() {
// 删除文件/目录 rm
command.CmdRm(),
//// 拷贝文件/目录 cp
//command.CmdCp(),
// 备份盘和资源库之间拷贝文件 xcp
command.CmdXcp(),
// 复制文件/目录 cp
command.CmdCp(),
@@ -460,47 +385,26 @@ func main() {
// 重命名文件 rename
command.CmdRename(),
// 分享文件/目录 share
command.CmdShare(),
// 保存分享文件/目录 save
command.CmdSave(),
// 同步备份 sync
command.CmdSync(),
// 上传文件/目录 upload
command.CmdUpload(),
// 手动秒传
//command.CmdRapidUpload(),
// 下载文件/目录 download
command.CmdDownload(),
// 获取文件下载链接
//command.CmdLocateUrl(),
// 导出文件/目录元数据 export
//command.CmdExport(),
// 导入文件 import
//command.CmdImport(),
// webdav服务(depressed)
//command.CmdWebdav(),
// 回收站
command.CmdRecycle(),
// 显示和修改程序配置项 config
command.CmdConfig(),
// 工具箱 tool
command.CmdTool(),
// 分享文件/目录 share
command.CmdShare(),
// 相簿
//command.CmdAlbum(), // 先移除,需要重构&适配
command.CmdAlbum(),
// 显示命令历史
{
@@ -647,6 +551,67 @@ func main() {
},
},
// 显示程序环境变量
{
Name: "env",
Usage: "显示程序环境变量",
Description: ` ALIYUNPAN_CONFIG_DIR: 配置文件路径
ALIYUNPAN_DOWNLOAD_DIR: 配置下载路径
ALIYUNPAN_VERBOSE: 是否启用调试
XDG_CONFIG_HOME: XDG配置主目录
XDG_DOWNLOAD_DIR: XDG配置下载目录
`,
Category: "其他",
Action: func(c *cli.Context) error {
envStr := "%s=%s\n"
envVar, ok := os.LookupEnv(config.EnvVerbose)
if ok {
if envVar == "1" {
fmt.Printf(envStr, config.EnvVerbose, "1")
} else {
fmt.Printf(envStr, config.EnvVerbose, "0")
}
} else {
fmt.Printf(envStr, config.EnvVerbose, "0")
}
envVar, ok = os.LookupEnv(config.EnvConfigDir)
if ok {
fmt.Printf(envStr, config.EnvConfigDir, envVar)
} else {
fmt.Printf(envStr, config.EnvConfigDir, config.GetConfigDir())
}
envVar, ok = os.LookupEnv(config.EnvDownloadDir)
if ok {
fmt.Printf(envStr, config.EnvDownloadDir, envVar)
} else {
fmt.Printf(envStr, config.EnvDownloadDir, config.GetDefaultDownloadDir())
}
envVar, ok = os.LookupEnv("XDG_CONFIG_HOME")
if ok {
fmt.Printf(envStr, "XDG_CONFIG_HOME", envVar)
} else {
fmt.Printf(envStr, "XDG_CONFIG_HOME", "")
}
envVar, ok = os.LookupEnv("XDG_DOWNLOAD_DIR")
if ok {
fmt.Printf(envStr, "XDG_DOWNLOAD_DIR", envVar)
} else {
fmt.Printf(envStr, "XDG_DOWNLOAD_DIR", "")
}
return nil
},
},
// 当前本地工作目录
command_local.CmdLocalPwd(),
// 切换本地工作目录
command_local.CmdLocalCd(),
// 展示本地目录文件列表
command_local.CmdLocalLs(),
// 调试用 debug
//{
// Name: "debug",
@@ -654,11 +619,8 @@ func main() {
// Usage: "开发调试用",
// Description: "",
// Category: "debug",
// Before: cmder.ReloadConfigFunc,
// Before: command.ReloadConfigFunc,
// Action: func(c *cli.Context) error {
// os.Setenv(config.EnvVerbose, "1")
// logger.IsVerbose = true
// fmt.Println("显示调试日志", logger.IsVerbose)
// return nil
// },
// Flags: []cli.Flag{
@@ -676,7 +638,271 @@ func main() {
//},
}
// 隐藏不支持的命令
if global.IsSupportNoneOpenApiCommands {
hiddenCommands := []cli.Command{
// 备份盘和资源库之间拷贝文件 xcp
command.CmdXcp(),
// 分享文件/目录 sharew,web端接口的分享功能
command.CmdShareWeb(),
// 保存分享文件/目录 save
command.CmdSave(),
// 回收站
command.CmdRecycle(),
// 相簿
//command.CmdAlbum(),
}
app.Commands = append(app.Commands, hiddenCommands...)
}
sort.Sort(cli.FlagsByName(app.Flags))
sort.Sort(cli.CommandsByName(app.Commands))
app.Run(os.Args)
}
// panFilePathCompleter 云盘文件路径Tab补全处理器
func panFilePathCompleter(line string, lineArgs []string, numArgs int, onlyNeedFolder bool) (s []string) {
return filePathCompleterProcessor("pan", line, lineArgs, numArgs, onlyNeedFolder, getPanFileListByTargetPath)
}
// localFilePathCompleter 本地文件路径Tab补全处理器
func localFilePathCompleter(line string, lineArgs []string, numArgs int, onlyNeedFolder bool) (s []string) {
return filePathCompleterProcessor("local", line, lineArgs, numArgs, onlyNeedFolder, getLocalFileListByTargetPath)
}
// filePathCompleterProcessor 路径自动补充处理器
// 典型测试样例
// lls d:\
// lls ~/
// lls ~\
// lls ~\Doc
// ls ~/
// ls<空格><tab>
// ls app<tab>
// ls /doc<tab>
func filePathCompleterProcessor(targetType string, line string, lineArgs []string, numArgs int, onlyNeedFolder bool, fileListFunc getFileListByTargetPath) (s []string) {
var (
runeFunc = unicode.IsSpace
cmdRuneFunc = func(r rune) bool {
switch r {
case '\'', '"':
return true
}
return unicode.IsSpace(r)
}
targetPath string
// 路径的结尾是否是有前缀,如果有前缀则文件、文件夹名称需要匹配前缀
endPathHasPrefixStr = false
)
if numArgs > 1 {
targetPath = lineArgs[numArgs-1]
} else {
// 没有输入文件路径的时候,默认为当前目录
targetPath = "./"
line += targetPath
lineArgs = append(lineArgs, targetPath)
numArgs = 2
}
switch {
case targetPath == "." || strings.HasSuffix(targetPath, "/."):
s = append(s, line+"/")
return
case targetPath == ".." || strings.HasSuffix(targetPath, "/.."):
s = append(s, line+"/")
return
}
// 主目录"~"特殊路径处理
if strings.HasPrefix(targetPath, "~") {
if targetType == "local" {
if p, e := homedir.Expand(targetPath); e == nil {
targetPath = p
}
} else {
// 云盘的主目录~默认为根目录
targetPath = strings.ReplaceAll(strings.Replace(targetPath, "~", "/", 1), "//", "/")
}
}
// 完整路径是需要“/”作为结尾,否则默认用户只输入了一部分文件名,输入的部分字符作为文件名前缀进行搜索匹配
// 如果用户输入:/Users/tickstep/doc,则自动补全的路径需要在/Users/tickstep/目录下搜索并且文件名必须包含doc这个前缀,例如:/Users/tickstep/document/ 和 /Users/tickstep/doc/ 都是匹配项
// 如果用户输入:/Users/tickstep/,则自动补全的路径需要在/Users/tickstep/目录下搜索即可,例如:/Users/tickstep/photo/ 和 /Users/tickstep/doc/ 都是匹配项
if strings.HasSuffix(targetPath, "/") {
endPathHasPrefixStr = false
} else {
endPathHasPrefixStr = true
}
if endPathHasPrefixStr {
escaper.EscapeStringsByRuneFunc(lineArgs[:numArgs-1], runeFunc) // 转义
} else {
escaper.EscapeStringsByRuneFunc(lineArgs, runeFunc)
}
targetFullPath, targetDir, files := fileListFunc(targetPath)
for _, file := range files {
if file == nil {
continue
}
if onlyNeedFolder && !file.IsFolder {
// 过滤文件,只需要文件夹
continue
}
var (
appendLine string
)
// 用户已经输入了部分路径字符,搜索匹配文件路径需要匹配用户输入的路径字符串前缀
if endPathHasPrefixStr {
fp := command_local.LocalPathClean(file.Path)
tp := command_local.LocalPathClean(targetFullPath)
if !strings.HasPrefix(fp, tp) {
if command_local.LocalPathBase(targetDir) == command_local.LocalPathBase(targetFullPath) {
appendLine = strings.Join(append(lineArgs[:numArgs-1], escaper.EscapeByRuneFunc(path.Join(targetPath, file.FileName), cmdRuneFunc)), " ")
goto handle
}
continue
}
appendLine = strings.Join(append(lineArgs[:numArgs-1], escaper.EscapeByRuneFunc(command_local.LocalPathClean(path.Join(command_local.LocalPathDir(targetPath), file.FileName)), cmdRuneFunc)), " ")
goto handle
}
// 没有输入任何路径前缀字符,默认都符合条件,全部显示到提示列表
appendLine = strings.Join(append(lineArgs[:numArgs-1], escaper.EscapeByRuneFunc(command_local.LocalPathClean(path.Join(targetPath, file.FileName)), cmdRuneFunc)), " ")
goto handle
handle:
if file.IsFolder {
s = append(s, appendLine+"/")
continue
}
s = append(s, appendLine+" ")
continue
}
// 没有命中的路径,返回当前用户输入的内容即可
if len(s) == 0 {
s = append(s, line)
}
return
}
type fileEntity struct {
FileName string
Path string
IsFolder bool
}
type getFileListByTargetPath func(targetPath string) (string, string, []*fileEntity)
// getPanFileListByTargetPath 通过路径获取云盘文件列表
func getPanFileListByTargetPath(targetPath string) (string, string, []*fileEntity) {
var (
activeUser = config.Config.ActiveUser()
targetDir string
targetFullPath string
isAbs = path.IsAbs(targetPath)
isDir = strings.LastIndex(targetPath, "/") == len(targetPath)-1
r = []*fileEntity{}
)
if activeUser == nil {
return targetFullPath, targetDir, r
}
if isAbs {
targetDir = path.Dir(targetPath)
targetFullPath = targetPath
} else {
wd := "/"
if activeUser.IsFileDriveActive() {
wd = activeUser.Workdir
} else if activeUser.IsResourceDriveActive() {
wd = activeUser.ResourceWorkdir
} else if activeUser.IsAlbumDriveActive() {
wd = activeUser.AlbumWorkdir
}
targetFullPath = path.Join(wd, targetPath)
targetDir = targetFullPath
if !isDir {
targetDir = path.Dir(targetDir)
}
}
files, err := activeUser.CacheFilesDirectoriesList(targetDir)
if err != nil {
return targetFullPath, targetDir, r
}
for _, file := range files {
if file == nil {
continue
}
r = append(r, &fileEntity{
FileName: file.FileName,
Path: file.Path,
IsFolder: file.IsFolder(),
})
}
return targetFullPath, targetDir, r
}
// getLocalFileListByTargetPath 通过路径获取本地文件列表
func getLocalFileListByTargetPath(targetPath string) (string, string, []*fileEntity) {
targetPath = strings.ReplaceAll(targetPath, "\\", "/")
var (
targetDir string
targetFullPath string
isAbs = filepath.IsAbs(targetPath)
isDir = strings.HasSuffix(targetPath, "/")
r = []*fileEntity{}
)
if isAbs {
// 绝对路径
targetDir = targetPath
targetFullPath = targetPath
if !isDir {
targetDir = command_local.LocalPathDir(targetDir)
}
} else {
// 相对路径
targetFullPath = command_local.LocalPathJoin(targetPath)
targetDir = targetFullPath
if !isDir {
targetDir = command_local.LocalPathDir(targetDir)
}
}
// windows
if runtime.GOOS == "windows" {
if strings.HasSuffix(targetDir, ":") {
targetDir += "/"
}
}
fileEntryList, err := os.ReadDir(targetDir)
if err != nil {
return targetFullPath, targetDir, r
}
for _, file := range fileEntryList {
if file == nil {
continue
}
if runtime.GOOS == "windows" { // windows隐藏文件
if strings.HasPrefix(file.Name(), "$") {
continue
}
} else { // linux / macOS隐藏文件
if strings.HasPrefix(file.Name(), ".") {
continue
}
}
r = append(r, &fileEntity{
FileName: file.Name(),
Path: path.Join(targetDir, file.Name()),
IsFolder: file.IsDir(),
})
}
return targetFullPath, targetDir, r
}
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -2,13 +2,13 @@
"FixedFileInfo": {
"FileVersion": {
"Major": 0,
"Minor": 3,
"Minor": 4,
"Patch": 0,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 3,
"Minor": 4,
"Patch": 0,
"Build": 0
},
@@ -22,14 +22,14 @@
"Comments": "",
"CompanyName": "tickstep",
"FileDescription": "阿里云盘客户端",
"FileVersion": "v0.3.0",
"FileVersion": "v0.4.0",
"InternalName": "",
"LegalCopyright": "© 2021-2024 tickstep.",
"LegalCopyright": "© 2021-2026 tickstep.",
"LegalTrademarks": "",
"OriginalFilename": "",
"PrivateBuild": "",
"ProductName": "aliyunpan",
"ProductVersion": "v0.3.0",
"ProductVersion": "v0.4.0",
"SpecialBuild": ""
},
"VarFileInfo": {