diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/aliyunpan.exe.manifest b/aliyunpan.exe.manifest
new file mode 100644
index 0000000..05ec4e9
--- /dev/null
+++ b/aliyunpan.exe.manifest
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
\ No newline at end of file
diff --git a/assets/aliyunpan.ico b/assets/aliyunpan.ico
new file mode 100755
index 0000000..5228989
Binary files /dev/null and b/assets/aliyunpan.ico differ
diff --git a/assets/aliyunpan.png b/assets/aliyunpan.png
new file mode 100755
index 0000000..9e4aaea
Binary files /dev/null and b/assets/aliyunpan.png differ
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..9b704d5
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,139 @@
+#!/bin/sh
+
+# how to use
+# for macOS & linux, run this command in shell
+# ./build.sh v0.1.0
+
+name="aliyunpan"
+version=$1
+
+if [ "$1" = "" ]; then
+ version=v1.0.0
+fi
+
+output="out"
+
+default_golang() {
+ export GOROOT=/usr/local/go
+ go=$GOROOT/bin/go
+}
+
+Build() {
+ default_golang
+ goarm=$4
+ if [ "$4" = "" ]; then
+ goarm=7
+ fi
+
+ echo "Building $1..."
+ export GOOS=$2 GOARCH=$3 GO386=sse2 CGO_ENABLED=0 GOARM=$4
+ if [ $2 = "windows" ]; then
+ goversioninfo -o=resource_windows_386.syso
+ goversioninfo -64 -o=resource_windows_amd64.syso
+ $go build -ldflags "-X main.Version=$version -s -w" -o "$output/$1/$name.exe"
+ RicePack $1 $name.exe
+ else
+ $go build -ldflags "-X main.Version=$version -s -w" -o "$output/$1/$name"
+ RicePack $1 $name
+ fi
+
+ Pack $1 $2
+}
+
+AndroidBuild() {
+ default_golang
+ echo "Building $1..."
+ export GOOS=$2 GOARCH=$3 GOARM=$4 CGO_ENABLED=1
+ $go build -ldflags "-X main.Version=$version -s -w -linkmode=external -extldflags=-pie" -o "$output/$1/$name"
+
+ RicePack $1 $name
+ Pack $1 $2
+}
+
+IOSBuild() {
+ default_golang
+ echo "Building $1..."
+ mkdir -p "$output/$1"
+ cd "$output/$1"
+ export CC=/usr/local/go/misc/ios/clangwrap.sh GOOS=ios GOARCH=arm64 GOARM=7 CGO_ENABLED=1
+ $go build -ldflags "-X main.Version=$version -s -w" -o $name github.com/tickstep/aliyunpan
+ jtool --sign --inplace --ent ../../entitlements.xml $name
+ cd ../..
+ RicePack $1 $name
+ Pack $1 "ios"
+}
+
+# zip 打包
+Pack() {
+ if [ $2 != "windows" ]; then
+ chmod +x "$output/$1/$name"
+ fi
+
+ cp README.md "$output/$1"
+
+ cd $output
+ zip -q -r "$1.zip" "$1"
+
+ # 删除
+ rm -rf "$1"
+
+ cd ..
+}
+
+# rice 打包静态资源
+RicePack() {
+ return # 已取消web功能
+}
+
+# Android
+export ANDROID_NDK_ROOT=/Users/tickstep/Applications/android_ndk/android-ndk-r23-darwin
+CC=$ANDROID_NDK_ROOT/bin/arm-linux-androideabi/bin/clang AndroidBuild $name-$version"-android-api16-armv7" android arm 7
+CC=$ANDROID_NDK_ROOT/bin/aarch64-linux-android/bin/clang AndroidBuild $name-$version"-android-api21-arm64" android arm64 7
+CC=$ANDROID_NDK_ROOT/bin/i686-linux-android/bin/clang AndroidBuild $name-$version"-android-api16-386" android 386 7
+CC=$ANDROID_NDK_ROOT/bin/x86_64-linux-android/bin/clang AndroidBuild $name-$version"-android-api21-amd64" android amd64 7
+
+# iOS
+IOSBuild $name-$version"-ios-arm64"
+
+# OS X / macOS
+Build $name-$version"-darwin-macos-amd64" darwin amd64
+# Build $name-$version"-darwin-macos-386" darwin 386
+Build $name-$version"-darwin-macos-arm64" darwin arm64
+
+# Windows
+Build $name-$version"-windows-x86" windows 386
+Build $name-$version"-windows-x64" windows amd64
+Build $name-$version"-windows-arm" windows arm
+
+# Linux
+Build $name-$version"-linux-386" linux 386
+Build $name-$version"-linux-amd64" linux amd64
+Build $name-$version"-linux-armv5" linux arm 5
+Build $name-$version"-linux-armv7" linux arm 7
+Build $name-$version"-linux-arm64" linux arm64
+GOMIPS=softfloat Build $name-$version"-linux-mips" linux mips
+Build $name-$version"-linux-mips64" linux mips64
+GOMIPS=softfloat Build $name-$version"-linux-mipsle" linux mipsle
+Build $name-$version"-linux-mips64le" linux mips64le
+# Build $name-$version"-linux-ppc64" linux ppc64
+# Build $name-$version"-linux-ppc64le" linux ppc64le
+# Build $name-$version"-linux-s390x" linux s390x
+
+# Others
+# Build $name-$version"-solaris-amd64" solaris amd64
+Build $name-$version"-freebsd-386" freebsd 386
+Build $name-$version"-freebsd-amd64" freebsd amd64
+# Build $name-$version"-freebsd-arm" freebsd arm
+# Build $name-$version"-netbsd-386" netbsd 386
+# Build $name-$version"-netbsd-amd64" netbsd amd64
+# Build $name-$version"-netbsd-arm" netbsd arm
+# Build $name-$version"-openbsd-386" openbsd 386
+# Build $name-$version"-openbsd-amd64" openbsd amd64
+# Build $name-$version"-openbsd-arm" openbsd arm
+# Build $name-$version"-plan9-386" plan9 386
+# Build $name-$version"-plan9-amd64" plan9 amd64
+# Build $name-$version"-plan9-arm" plan9 arm
+# Build $name-$version"-nacl-386" nacl 386
+# Build $name-$version"-nacl-amd64p32" nacl amd64p32
+# Build $name-$version"-nacl-arm" nacl arm
+# Build $name-$version"-dragonflybsd-amd64" dragonfly amd64
diff --git a/cmder/cmder_helper.go b/cmder/cmder_helper.go
new file mode 100644
index 0000000..7e27016
--- /dev/null
+++ b/cmder/cmder_helper.go
@@ -0,0 +1,92 @@
+package cmder
+
+import (
+ "fmt"
+ "github.com/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
+ "github.com/tickstep/aliyunpan/cmder/cmdliner"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/tickstep/library-go/logger"
+ "github.com/urfave/cli"
+ "sync"
+)
+
+var (
+ appInstance *cli.App
+
+ saveConfigMutex *sync.Mutex = new(sync.Mutex)
+
+ ReloadConfigFunc = func(c *cli.Context) error {
+ err := config.Config.Reload()
+ if err != nil {
+ fmt.Printf("重载配置错误: %s\n", err)
+ }
+ return nil
+ }
+
+ SaveConfigFunc = func(c *cli.Context) error {
+ saveConfigMutex.Lock()
+ defer saveConfigMutex.Unlock()
+ err := config.Config.Save()
+ if err != nil {
+ fmt.Printf("保存配置错误: %s\n", err)
+ }
+ return nil
+ }
+)
+
+func SetApp(app *cli.App) {
+ appInstance = app
+}
+
+func App() *cli.App {
+ return appInstance
+}
+
+func DoLoginHelper(refreshToken string) (refreshTokenStr string, webToken aliyunpan.WebLoginToken, error error) {
+ line := cmdliner.NewLiner()
+ defer line.Close()
+
+ if refreshToken == "" {
+ refreshToken, error = line.State.Prompt("请输入RefreshToken, 回车键提交 > ")
+ if error != nil {
+ return
+ }
+ }
+
+ // app login
+ atoken, apperr := aliyunpan.GetAccessTokenFromRefreshToken(refreshToken)
+ if apperr != nil {
+ if apperr.Code == apierror.ApiCodeTokenExpiredCode || apperr.Code == apierror.ApiCodeRefreshTokenExpiredCode {
+ fmt.Println("Token过期,需要重新登录")
+ } else {
+ fmt.Println("Token登录失败:", apperr)
+ }
+ return "", webToken, fmt.Errorf("登录失败")
+ }
+ refreshTokenStr = refreshToken
+ return refreshTokenStr, *atoken, nil
+}
+
+func TryLogin() *config.PanUser {
+ // can do automatically login?
+ for _, u := range config.Config.UserList {
+ if u.UserId == config.Config.ActiveUID {
+ // login
+ _, webToken, err := DoLoginHelper(u.RefreshToken)
+ if err != nil {
+ logger.Verboseln("automatically login error")
+ break
+ }
+ // success
+ u.WebToken = webToken
+
+ // save
+ SaveConfigFunc(nil)
+ // reload
+ ReloadConfigFunc(nil)
+ return config.Config.ActiveUser()
+ }
+ }
+ return nil
+}
\ No newline at end of file
diff --git a/cmder/cmdliner/args/args.go b/cmder/cmdliner/args/args.go
new file mode 100644
index 0000000..20828aa
--- /dev/null
+++ b/cmder/cmdliner/args/args.go
@@ -0,0 +1,97 @@
+// 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 args
+
+import (
+ "strings"
+ "unicode"
+)
+
+const (
+ CharEscape = '\\'
+ CharSingleQuote = '\''
+ CharDoubleQuote = '"'
+ CharBackQuote = '`'
+)
+
+// IsQuote 是否为引号
+func IsQuote(r rune) bool {
+ return r == CharSingleQuote || r == CharDoubleQuote || r == CharBackQuote
+}
+
+// Parse 解析line, 忽略括号
+func Parse(line string) (lineArgs []string) { // 在函数中定义的返回值变量,会自动赋为 zero-value,即相当于 var lineArgs string[]
+ var (
+ rl = []rune(line + " ")
+ buf = strings.Builder{}
+ quoteChar rune
+ nextChar rune
+ escaped bool
+ in bool
+ )
+
+ var (
+ isSpace bool
+ )
+
+ for k, r := range rl {
+ isSpace = unicode.IsSpace(r)
+ if !isSpace && !in {
+ in = true
+ }
+
+ switch {
+ case escaped: // 已转义, 跳过
+ escaped = false
+ //pass
+ case r == CharEscape: // 转义模式
+ if k+1+1 < len(rl) { // 不是最后一个字符, 多+1是因为最后一个空格
+ nextChar = rl[k+1]
+ // 仅支持转义这些字符, 否则原样输出反斜杠
+ if unicode.IsSpace(nextChar) || IsQuote(nextChar) || nextChar == CharEscape {
+ escaped = true
+ continue
+ }
+ }
+ // pass
+ case IsQuote(r):
+ if quoteChar == 0 { //未引
+ quoteChar = r
+ continue
+ }
+
+ if quoteChar == r { //取消引
+ quoteChar = 0
+ continue
+ }
+ case isSpace:
+ if !in { // 忽略多余的空格
+ continue
+ }
+ if quoteChar == 0 { // 未在引号内
+ lineArgs = append(lineArgs, buf.String())
+ buf.Reset()
+ in = false
+ continue
+ }
+ }
+
+ buf.WriteRune(r)
+ }
+
+ // Go 允许在定义函数时,命名返回值,当然这些变量可以在函数中使用。
+ // 在 return 语句中,无需显示的返回这些值,Go 会自动将其返回。当然 return 语句还是必须要写的,否则编译器会报错。
+ // 相当于 return lineArgs
+ return
+}
diff --git a/cmder/cmdliner/clear.go b/cmder/cmdliner/clear.go
new file mode 100644
index 0000000..c8341b4
--- /dev/null
+++ b/cmder/cmdliner/clear.go
@@ -0,0 +1,17 @@
+// +build !windows
+
+package cmdliner
+
+import (
+ "fmt"
+)
+
+// ClearScreen 清空屏幕
+func (pl *CmdLiner) ClearScreen() {
+ ClearScreen()
+}
+
+// ClearScreen 清空屏幕
+func ClearScreen() {
+ fmt.Print("\x1b[H\x1b[2J")
+}
diff --git a/cmder/cmdliner/clear_windows.go b/cmder/cmdliner/clear_windows.go
new file mode 100644
index 0000000..a135281
--- /dev/null
+++ b/cmder/cmdliner/clear_windows.go
@@ -0,0 +1,69 @@
+// 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 cmdliner
+
+import (
+ "syscall"
+ "unsafe"
+)
+
+const (
+ std_output_handle = uint32(-11 & 0xFFFFFFFF)
+)
+
+var (
+ kernel32 = syscall.NewLazyDLL("kernel32.dll")
+
+ procGetStdHandle = kernel32.NewProc("GetStdHandle")
+ procSetConsoleCursorPosition = kernel32.NewProc("SetConsoleCursorPosition")
+ procGetConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo")
+ procFillConsoleOutputCharacter = kernel32.NewProc("FillConsoleOutputCharacterW")
+)
+
+type (
+ coord struct {
+ x, y int16
+ }
+ smallRect struct {
+ left, top, right, bottom int16
+ }
+ consoleScreenBufferInfo struct {
+ dwSize coord
+ dwCursorPosition coord
+ wAttributes int16
+ srWindow smallRect
+ dwMaximumWindowSize coord
+ }
+)
+
+// ClearScreen 清空屏幕
+func (pl *CmdLiner) ClearScreen() {
+ ClearScreen()
+}
+
+// ClearScreen 清空屏幕
+func ClearScreen() {
+ out, _, _ := procGetStdHandle.Call(uintptr(std_output_handle))
+ hOut := syscall.Handle(out)
+
+ var sbi consoleScreenBufferInfo
+ procGetConsoleScreenBufferInfo.Call(uintptr(hOut), uintptr(unsafe.Pointer(&sbi)))
+
+ var numWritten uint32
+ procFillConsoleOutputCharacter.Call(uintptr(hOut), uintptr(' '),
+ uintptr(sbi.dwSize.x)*uintptr(sbi.dwSize.y),
+ 0,
+ uintptr(unsafe.Pointer(&numWritten)))
+ procSetConsoleCursorPosition.Call(uintptr(hOut), 0)
+}
diff --git a/cmder/cmdliner/cmdliner.go b/cmder/cmdliner/cmdliner.go
new file mode 100644
index 0000000..8d5f6d6
--- /dev/null
+++ b/cmder/cmdliner/cmdliner.go
@@ -0,0 +1,82 @@
+// 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 cmdliner
+
+import (
+ "github.com/peterh/liner"
+)
+
+// CmdLiner 封装 *liner.State, 提供更简便的操作
+type CmdLiner struct {
+ State *liner.State
+ History *LineHistory
+
+ tmode liner.ModeApplier
+ lmode liner.ModeApplier
+
+ paused bool
+}
+
+// NewLiner 返回 *CmdLiner, 默认设置允许 Ctrl+C 结束
+func NewLiner() *CmdLiner {
+ pl := &CmdLiner{}
+ pl.tmode, _ = liner.TerminalMode()
+
+ line := liner.NewLiner()
+ pl.lmode, _ = liner.TerminalMode()
+
+ line.SetMultiLineMode(true)
+ line.SetCtrlCAborts(true)
+
+ pl.State = line
+
+ return pl
+}
+
+// Pause 暂停服务
+func (pl *CmdLiner) Pause() error {
+ if pl.paused {
+ panic("CmdLiner already paused")
+ }
+
+ pl.paused = true
+ pl.DoWriteHistory()
+
+ return pl.tmode.ApplyMode()
+}
+
+// Resume 恢复服务
+func (pl *CmdLiner) Resume() error {
+ if !pl.paused {
+ panic("CmdLiner is not paused")
+ }
+
+ pl.paused = false
+
+ return pl.lmode.ApplyMode()
+}
+
+// Close 关闭服务
+func (pl *CmdLiner) Close() (err error) {
+ err = pl.State.Close()
+ if err != nil {
+ return err
+ }
+
+ if pl.History != nil && pl.History.historyFile != nil {
+ return pl.History.historyFile.Close()
+ }
+
+ return nil
+}
diff --git a/cmder/cmdliner/linehistory.go b/cmder/cmdliner/linehistory.go
new file mode 100644
index 0000000..33b5bfb
--- /dev/null
+++ b/cmder/cmdliner/linehistory.go
@@ -0,0 +1,68 @@
+// 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 cmdliner
+
+import (
+ "fmt"
+ "os"
+)
+
+// LineHistory 命令行历史
+type LineHistory struct {
+ historyFilePath string
+ historyFile *os.File
+}
+
+// NewLineHistory 设置历史
+func NewLineHistory(filePath string) (lh *LineHistory, err error) {
+ lh = &LineHistory{
+ historyFilePath: filePath,
+ }
+
+ lh.historyFile, err = os.OpenFile(filePath, os.O_RDWR|os.O_CREATE, 0644)
+ if err != nil {
+ return nil, err
+ }
+
+ return lh, nil
+}
+
+// DoWriteHistory 执行写入历史
+func (pl *CmdLiner) DoWriteHistory() (err error) {
+ if pl.History == nil {
+ return fmt.Errorf("history not set")
+ }
+
+ pl.History.historyFile, err = os.Create(pl.History.historyFilePath)
+ if err != nil {
+ return fmt.Errorf("写入历史错误, %s", err)
+ }
+
+ _, err = pl.State.WriteHistory(pl.History.historyFile)
+ if err != nil {
+ return fmt.Errorf("写入历史错误: %s", err)
+ }
+
+ return nil
+}
+
+// ReadHistory 读取历史
+func (pl *CmdLiner) ReadHistory() (err error) {
+ if pl.History == nil {
+ return fmt.Errorf("history not set")
+ }
+
+ _, err = pl.State.ReadHistory(pl.History.historyFile)
+ return err
+}
diff --git a/cmder/cmdtable/cmdtable.go b/cmder/cmdtable/cmdtable.go
new file mode 100644
index 0000000..c917ac4
--- /dev/null
+++ b/cmder/cmdtable/cmdtable.go
@@ -0,0 +1,33 @@
+// 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 cmdtable
+
+import (
+ "github.com/olekukonko/tablewriter"
+ "io"
+)
+
+type CmdTable struct {
+ *tablewriter.Table
+}
+
+// NewTable 预设了一些配置
+func NewTable(wt io.Writer) CmdTable {
+ tb := tablewriter.NewWriter(wt)
+ tb.SetAutoWrapText(false)
+ tb.SetBorder(false)
+ tb.SetHeaderLine(false)
+ tb.SetColumnSeparator("")
+ return CmdTable{tb}
+}
diff --git a/cmder/cmdutil/addr.go b/cmder/cmdutil/addr.go
new file mode 100644
index 0000000..ac53d47
--- /dev/null
+++ b/cmder/cmdutil/addr.go
@@ -0,0 +1,45 @@
+// 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 cmdutil
+
+import (
+ "net"
+)
+
+// ListAddresses 列出本地可用的 IP 地址
+func ListAddresses() (addresses []string) {
+ iFaces, _ := net.Interfaces()
+ addresses = make([]string, 0, len(iFaces))
+ for k := range iFaces {
+ iFaceAddrs, _ := iFaces[k].Addrs()
+ for l := range iFaceAddrs {
+ switch v := iFaceAddrs[l].(type) {
+ case *net.IPNet:
+ addresses = append(addresses, v.IP.String())
+ case *net.IPAddr:
+ addresses = append(addresses, v.IP.String())
+ }
+ }
+ }
+ return
+}
+
+// ParseHost 解析地址中的host
+func ParseHost(address string) string {
+ h, _, err := net.SplitHostPort(address)
+ if err != nil {
+ return address
+ }
+ return h
+}
diff --git a/cmder/cmdutil/cmdutil.go b/cmder/cmdutil/cmdutil.go
new file mode 100644
index 0000000..316a5ab
--- /dev/null
+++ b/cmder/cmdutil/cmdutil.go
@@ -0,0 +1,98 @@
+// 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 cmdutil
+
+import (
+ "compress/gzip"
+ "flag"
+ "io"
+ "io/ioutil"
+ "net/http/cookiejar"
+ "net/url"
+ "strings"
+)
+
+// TrimPathPrefix 去除目录的前缀
+func TrimPathPrefix(path, prefixPath string) string {
+ if prefixPath == "/" {
+ return path
+ }
+ return strings.TrimPrefix(path, prefixPath)
+}
+
+// ContainsString 检测字符串是否在字符串数组里
+func ContainsString(ss []string, s string) bool {
+ for k := range ss {
+ if ss[k] == s {
+ return true
+ }
+ }
+ return false
+}
+
+// GetURLCookieString 返回cookie字串
+func GetURLCookieString(urlString string, jar *cookiejar.Jar) string {
+ u, _ := url.Parse(urlString)
+ cookies := jar.Cookies(u)
+ cookieString := ""
+ for _, v := range cookies {
+ cookieString += v.String() + "; "
+ }
+ cookieString = strings.TrimRight(cookieString, "; ")
+ return cookieString
+}
+
+// DecompressGZIP 对 io.Reader 数据, 进行 gzip 解压
+func DecompressGZIP(r io.Reader) ([]byte, error) {
+ gzipReader, err := gzip.NewReader(r)
+ if err != nil {
+ return nil, err
+ }
+ gzipReader.Close()
+ return ioutil.ReadAll(gzipReader)
+}
+
+// FlagProvided 检测命令行是否提供名为 name 的 flag, 支持多个name(names)
+func FlagProvided(names ...string) bool {
+ if len(names) == 0 {
+ return false
+ }
+ var targetFlag *flag.Flag
+ for _, name := range names {
+ targetFlag = flag.Lookup(name)
+ if targetFlag == nil {
+ return false
+ }
+ if targetFlag.DefValue == targetFlag.Value.String() {
+ return false
+ }
+ }
+ return true
+}
+
+// Trigger 用于触发事件
+func Trigger(f func()) {
+ if f == nil {
+ return
+ }
+ go f()
+}
+
+// TriggerOnSync 用于触发事件, 同步触发
+func TriggerOnSync(f func()) {
+ if f == nil {
+ return
+ }
+ f()
+}
diff --git a/cmder/cmdutil/escaper/escaper.go b/cmder/cmdutil/escaper/escaper.go
new file mode 100644
index 0000000..f0cf964
--- /dev/null
+++ b/cmder/cmdutil/escaper/escaper.go
@@ -0,0 +1,76 @@
+// 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 escaper
+
+import (
+ "strings"
+)
+
+type (
+ // RuneFunc 判断指定rune
+ RuneFunc func(r rune) bool
+)
+
+// EscapeByRuneFunc 通过runeFunc转义, runeFunc返回真, 则转义
+func EscapeByRuneFunc(s string, runeFunc RuneFunc) string {
+ if runeFunc == nil {
+ return s
+ }
+
+ var (
+ builder = &strings.Builder{}
+ rs = []rune(s)
+ )
+
+ for k := range rs {
+ if !runeFunc(rs[k]) {
+ builder.WriteRune(rs[k])
+ continue
+ }
+
+ if k >= 1 && rs[k-1] == '\\' {
+ builder.WriteRune(rs[k])
+ continue
+ }
+ builder.WriteString(`\`)
+ builder.WriteRune(rs[k])
+ }
+ return builder.String()
+}
+
+// Escape 转义指定的escapeRunes, 在escapeRunes的前面加上一个反斜杠
+func Escape(s string, escapeRunes []rune) string {
+ return EscapeByRuneFunc(s, func(r rune) bool {
+ for k := range escapeRunes {
+ if escapeRunes[k] == r {
+ return true
+ }
+ }
+ return false
+ })
+}
+
+// EscapeStrings 转义字符串数组
+func EscapeStrings(ss []string, escapeRunes []rune) {
+ for k := range ss {
+ ss[k] = Escape(ss[k], escapeRunes)
+ }
+}
+
+// EscapeStringsByRuneFunc 转义字符串数组, 通过runeFunc
+func EscapeStringsByRuneFunc(ss []string, runeFunc RuneFunc) {
+ for k := range ss {
+ ss[k] = EscapeByRuneFunc(ss[k], runeFunc)
+ }
+}
diff --git a/cmder/cmdutil/file.go b/cmder/cmdutil/file.go
new file mode 100644
index 0000000..f7f5836
--- /dev/null
+++ b/cmder/cmdutil/file.go
@@ -0,0 +1,125 @@
+// 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 cmdutil
+
+import (
+ "github.com/kardianos/osext"
+ "github.com/tickstep/library-go/logger"
+ "os"
+ "path"
+ "path/filepath"
+ "runtime"
+ "strings"
+)
+
+func IsPipeInput() bool {
+ fileInfo, err := os.Stdin.Stat()
+ if err != nil {
+ return false
+ }
+ return (fileInfo.Mode() & os.ModeNamedPipe) == os.ModeNamedPipe
+}
+
+// IsIPhoneOS 是否为苹果移动设备
+func IsIPhoneOS() bool {
+ if runtime.GOOS == "darwin" && (runtime.GOARCH == "arm" || runtime.GOARCH == "arm64") {
+ _, err := os.Stat("Info.plist")
+ return err == nil
+ }
+ return false
+}
+
+// ChWorkDir 切换回工作目录
+func ChWorkDir() {
+ if !IsIPhoneOS() {
+ return
+ }
+
+ dir, err := filepath.Abs("")
+ if err != nil {
+ return
+ }
+
+ subPath := filepath.Dir(os.Args[0])
+ os.Chdir(strings.TrimSuffix(dir, subPath))
+}
+
+// Executable 获取程序所在的真实目录或真实相对路径
+func Executable() string {
+ executablePath, err := osext.Executable()
+ if err != nil {
+ logger.Verbosef("DEBUG: osext.Executable: %s\n", err)
+ executablePath, err = filepath.Abs(filepath.Dir(os.Args[0]))
+ if err != nil {
+ logger.Verbosef("DEBUG: filepath.Abs: %s\n", err)
+ executablePath = filepath.Dir(os.Args[0])
+ }
+ }
+
+ if IsIPhoneOS() {
+ executablePath = filepath.Join(strings.TrimSuffix(executablePath, os.Args[0]), filepath.Base(os.Args[0]))
+ }
+
+ // 读取链接
+ linkedExecutablePath, err := filepath.EvalSymlinks(executablePath)
+ if err != nil {
+ logger.Verbosef("DEBUG: filepath.EvalSymlinks: %s\n", err)
+ return executablePath
+ }
+ return linkedExecutablePath
+}
+
+// ExecutablePath 获取程序所在目录
+func ExecutablePath() string {
+ return filepath.Dir(Executable())
+}
+
+// ExecutablePathJoin 返回程序所在目录的子目录
+func ExecutablePathJoin(subPath string) string {
+ return filepath.Join(ExecutablePath(), subPath)
+}
+
+// WalkDir 获取指定目录及所有子目录下的所有文件,可以匹配后缀过滤。
+// 支持 Linux/macOS 软链接
+func WalkDir(dirPth, suffix string) (files []string, err error) {
+ files = make([]string, 0, 32)
+ suffix = strings.ToUpper(suffix) //忽略后缀匹配的大小写
+
+ var walkFunc filepath.WalkFunc
+ walkFunc = func(filename string, fi os.FileInfo, err error) error { //遍历目录
+ if err != nil {
+ return err
+ }
+ if fi.IsDir() { // 忽略目录
+ return nil
+ }
+ if fi.Mode()&os.ModeSymlink != 0 { // 读取 symbol link
+ err = filepath.Walk(filename+string(os.PathSeparator), walkFunc)
+ return err
+ }
+
+ if strings.HasSuffix(strings.ToUpper(fi.Name()), suffix) {
+ files = append(files, path.Clean(filename))
+ }
+ return nil
+ }
+
+ err = filepath.Walk(dirPth, walkFunc)
+ return files, err
+}
+
+// ConvertToUnixPathSeparator 将 windows 目录分隔符转换为 Unix 的
+func ConvertToUnixPathSeparator(p string) string {
+ return strings.Replace(p, "\\", "/", -1)
+}
diff --git a/cmder/cmdutil/jsonhelper/jsonhelper.go b/cmder/cmdutil/jsonhelper/jsonhelper.go
new file mode 100644
index 0000000..62f2cd2
--- /dev/null
+++ b/cmder/cmdutil/jsonhelper/jsonhelper.go
@@ -0,0 +1,31 @@
+// 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 jsonhelper
+
+import (
+ "github.com/json-iterator/go"
+ "io"
+)
+
+// UnmarshalData 将 r 中的 json 格式的数据, 解析到 data
+func UnmarshalData(r io.Reader, data interface{}) error {
+ d := jsoniter.NewDecoder(r)
+ return d.Decode(data)
+}
+
+// MarshalData 将 data, 生成 json 格式的数据, 写入 w 中
+func MarshalData(w io.Writer, data interface{}) error {
+ e := jsoniter.NewEncoder(w)
+ return e.Encode(data)
+}
diff --git a/docs/complie_project.md b/docs/complie_project.md
new file mode 100644
index 0000000..afb0200
--- /dev/null
+++ b/docs/complie_project.md
@@ -0,0 +1,15 @@
+# 关于 Windows EXE ICO 和应用信息编译
+为了编译出来的windows的exe文件带有ico和应用程序信息,需要使用 github.com/josephspurrier/goversioninfo/cmd/goversioninfo 工具
+
+工具安装,运行下面的命令即可生成工具。也可以直接用 bin/ 文件夹下面的编译好的
+```
+go get github.com/josephspurrier/goversioninfo/cmd/goversioninfo
+```
+
+versioninfo.json - 里面有exe程序信息以及ico的配置
+使用 goversioninfo 工具运行以下命令
+```
+ goversioninfo -o=resource_windows_386.syso
+ goversioninfo -64 -o=resource_windows_amd64.syso
+```
+即可编译出.syso资源库,再使用 go build 编译之后,exe文件就会拥有应用程序信息和ico图标
\ No newline at end of file
diff --git a/entitlements.xml b/entitlements.xml
new file mode 100644
index 0000000..d93f53b
--- /dev/null
+++ b/entitlements.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ application-identifier
+ com.tickstep.aliyunpan
+ get-task-allow
+
+ platform-application
+
+ keychain-access-groups
+
+ com.tickstep.aliyunpan
+
+
+
\ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..f61b434
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,22 @@
+module github.com/tickstep/aliyunpan
+
+go 1.16
+
+require (
+ github.com/GeertJohan/go.incremental v1.0.0
+ github.com/json-iterator/go v1.1.10
+ github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/oleiade/lane v0.0.0-20160817071224-3053869314bb
+ github.com/olekukonko/tablewriter v0.0.2-0.20190618033246-cc27d85e17ce
+ github.com/peterh/liner v1.2.1
+ github.com/tickstep/bolt v1.3.3
+ github.com/tickstep/aliyunpan-api v0.0.1
+ github.com/tickstep/library-go v0.0.5
+ github.com/urfave/cli v1.21.1-0.20190817182405-23c83030263f
+ golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 // indirect
+)
+
+//replace github.com/tickstep/bolt => /Users/tickstep/Documents/Workspace/go/projects/bolt
+//replace github.com/tickstep/library-go => /Users/tickstep/Documents/Workspace/go/projects/library-go
+replace github.com/tickstep/aliyunpan-api => /Users/tickstep/Documents/Workspace/go/projects/aliyunpan-api
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..43f72ff
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,99 @@
+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/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
+github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
+github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
+github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
+github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk=
+github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
+github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68=
+github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1 h1:PJPDf8OUfOK1bb/NeTKd4f1QXZItOX389VN3B6qC8ro=
+github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
+github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
+github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
+github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
+github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mattn/go-sqlite3 v1.14.4 h1:4rQjbDxdu9fSgI/r3KN72G3c2goxknAqHHgPWWs8UlI=
+github.com/mattn/go-sqlite3 v1.14.4/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/oleiade/lane v0.0.0-20160817071224-3053869314bb h1:x0yCvYsspui5SAxSRvLd2zFg7PfFijzKdCo7QAtN92I=
+github.com/oleiade/lane v0.0.0-20160817071224-3053869314bb/go.mod h1:ym0w0flrmBtGvApLDgFLa0sfGJkWxDQqnm0/0ok5w3Y=
+github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8urCTFX88=
+github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
+github.com/olekukonko/tablewriter v0.0.2-0.20190618033246-cc27d85e17ce h1:RLmZmfx/K62HKpbwPqtW3tg+V2GgugN/XNNx+uiMH/Y=
+github.com/olekukonko/tablewriter v0.0.2-0.20190618033246-cc27d85e17ce/go.mod h1:rSAaSIOAGT9odnlyGlUfAJaoc5w2fSBUmeGDbRWPxyQ=
+github.com/peterh/liner v1.1.1-0.20190305032635-6f820f8f90ce h1:Lz+a/i+oS4A7tb6J6IyH4ZFiWgqvNv2yslv0Qn79wok=
+github.com/peterh/liner v1.1.1-0.20190305032635-6f820f8f90ce/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0=
+github.com/peterh/liner v1.2.1 h1:O4BlKaq/LWu6VRWmol4ByWfzx6MfXc5Op5HETyIy5yg=
+github.com/peterh/liner v1.2.1/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
+github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
+github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
+github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
+github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
+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 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
+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/bolt v1.3.3 h1:3abb88T5JJocnBlYTJE3EqKvwWV6+D/JZD2Fsdt1QHI=
+github.com/tickstep/bolt v1.3.3/go.mod h1:Y24RwCywbOsKAyHiVcJ4K2QQfED+q67x7PEOH9OG7Q4=
+github.com/tickstep/cloudpan189-api v0.0.3 h1:L8woe6XSUjQOsa7XRC7zH8VZj8SNKo/1qQb1k2ttLkU=
+github.com/tickstep/cloudpan189-api v0.0.3/go.mod h1:1H1r6h5fOu8sEhPY6h09YTuSt4OKl5uFJchFscBFRwo=
+github.com/tickstep/cloudpan189-api v0.0.4 h1:qsEhqvL2L5P6EHP+vfLhVqyjJbcHUkE9JEfUiyu1z0g=
+github.com/tickstep/cloudpan189-api v0.0.4/go.mod h1:1H1r6h5fOu8sEhPY6h09YTuSt4OKl5uFJchFscBFRwo=
+github.com/tickstep/cloudpan189-api v0.0.5 h1:R46M4op2aABKJgR2S8c/KUe/fIQYbUpQGxlJ9SUnsBo=
+github.com/tickstep/cloudpan189-api v0.0.5/go.mod h1:HYu2wtfBDRldAnohj9UmNpEZipQDMm2wOYszNuLzZ9s=
+github.com/tickstep/cloudpan189-api v0.0.6 h1:b+ctfvWfOVyq/Zot3rCrJxqf85b+toDSNLeGon4OWQo=
+github.com/tickstep/cloudpan189-api v0.0.6/go.mod h1:qhNVXxF1UGOApXy9uG+UuKHReFI1GpwHn0pzL4tqSd8=
+github.com/tickstep/library-go v0.0.1 h1:UbXeGE6ZxnxA6KTjMofEhG3h2aHJ4UGacfh7U2B4zgw=
+github.com/tickstep/library-go v0.0.1/go.mod h1:egoK/RvOJ3Qs2tHpkq374CWjhNjI91JSCCG1GrhDYSw=
+github.com/tickstep/library-go v0.0.2 h1:0JCxT2ZzRMrydUyqou3d9FI44ULrlxnkFcyioaGUbrE=
+github.com/tickstep/library-go v0.0.2/go.mod h1:egoK/RvOJ3Qs2tHpkq374CWjhNjI91JSCCG1GrhDYSw=
+github.com/tickstep/library-go v0.0.3 h1:j6C2qIYMC33Re/qa3cYS49DSMalgjtLR4KqGi6d1qBI=
+github.com/tickstep/library-go v0.0.3/go.mod h1:egoK/RvOJ3Qs2tHpkq374CWjhNjI91JSCCG1GrhDYSw=
+github.com/tickstep/library-go v0.0.4 h1:wNR48yDlaikxdJokL/NYX0J4ul8NN+TmBfXjxO2kfNw=
+github.com/tickstep/library-go v0.0.4/go.mod h1:egoK/RvOJ3Qs2tHpkq374CWjhNjI91JSCCG1GrhDYSw=
+github.com/tickstep/library-go v0.0.5 h1:MBb1tsvs4Wi67zy0E9eobVWLgsfPRLsqKAEdSEi3LBE=
+github.com/tickstep/library-go v0.0.5/go.mod h1:egoK/RvOJ3Qs2tHpkq374CWjhNjI91JSCCG1GrhDYSw=
+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=
+github.com/xujiajun/gorouter v1.2.0/go.mod h1:yJrIta+bTNpBM/2UT8hLOaEAFckO+m/qmR3luMIQygM=
+github.com/xujiajun/mmap-go v1.0.1 h1:7Se7ss1fLPPRW+ePgqGpCkfGIZzJV6JPq9Wq9iv/WHc=
+github.com/xujiajun/mmap-go v1.0.1/go.mod h1:CNN6Sw4SL69Sui00p0zEzcZKbt+5HtEnYUsc6BKKRMg=
+github.com/xujiajun/nutsdb v0.5.1-0.20200830145825-432dd3d0c873 h1:pgTLmYRrfy9lGWgXrEqaIhn4WSJb9xA5Q+Uir2Dxeag=
+github.com/xujiajun/nutsdb v0.5.1-0.20200830145825-432dd3d0c873/go.mod h1:Q8FXi2zeQRluPpUl/CKQ6J7u/9gcI02J6cZp3owFLyA=
+github.com/xujiajun/utils v0.0.0-20190123093513-8bf096c4f53b h1:jKG9OiL4T4xQN3IUrhUpc1tG+HfDXppkgVcrAiiaI/0=
+github.com/xujiajun/utils v0.0.0-20190123093513-8bf096c4f53b/go.mod h1:AZd87GYJlUzl82Yab2kTjx1EyXSQCAfZDhpTo1SQC4k=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6/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 h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0=
+golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/command/backup.go b/internal/command/backup.go
new file mode 100644
index 0000000..9355cce
--- /dev/null
+++ b/internal/command/backup.go
@@ -0,0 +1,303 @@
+// 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/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
+ "github.com/tickstep/aliyunpan/cmder"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/tickstep/aliyunpan/internal/functions/panupload"
+ "github.com/tickstep/library-go/logger"
+ "github.com/urfave/cli"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "sync"
+)
+
+func CmdBackup() cli.Command {
+ return cli.Command{
+ Name: "backup",
+ Description: `备份指定 <文件/目录> 到云盘 <目标目录> 中
+
+和上传的功能一样,只是备份多进行了如下操作
+
+1. 增加了数据库,记录已经上传的文件信息。
+ 目前只记录 文件位置、大小、修改时间、MD5 。
+2. 上传前先根据数据库记录判断是否需要重新上传。
+3. 强制同名覆盖。
+
+注:只备份(上传)新的文件(同名覆盖),不处理删除操作。
+
+ 示例:
+ 1. 将本地的 C:\Users\Administrator\Video 整个目录备份到网盘 /视频 目录
+ 注意区别反斜杠 "\" 和 斜杠 "/" !!!
+ aliyunpan-go backup C:/Users/Administrator/Video /视频
+
+ 2. 将本地的 C:\Users\Administrator\Video 整个目录备份到网盘 /视频 目录,但是排除所有的.jpg文件
+ aliyunpan-go backup -exn "\.jpg$" C:/Users/Administrator/Video /视频
+
+ 3. 将本地的 C:\Users\Administrator\Video 整个目录备份到网盘 /视频 目录,但是排除所有的.jpg文件和.mp3文件,每一个排除项就是一个exn参数
+ aliyunpan-go backup -exn "\.jpg$" -exn "\.mp3$" C:/Users/Administrator/Video /视频
+
+ 4. 将本地的 C:\Users\Administrator\Video 整个目录备份到网盘 /视频 目录,但是排除所有的 @eadir 文件夹
+ aliyunpan-go backup -exn "^@eadir$" C:/Users/Administrator/Video /视频
+
+ 参考:
+ 以下是典型的排除特定文件或者文件夹的例子,注意:参数值必须是正则表达式。在正则表达式中,^表示匹配开头,$表示匹配结尾。
+ 1)排除@eadir文件或者文件夹:-exn "^@eadir$"
+ 2)排除.jpg文件:-exn "\.jpg$"
+ 3)排除.号开头的文件:-exn "^\."
+ 4)排除~号开头的文件:-exn "^~"
+ 5)排除 myfile.txt 文件:-exn "^myfile.txt$"
+`,
+ Usage: "备份文件或目录",
+ UsageText: "backup <文件/目录路径1> <文件/目录2> <文件/目录3> ... <目标目录>",
+ Category: "阿里云盘",
+ Before: cmder.ReloadConfigFunc,
+ Action: Backup,
+ Flags: append(UploadFlags, cli.BoolFlag{
+ Name: "delete",
+ Usage: "通过本地数据库记录同步删除网盘文件",
+ }, cli.BoolFlag{
+ Name: "sync",
+ Usage: "本地同步到网盘(会同步删除网盘文件)",
+ }),
+ }
+}
+
+func OpenSyncDb(path string) (panupload.SyncDb, error) {
+ return panupload.OpenSyncDb(path, BackupMetaBucketName)
+}
+
+// 删除那些本地不存在而网盘存在的网盘文件 默认使用本地数据库判断,如果 flagSync 为 true 则遍历网盘文件列表进行判断(速度较慢)。
+func DelRemoteFileFromDB(driveId string, localDir string, savePath string, flagSync bool) {
+ activeUser := config.Config.ActiveUser()
+ var db panupload.SyncDb
+ var err error
+
+ dbpath := filepath.Join(localDir, BackupMetaDirName)
+ db, err = OpenSyncDb(dbpath + string(os.PathSeparator) + "db")
+ if err != nil {
+ fmt.Println("同步数据库打开失败!", err)
+ return
+ }
+ defer db.Close()
+
+ savePath = path.Join(savePath, filepath.Base(localDir))
+
+ //判断本地文件是否存在,如果存在返回 true 否则删除数据库相关记录和网盘上的文件。
+ isLocalFileExist := func(ent *panupload.UploadedFileMeta) (isExists bool) {
+ testPath := strings.TrimPrefix(ent.Path, savePath)
+ testPath = filepath.Join(localDir, testPath)
+ logger.Verboseln("同步删除检测:", testPath, ent.Path)
+
+ //为防止误删,只有当 err 是文件不存在的时候才进行删除处理。
+ if fi, err := os.Stat(testPath); err == nil || !os.IsNotExist(err) {
+ //使用sync功能时没有传时间参数进来,为方便对比回写数据库需补上时间。
+ if fi != nil {
+ ent.ModTime = fi.ModTime().Unix()
+ }
+ return true
+ }
+
+ var err *apierror.ApiError
+
+ // 尝试从本地数据库查找
+ if ent.ParentId == "" {
+ if test := db.Get(path.Dir(ent.Path)); test != nil && test.IsFolder && test.FileId != "" {
+ ent.ParentId = test.FileId
+ }
+ }
+
+ // 从网盘查找
+ if ent.FileId == "" || ent.ParentId == "" {
+ efi, err := activeUser.PanClient().FileInfoById(driveId, ent.FileId)
+ //网盘上不存在这个文件或目录,只需要清理数据库
+ if err != nil && err.Code == apierror.ApiCodeFileNotFoundCode {
+ db.DelWithPrefix(ent.Path)
+ logger.Verboseln("删除数据库记录", ent.Path)
+ return
+ }
+ if efi != nil {
+ ent.FileId = efi.FileId
+ ent.ParentId = efi.ParentFileId
+ }
+ }
+
+ if ent.FileId == "" {
+ return
+ }
+
+ // 本地文件不存在
+ // 删除网盘对应文件
+ fileDeleteResult, err := activeUser.PanClient().FileDelete([]*aliyunpan.FileBatchActionParam{{DriveId:driveId, FileId:ent.FileId}})
+ if err != nil || len(fileDeleteResult) == 0 {
+ fmt.Println("删除网盘文件或目录失败", ent.Path, err)
+ } else {
+ db.DelWithPrefix(ent.Path)
+ logger.Verboseln("删除网盘文件和数据库记录", ent.Path)
+ }
+ return
+ }
+
+ // 根据数据库记录删除不存在的文件
+ if !flagSync {
+ for ent, err := db.First(savePath); err == nil; ent, err = db.Next(savePath) {
+ isLocalFileExist(ent)
+ }
+ return
+ }
+
+ parent := db.Get(savePath)
+ if parent.FileId == "" {
+ efi, err := activeUser.PanClient().FileInfoByPath(driveId, savePath)
+ if err != nil {
+ return
+ }
+ parent.FileId = efi.FileId
+ }
+
+ var syncFunc func(curPath, parentID string)
+
+ syncFunc = func(curPath, parentID string) {
+ param := &aliyunpan.FileListParam{
+ DriveId: driveId,
+ ParentFileId: parentID,
+ }
+ fileResult, err := activeUser.PanClient().FileListGetAll(param)
+ if err != nil {
+ return
+ }
+ if fileResult == nil || len(fileResult) == 0 {
+ return
+ }
+ for _, fileEntity := range fileResult {
+ ufm := &panupload.UploadedFileMeta{
+ FileId: fileEntity.FileId,
+ ParentId: fileEntity.ParentFileId,
+ Size: fileEntity.FileSize,
+ IsFolder: fileEntity.IsFolder(),
+ Path: path.Join(curPath, fileEntity.FileName),
+ SHA1: strings.ToLower(fileEntity.ContentHash),
+ }
+
+ if !isLocalFileExist(ufm) {
+ continue
+ }
+
+ //如果这是一个目录就直接更新数据库,否则判断原始记录的Hash信息,如果一致才更新。
+ if ufm.IsFolder {
+ db.Put(ufm.Path, ufm)
+ syncFunc(ufm.Path, ufm.FileId)
+ } else if test := db.Get(ufm.Path); test.SHA1 == ufm.SHA1 {
+ db.Put(ufm.Path, ufm)
+ }
+ }
+ }
+
+ //开启自动清理功能
+ db.AutoClean(parent.Path, true)
+ db.Put(parent.Path, parent)
+
+ syncFunc(savePath, parent.FileId)
+}
+
+func checkPath(localdir string) (string, error) {
+ fullPath, err := filepath.Abs(localdir)
+ if err != nil {
+ fullPath = localdir
+ }
+
+ if fi, err := os.Stat(fullPath); err != nil && !fi.IsDir() {
+ return fullPath, os.ErrInvalid
+ }
+
+ dbpath := filepath.Join(fullPath, BackupMetaDirName)
+ //数据库目录判断
+ fi, err := os.Stat(dbpath)
+
+ if err != nil {
+ if os.IsNotExist(err) {
+ err = os.Mkdir(dbpath, 0755)
+ }
+ if err != nil {
+ return fullPath, fmt.Errorf("数据库目录[%s]创建失败,跳过处理: %s", dbpath, err)
+ }
+ }
+
+ if fi != nil && !fi.IsDir() {
+ return fullPath, os.ErrPermission
+ }
+
+ return fullPath, nil
+}
+
+func Backup(c *cli.Context) error {
+ if c.NArg() < 2 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+
+ subArgs := c.Args()
+ localpaths := make([]string, 0)
+ flagSync := c.Bool("sync")
+ flagDelete := c.Bool("delete")
+
+ opt := &UploadOptions{
+ AllParallel: c.Int("p"),
+ Parallel: 1, // 阿里云盘一个文件只支持单线程上传
+ MaxRetry: c.Int("retry"),
+ NoRapidUpload: c.Bool("norapid"),
+ ShowProgress: !c.Bool("np"),
+ IsOverwrite: true,
+ DriveId: parseDriveId(c),
+ ExcludeNames: c.StringSlice("exn"),
+ BlockSize: int64(c.Int("bs") * 1024),
+ }
+
+ localCount := c.NArg() - 1
+ savePath := GetActiveUser().PathJoin(opt.DriveId, subArgs[localCount])
+
+ wg := sync.WaitGroup{}
+ wg.Add(localCount)
+ for _, p := range subArgs[:localCount] {
+ go func(p string) {
+ defer wg.Done()
+ fullPath, err := checkPath(p)
+ switch err {
+ case nil:
+ if flagSync || flagDelete {
+ DelRemoteFileFromDB(opt.DriveId, fullPath, savePath, flagSync)
+ }
+ case os.ErrInvalid:
+ default:
+ return
+ }
+ localpaths = append(localpaths, fullPath)
+ }(p)
+ }
+
+ wg.Wait()
+
+ if len(localpaths) == 0 {
+ return nil
+ }
+
+ RunUpload(localpaths, savePath, opt)
+ return nil
+}
diff --git a/internal/command/cd.go b/internal/command/cd.go
new file mode 100644
index 0000000..0e71038
--- /dev/null
+++ b/internal/command/cd.go
@@ -0,0 +1,113 @@
+// 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/tickstep/aliyunpan/cmder"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/urfave/cli"
+)
+
+func CmdCd() cli.Command {
+ return cli.Command{
+ Name: "cd",
+ Category: "阿里云盘",
+ Usage: "切换工作目录",
+ Description: `
+ aliyunpan cd <目录, 绝对路径或相对路径>
+
+ 示例:
+
+ 切换 /我的资源 工作目录:
+ aliyunpan cd /我的资源
+
+ 切换上级目录:
+ aliyunpan cd ..
+
+ 切换根目录:
+ aliyunpan cd /
+`,
+ Before: cmder.ReloadConfigFunc,
+ After: cmder.SaveConfigFunc,
+ Action: func(c *cli.Context) error {
+ if c.NArg() == 0 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+ if config.Config.ActiveUser() == nil {
+ fmt.Println("未登录账号")
+ return nil
+ }
+ RunChangeDirectory(parseDriveId(c), c.Args().Get(0))
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "driveId",
+ Usage: "网盘ID",
+ Value: "",
+ },
+ },
+ }
+}
+
+func CmdPwd() cli.Command {
+ return cli.Command{
+ Name: "pwd",
+ Usage: "输出工作目录",
+ UsageText: cmder.App().Name + " pwd",
+ Category: "阿里云盘",
+ Before: cmder.ReloadConfigFunc,
+ Action: func(c *cli.Context) error {
+ if config.Config.ActiveUser() == nil {
+ fmt.Println("未登录账号")
+ return nil
+ }
+ activeUser := config.Config.ActiveUser()
+ if activeUser.IsFileDriveActive() {
+ fmt.Println(activeUser.Workdir)
+ } else if activeUser.IsAlbumDriveActive() {
+ fmt.Println(activeUser.AlbumWorkdir)
+ }
+ return nil
+ },
+ }
+}
+
+func RunChangeDirectory(driveId, targetPath string) {
+ user := config.Config.ActiveUser()
+ targetPath = user.PathJoin(driveId, targetPath)
+
+ targetPathInfo, err := user.PanClient().FileInfoByPath(driveId, targetPath)
+ if err != nil {
+ fmt.Println(err)
+ return
+ }
+
+ if !targetPathInfo.IsFolder() {
+ fmt.Printf("错误: %s 不是一个目录 (文件夹)\n", targetPath)
+ return
+ }
+
+ if user.IsFileDriveActive() {
+ user.Workdir = targetPath
+ user.WorkdirFileEntity = *targetPathInfo
+ } else if user.IsAlbumDriveActive() {
+ user.AlbumWorkdir = targetPath
+ user.AlbumWorkdirFileEntity = *targetPathInfo
+ }
+
+ fmt.Printf("改变工作目录: %s\n", targetPath)
+}
diff --git a/internal/command/command.go b/internal/command/command.go
new file mode 100644
index 0000000..6cc3206
--- /dev/null
+++ b/internal/command/command.go
@@ -0,0 +1,396 @@
+// 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 (
+ "errors"
+ "fmt"
+ "github.com/tickstep/aliyunpan/cmder"
+ "github.com/tickstep/aliyunpan/cmder/cmdutil"
+ "github.com/tickstep/aliyunpan/library/crypto"
+ "github.com/tickstep/library-go/getip"
+ "github.com/urfave/cli"
+ "net/url"
+ "path"
+ "strconv"
+ "strings"
+
+ "github.com/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan/internal/config"
+)
+
+type (
+ // 秒传数据项
+ RapidUploadItem struct {
+ FileSha1 string
+ FileSize int64
+ FilePath string // 绝对路径,包含文件名
+ }
+)
+
+const (
+ cryptoDescription = `
+ 可用的方法 :
+ aes-128-ctr, aes-192-ctr, aes-256-ctr,
+ aes-128-cfb, aes-192-cfb, aes-256-cfb,
+ aes-128-ofb, aes-192-ofb, aes-256-ofb.
+
+ 密钥 :
+ aes-128 对应key长度为16, aes-192 对应key长度为24, aes-256 对应key长度为32,
+ 如果key长度不符合, 则自动修剪key, 舍弃超出长度的部分, 长度不足的部分用'\0'填充.
+
+ GZIP :
+ 在文件加密之前, 启用GZIP压缩文件; 文件解密之后启用GZIP解压缩文件, 默认启用,
+ 如果不启用, 则无法检测文件是否解密成功, 解密文件时会保留源文件, 避免解密失败造成文件数据丢失.`
+)
+
+var ErrBadArgs = errors.New("参数错误")
+var ErrNotLogined = errors.New("未登录账号")
+
+func GetActivePanClient() *aliyunpan.PanClient {
+ return config.Config.ActiveUser().PanClient()
+}
+
+func GetActiveUser() *config.PanUser {
+ return config.Config.ActiveUser()
+}
+
+func parseDriveId(c *cli.Context) string {
+ driveId := config.Config.ActiveUser().ActiveDriveId
+ if c.IsSet("driveId") {
+ driveId = c.String("driveId")
+ }
+ return driveId
+}
+
+// newRapidUploadItem 通过解析秒传链接创建秒传实体
+func newRapidUploadItem(rapidUploadShareLink string) (*RapidUploadItem, error) {
+ if strings.IndexAny(rapidUploadShareLink, "aliyunpan://") != 0 {
+ return nil, fmt.Errorf("秒传链接格式错误: %s", rapidUploadShareLink)
+ }
+
+ // 格式:aliyunpan://文件名|sha1|文件大小|<相对路径>
+ rapidUploadShareLinkStr := strings.Replace(rapidUploadShareLink, "aliyunpan://", "", 1)
+
+ item := &RapidUploadItem{}
+ parts := strings.Split(rapidUploadShareLinkStr, "|")
+
+ if len(parts) < 4 {
+ return nil, fmt.Errorf("秒传链接格式错误: %s", rapidUploadShareLink)
+ }
+
+ // hash
+ if len(parts[1]) == 0 {
+ return nil, fmt.Errorf("文件sha1错误: %s", rapidUploadShareLink)
+ }
+ item.FileSha1 = strings.TrimSpace(parts[1])
+
+ // size
+ if size,e := strconv.ParseInt(parts[2], 10, 64); e == nil{
+ item.FileSize = size
+ } else {
+ return nil, fmt.Errorf("文件大小错误: %s", rapidUploadShareLink)
+ }
+
+ // path
+ relativePath, _ := url.QueryUnescape(parts[3])
+ item.FilePath = path.Join(relativePath, parts[0])
+
+ // result
+ return item, nil
+}
+
+func newRapidUploadItemFromFileEntity(fileEntity *aliyunpan.FileEntity) *RapidUploadItem {
+ if fileEntity == nil {
+ return nil
+ }
+ return &RapidUploadItem{
+ FileSha1: fileEntity.ContentHash,
+ FileSize: fileEntity.FileSize,
+ FilePath: fileEntity.Path,
+ }
+}
+
+// 创建秒传链接
+// 链接格式说明:aliyunpan://文件名|sha1|文件大小|<相对路径>
+// "相对路径" 可以为空,为空代表存储到网盘根目录
+func (r *RapidUploadItem) createRapidUploadLink(hideRelativePath bool) string {
+ fullLink := &strings.Builder{}
+
+ p := r.FilePath
+ p = strings.ReplaceAll(p, "\\", "/")
+
+
+ fileName := path.Base(p)
+ dirPath := path.Dir(p)
+
+ // 去掉开头/
+ if strings.Index(dirPath, "/") == 0 {
+ dirPath = dirPath[1:]
+ }
+ // 相对路径编码
+ dirPath = url.QueryEscape(dirPath)
+
+ // 隐藏相对路径
+ if hideRelativePath {
+ dirPath = ""
+ }
+
+ // 拼接
+ fmt.Fprintf(fullLink, "aliyunpan://%s|%s|%d|%s",
+ fileName, strings.ToUpper(r.FileSha1), r.FileSize, dirPath)
+
+ return fullLink.String()
+}
+
+func CmdConfig() cli.Command {
+ return cli.Command{
+ Name: "config",
+ Usage: "显示和修改程序配置项",
+ Description: "显示和修改程序配置项",
+ Category: "配置",
+ Before: cmder.ReloadConfigFunc,
+ After: cmder.SaveConfigFunc,
+ Action: func(c *cli.Context) error {
+ fmt.Printf("----\n运行 %s config set 可进行设置配置\n\n当前配置:\n", cmder.App().Name)
+ config.Config.PrintTable()
+ return nil
+ },
+ Subcommands: []cli.Command{
+ {
+ Name: "set",
+ Usage: "修改程序配置项",
+ UsageText: cmder.App().Name + " config set [arguments...]",
+ Description: `
+ 注意:
+ 可通过设置环境变量 ALIYUNPAN_CONFIG_DIR, 指定配置文件存放的目录.
+
+ cache_size 的值支持可选设置单位, 单位不区分大小写, b 和 B 均表示字节的意思, 如 64KB, 1MB, 32kb, 65536b, 65536
+ max_download_rate, max_upload_rate 的值支持可选设置单位, 单位为每秒的传输速率, 后缀'/s' 可省略, 如 2MB/s, 2MB, 2m, 2mb 均为一个意思
+
+ 例子:
+ aliyunpan config set -cache_size 64KB
+ aliyunpan config set -cache_size 16384 -max_download_parallel 200 -savedir D:/download`,
+ Action: func(c *cli.Context) error {
+ if c.NumFlags() <= 0 || c.NArg() > 0 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+ if c.IsSet("cache_size") {
+ err := config.Config.SetCacheSizeByStr(c.String("cache_size"))
+ if err != nil {
+ fmt.Printf("设置 cache_size 错误: %s\n", err)
+ return nil
+ }
+ }
+ if c.IsSet("max_download_parallel") {
+ config.Config.MaxDownloadParallel = c.Int("max_download_parallel")
+ }
+ if c.IsSet("max_upload_parallel") {
+ config.Config.MaxUploadParallel = c.Int("max_upload_parallel")
+ }
+ if c.IsSet("max_download_load") {
+ config.Config.MaxDownloadLoad = c.Int("max_download_load")
+ }
+ if c.IsSet("max_download_rate") {
+ err := config.Config.SetMaxDownloadRateByStr(c.String("max_download_rate"))
+ if err != nil {
+ fmt.Printf("设置 max_download_rate 错误: %s\n", err)
+ return nil
+ }
+ }
+ if c.IsSet("max_upload_rate") {
+ err := config.Config.SetMaxUploadRateByStr(c.String("max_upload_rate"))
+ if err != nil {
+ fmt.Printf("设置 max_upload_rate 错误: %s\n", err)
+ return nil
+ }
+ }
+ if c.IsSet("savedir") {
+ config.Config.SaveDir = c.String("savedir")
+ }
+ if c.IsSet("proxy") {
+ config.Config.SetProxy(c.String("proxy"))
+ }
+ if c.IsSet("local_addrs") {
+ config.Config.SetLocalAddrs(c.String("local_addrs"))
+ }
+
+ err := config.Config.Save()
+ if err != nil {
+ fmt.Println(err)
+ return err
+ }
+
+ config.Config.PrintTable()
+ fmt.Printf("\n保存配置成功!\n\n")
+
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "cache_size",
+ Usage: "下载缓存",
+ },
+ cli.IntFlag{
+ Name: "max_download_parallel",
+ Usage: "下载网络连接的最大并发量",
+ },
+ cli.IntFlag{
+ Name: "max_upload_parallel",
+ Usage: "上传网络连接的最大并发量",
+ },
+ cli.IntFlag{
+ Name: "max_download_load",
+ Usage: "同时进行下载文件的最大数量",
+ },
+ cli.StringFlag{
+ Name: "max_download_rate",
+ Usage: "限制最大下载速度, 0代表不限制",
+ },
+ cli.StringFlag{
+ Name: "max_upload_rate",
+ Usage: "限制最大上传速度, 0代表不限制",
+ },
+ cli.StringFlag{
+ Name: "savedir",
+ Usage: "下载文件的储存目录",
+ },
+ cli.StringFlag{
+ Name: "proxy",
+ Usage: "设置代理, 支持 http/socks5 代理",
+ },
+ cli.StringFlag{
+ Name: "local_addrs",
+ Usage: "设置本地网卡地址, 多个地址用逗号隔开",
+ },
+ },
+ },
+ },
+ }
+}
+
+
+func CmdTool() cli.Command {
+ return cli.Command{
+ Name: "tool",
+ Usage: "工具箱",
+ Action: func(c *cli.Context) error {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ },
+ Subcommands: []cli.Command{
+ {
+ Name: "getip",
+ Usage: "获取IP地址",
+ Action: func(c *cli.Context) error {
+ fmt.Printf("内网IP地址: \n")
+ for _, address := range cmdutil.ListAddresses() {
+ fmt.Printf("%s\n", address)
+ }
+ fmt.Printf("\n")
+
+ ipAddr, err := getip.IPInfoFromTechainBaiduByClient(config.Config.HTTPClient(""))
+ if err != nil {
+ fmt.Printf("获取公网IP错误: %s\n", err)
+ return nil
+ }
+
+ fmt.Printf("公网IP地址: %s\n", ipAddr)
+ return nil
+ },
+ },
+ {
+ Name: "enc",
+ Usage: "加密文件",
+ UsageText: cmder.App().Name + " enc -method= -key= [files...]",
+ Description: cryptoDescription,
+ Action: func(c *cli.Context) error {
+ if c.NArg() <= 0 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+
+ for _, filePath := range c.Args() {
+ encryptedFilePath, err := crypto.EncryptFile(c.String("method"), []byte(c.String("key")), filePath, !c.Bool("disable-gzip"))
+ if err != nil {
+ fmt.Printf("%s\n", err)
+ continue
+ }
+
+ fmt.Printf("加密成功, %s -> %s\n", filePath, encryptedFilePath)
+ }
+
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "method",
+ Usage: "加密方法",
+ Value: "aes-128-ctr",
+ },
+ cli.StringFlag{
+ Name: "key",
+ Usage: "加密密钥",
+ Value: cmder.App().Name,
+ },
+ cli.BoolFlag{
+ Name: "disable-gzip",
+ Usage: "不启用GZIP",
+ },
+ },
+ },
+ {
+ Name: "dec",
+ Usage: "解密文件",
+ UsageText: cmder.App().Name + " dec -method= -key= [files...]",
+ Description: cryptoDescription,
+ Action: func(c *cli.Context) error {
+ if c.NArg() <= 0 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+
+ for _, filePath := range c.Args() {
+ decryptedFilePath, err := crypto.DecryptFile(c.String("method"), []byte(c.String("key")), filePath, !c.Bool("disable-gzip"))
+ if err != nil {
+ fmt.Printf("%s\n", err)
+ continue
+ }
+
+ fmt.Printf("解密成功, %s -> %s\n", filePath, decryptedFilePath)
+ }
+
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "method",
+ Usage: "加密方法",
+ Value: "aes-128-ctr",
+ },
+ cli.StringFlag{
+ Name: "key",
+ Usage: "加密密钥",
+ Value: cmder.App().Name,
+ },
+ cli.BoolFlag{
+ Name: "disable-gzip",
+ Usage: "不启用GZIP",
+ },
+ },
+ },
+ },
+ }
+}
diff --git a/internal/command/command_test.go b/internal/command/command_test.go
new file mode 100644
index 0000000..bbc9806
--- /dev/null
+++ b/internal/command/command_test.go
@@ -0,0 +1,21 @@
+package command
+
+import (
+ "fmt"
+ "testing"
+)
+
+func TestRapidUploadItem_createRapidUploadLink(t *testing.T) {
+ item := &RapidUploadItem{
+ FileSha1: "752FCCBFB2436A6FFCA3B287831D4FAA5654B07E",
+ FileSize: 7005440,
+ FilePath: "/dgsdg/rtt5/我的文件夹/file我的文件.dmg",
+ }
+ fmt.Println(item.createRapidUploadLink(false))
+}
+
+func TestRapidUploadItem_newRapidUploadItem(t *testing.T) {
+ link := "aliyunpan://file我的文件.dmg|752FCCBFB2436A6FFCA3B287831D4FAA5654B07E|7005440|dgsdg%2Frtt5%2F%E6%88%91%E7%9A%84%E6%96%87%E4%BB%B6%E5%A4%B9"
+ item,_ := newRapidUploadItem(link)
+ fmt.Println(item)
+}
\ No newline at end of file
diff --git a/internal/command/download.go b/internal/command/download.go
new file mode 100644
index 0000000..20a67ff
--- /dev/null
+++ b/internal/command/download.go
@@ -0,0 +1,324 @@
+// 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/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/tickstep/aliyunpan/internal/file/downloader"
+ "github.com/tickstep/aliyunpan/internal/functions/pandownload"
+ "github.com/tickstep/aliyunpan/internal/taskframework"
+ "github.com/tickstep/aliyunpan/library/requester/transfer"
+ "github.com/tickstep/library-go/converter"
+ "github.com/urfave/cli"
+ "os"
+ "path/filepath"
+ "runtime"
+)
+
+type (
+ //DownloadOptions 下载可选参数
+ DownloadOptions struct {
+ IsPrintStatus bool
+ IsExecutedPermission bool
+ IsOverwrite bool
+ SaveTo string
+ Parallel int
+ Load int
+ MaxRetry int
+ NoCheck bool
+ ShowProgress bool
+ DriveId string
+ }
+
+ // LocateDownloadOption 获取下载链接可选参数
+ LocateDownloadOption struct {
+ FromPan bool
+ }
+)
+
+var (
+ // MaxDownloadRangeSize 文件片段最大值
+ MaxDownloadRangeSize = 55 * converter.MB
+
+ // DownloadCacheSize 默认每个线程下载缓存大小
+ DownloadCacheSize = 64 * converter.KB
+)
+
+func CmdDownload() cli.Command {
+ return cli.Command{
+ Name: "download",
+ Aliases: []string{"d"},
+ Usage: "下载文件/目录",
+ UsageText: cmder.App().Name + " download <文件/目录路径1> <文件/目录2> <文件/目录3> ...",
+ Description: `
+ 下载的文件默认保存到, 程序所在目录的 download/ 目录.
+ 通过 aliyunpan config set -savedir , 自定义保存的目录.
+ 支持多个文件或目录下载.
+ 自动跳过下载重名的文件!
+
+ 示例:
+
+ 设置保存目录, 保存到 D:\Downloads
+ 注意区别反斜杠 "\" 和 斜杠 "/" !!!
+ aliyunpan config set -savedir D:\\Downloads
+ 或者
+ aliyunpan config set -savedir D:/Downloads
+
+ 下载 /我的资源/1.mp4
+ aliyunpan d /我的资源/1.mp4
+
+ 下载 /我的资源 整个目录!!
+ aliyunpan d /我的资源
+
+ 下载 /我的资源/1.mp4 并保存下载的文件到本地的 d:/panfile
+ aliyunpan d --saveto d:/panfile /我的资源/1.mp4
+`,
+ Category: "阿里云盘",
+ Before: cmder.ReloadConfigFunc,
+ Action: func(c *cli.Context) error {
+ if c.NArg() == 0 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+
+ // 处理saveTo
+ var (
+ saveTo string
+ )
+ if c.Bool("save") {
+ saveTo = "."
+ } else if c.String("saveto") != "" {
+ saveTo = filepath.Clean(c.String("saveto"))
+ }
+
+ do := &DownloadOptions{
+ IsPrintStatus: c.Bool("status"),
+ IsExecutedPermission: c.Bool("x"),
+ IsOverwrite: c.Bool("ow"),
+ SaveTo: saveTo,
+ Parallel: c.Int("p"),
+ Load: c.Int("l"),
+ MaxRetry: c.Int("retry"),
+ NoCheck: c.Bool("nocheck"),
+ ShowProgress: !c.Bool("np"),
+ DriveId: parseDriveId(c),
+ }
+
+ RunDownload(c.Args(), do)
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.BoolFlag{
+ Name: "ow",
+ Usage: "overwrite, 覆盖已存在的文件",
+ },
+ cli.BoolFlag{
+ Name: "status",
+ Usage: "输出所有线程的工作状态",
+ },
+ cli.BoolFlag{
+ Name: "save",
+ Usage: "将下载的文件直接保存到当前工作目录",
+ },
+ cli.StringFlag{
+ Name: "saveto",
+ Usage: "将下载的文件直接保存到指定的目录",
+ },
+ cli.BoolFlag{
+ Name: "x",
+ Usage: "为文件加上执行权限, (windows系统无效)",
+ },
+ cli.IntFlag{
+ Name: "p",
+ Usage: "指定下载线程数",
+ },
+ cli.IntFlag{
+ Name: "l",
+ Usage: "指定同时进行下载文件的数量",
+ },
+ cli.IntFlag{
+ Name: "retry",
+ Usage: "下载失败最大重试次数",
+ Value: pandownload.DefaultDownloadMaxRetry,
+ },
+ cli.BoolFlag{
+ Name: "nocheck",
+ Usage: "下载文件完成后不校验文件",
+ },
+ cli.BoolFlag{
+ Name: "np",
+ Usage: "no progress 不展示下载进度条",
+ },
+ cli.StringFlag{
+ Name: "driveId",
+ Usage: "网盘ID",
+ Value: "",
+ },
+ },
+ }
+}
+
+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) {
+ if options == nil {
+ options = &DownloadOptions{}
+ }
+
+ if options.Load <= 0 {
+ options.Load = config.Config.MaxDownloadLoad
+ }
+
+ if options.MaxRetry < 0 {
+ options.MaxRetry = pandownload.DefaultDownloadMaxRetry
+ }
+
+ if runtime.GOOS == "windows" {
+ // windows下不加执行权限
+ 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,
+ }
+ if cfg.CacheSize == 0 {
+ cfg.CacheSize = int(DownloadCacheSize)
+ }
+
+ // 设置下载最大并发量
+ if options.Parallel < 1 {
+ options.Parallel = config.Config.MaxDownloadParallel
+ }
+
+ paths, err := matchPathByShellPattern(options.DriveId, paths...)
+ if err != nil {
+ fmt.Println(err)
+ return
+ }
+
+ fmt.Print("\n")
+ fmt.Printf("[0] 提示: 当前下载最大并发量为: %d, 下载缓存为: %d\n", options.Parallel, cfg.CacheSize)
+
+ var (
+ panClient = GetActivePanClient()
+ loadCount = 0
+ )
+
+ // 预测要下载的文件数量
+ for k := range paths {
+ // 使用递归获取文件的方法计算路径包含的文件的总数量
+ panClient.FilesDirectoriesRecurseList(options.DriveId, paths[k], func(depth int, _ string, fd *aliyunpan.FileEntity, apiError *apierror.ApiError) bool {
+ if apiError != nil {
+ panCommandVerbose.Warnf("%s\n", apiError)
+ return true
+ }
+
+ // 忽略统计文件夹数量
+ if !fd.IsFolder() {
+ loadCount++
+ if loadCount >= options.Load { // 文件的总数量超过指定的指定数量,则不再进行下层的递归查找文件
+ return false
+ }
+ }
+ return true
+ })
+
+ if loadCount >= options.Load {
+ break
+ }
+ }
+
+ // 修改Load, 设置MaxParallel
+ if loadCount > 0 {
+ options.Load = loadCount
+ // 取平均值
+ cfg.MaxParallel = config.AverageParallel(options.Parallel, loadCount)
+ } else {
+ cfg.MaxParallel = options.Parallel
+ }
+
+ var (
+ executor = taskframework.TaskExecutor{
+ IsFailedDeque: true, // 统计失败的列表
+ }
+ statistic = &pandownload.DownloadStatistic{}
+ )
+ // 处理队列
+ for k := range paths {
+ newCfg := *cfg
+ unit := pandownload.DownloadTaskUnit{
+ 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,
+ FilePanPath: paths[k],
+ DriveId: options.DriveId,
+ }
+
+ // 设置储存的路径
+ if options.SaveTo != "" {
+ unit.OriginSaveRootPath = options.SaveTo
+ unit.SavePath = filepath.Join(options.SaveTo, filepath.Base(paths[k]))
+ } else {
+ // 使用默认的保存路径
+ unit.OriginSaveRootPath = GetActiveUser().GetSavePath("")
+ unit.SavePath = GetActiveUser().GetSavePath(paths[k])
+ }
+ info := executor.Append(&unit, options.MaxRetry)
+ fmt.Printf("[%s] 加入下载队列: %s\n", info.Id(), paths[k])
+ }
+
+ // 开始计时
+ statistic.StartTimer()
+
+ // 开始执行
+ executor.Execute()
+
+ fmt.Printf("\n下载结束, 时间: %s, 数据总量: %s\n", statistic.Elapsed()/1e6*1e6, converter.ConvertFileSize(statistic.TotalSize()))
+
+ // 输出失败的文件列表
+ 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()
+ }
+}
diff --git a/internal/command/drive_list.go b/internal/command/drive_list.go
new file mode 100644
index 0000000..ae91fb9
--- /dev/null
+++ b/internal/command/drive_list.go
@@ -0,0 +1,128 @@
+// 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/cmder"
+ "github.com/tickstep/aliyunpan/cmder/cmdtable"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/urfave/cli"
+ "strconv"
+ "strings"
+)
+
+func CmdDrive() cli.Command {
+ return cli.Command{
+ Name: "drive",
+ Usage: "切换网盘(文件网盘/相册网盘)",
+ Description: `
+ 切换已登录的阿里云盘的云工作网盘(文件网盘/相册网盘)
+ 如果运行该条命令没有提供参数, 程序将会列出所有的网盘列表, 供选择切换.
+
+ 示例:
+ aliyunpan drive
+ aliyunpan drive
+`,
+ Category: "阿里云盘账号",
+ Before: cmder.ReloadConfigFunc,
+ After: cmder.SaveConfigFunc,
+ Action: func(c *cli.Context) error {
+ inputData := c.Args().Get(0)
+ targetDriveId := strings.TrimSpace(inputData)
+ RunSwitchDriveList(targetDriveId)
+ return nil
+ },
+ }
+}
+
+func RunSwitchDriveList(targetDriveId string) {
+ currentDriveId := config.Config.ActiveUser().ActiveDriveId
+ var activeDriveInfo *config.DriveInfo = nil
+ driveList,renderStr := getDriveOptionList()
+
+ if driveList == nil || len(driveList) == 0 {
+ fmt.Println("切换网盘失败")
+ return
+ }
+
+ if targetDriveId == "" {
+ // show option list
+ fmt.Println(renderStr)
+
+ // 提示输入 index
+ var index string
+ fmt.Printf("输入要切换的网盘 # 值 > ")
+ _, err := fmt.Scanln(&index)
+ if err != nil {
+ return
+ }
+
+ if n, err := strconv.Atoi(index); err == nil && n >= 0 && n < len(driveList) {
+ activeDriveInfo = driveList[n]
+ } else {
+ fmt.Printf("切换网盘失败, 请检查 # 值是否正确\n")
+ return
+ }
+ } else {
+ // 直接切换
+ for _,driveInfo := range driveList {
+ if driveInfo.DriveId == targetDriveId {
+ activeDriveInfo = driveInfo
+ break
+ }
+ }
+ }
+
+ if activeDriveInfo == nil {
+ fmt.Printf("切换网盘失败\n")
+ return
+ }
+
+ config.Config.ActiveUser().ActiveDriveId = activeDriveInfo.DriveId
+ activeUser := config.Config.ActiveUser()
+ if currentDriveId != config.Config.ActiveUser().ActiveDriveId {
+ // clear the drive work path
+ if activeUser.IsFileDriveActive() {
+ if activeUser.Workdir == "" {
+ config.Config.ActiveUser().Workdir = "/"
+ config.Config.ActiveUser().WorkdirFileEntity = *aliyunpan.NewFileEntityForRootDir()
+ }
+ } else if activeUser.IsAlbumDriveActive() {
+ if activeUser.AlbumWorkdir == "" {
+ config.Config.ActiveUser().AlbumWorkdir = "/"
+ config.Config.ActiveUser().AlbumWorkdirFileEntity = *aliyunpan.NewFileEntityForRootDir()
+ }
+ }
+ }
+ fmt.Printf("切换到网盘:%s\n", activeDriveInfo.DriveName)
+}
+
+func getDriveOptionList() (config.DriveInfoList, string) {
+ activeUser := config.Config.ActiveUser()
+
+ driveList := activeUser.DriveList
+ builder := &strings.Builder{}
+ tb := cmdtable.NewTable(builder)
+ tb.SetColumnAlignment([]int{tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_RIGHT, tablewriter.ALIGN_CENTER})
+ tb.SetHeader([]string{"#", "drive_id", "网盘名称"})
+
+ for k, info := range driveList {
+ tb.Append([]string{strconv.Itoa(k), info.DriveId, info.DriveName})
+ }
+ tb.Render()
+ return driveList, builder.String()
+}
diff --git a/internal/command/export_file.go b/internal/command/export_file.go
new file mode 100644
index 0000000..7b6c10f
--- /dev/null
+++ b/internal/command/export_file.go
@@ -0,0 +1,152 @@
+// 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/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
+ "github.com/tickstep/aliyunpan/cmder"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/tickstep/library-go/logger"
+ "github.com/urfave/cli"
+ "log"
+ "os"
+ "path"
+ "strconv"
+ "time"
+)
+
+
+func CmdExport() cli.Command {
+ return cli.Command{
+ Name: "export",
+ Usage: "导出文件/目录元数据",
+ UsageText: cmder.App().Name + " export <网盘文件/目录的路径1> <文件/目录2> <文件/目录3> ... <本地保存文件路径>",
+ Description: `
+ 导出指定文件/目录下面的所有文件的元数据信息,并保存到指定的本地文件里面。导出的文件元信息可以使用 import 命令(秒传文件功能)导入到网盘中。
+ 支持多个文件或目录的导出.
+
+ 示例:
+
+ 导出 /我的资源/1.mp4 元数据到文件 /Users/tickstep/Downloads/export_files.txt
+ aliyunpan export /我的资源/1.mp4 /Users/tickstep/Downloads/export_files.txt
+
+ 导出 /我的资源 整个目录 元数据到文件 /Users/tickstep/Downloads/export_files.txt
+ aliyunpan export /我的资源 /Users/tickstep/Downloads/export_files.txt
+
+ 导出 网盘 整个目录 元数据到文件 /Users/tickstep/Downloads/export_files.txt
+ aliyunpan export / /Users/tickstep/Downloads/export_files.txt
+`,
+ Category: "阿里云盘",
+ Before: cmder.ReloadConfigFunc,
+ Action: func(c *cli.Context) error {
+ if c.NArg() < 2 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+
+ subArgs := c.Args()
+ RunExportFiles(parseDriveId(c), c.Bool("ow"), subArgs[:len(subArgs)-1], subArgs[len(subArgs)-1])
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.BoolFlag{
+ Name: "ow",
+ Usage: "overwrite, 覆盖已存在的导出文件",
+ },
+ cli.StringFlag{
+ Name: "driveId",
+ Usage: "网盘ID",
+ Value: "",
+ },
+ },
+ }
+}
+
+
+func RunExportFiles(driveId string, overwrite bool, panPaths []string, saveLocalFilePath string) {
+ activeUser := config.Config.ActiveUser()
+ panClient := activeUser.PanClient()
+
+ lfi,_ := os.Stat(saveLocalFilePath)
+ realSaveFilePath := saveLocalFilePath
+ if lfi != nil {
+ if lfi.IsDir() {
+ realSaveFilePath = path.Join(saveLocalFilePath, "export_file_") + strconv.FormatInt(time.Now().Unix(), 10) + ".txt"
+ } else {
+ if !overwrite {
+ fmt.Println("导出文件已存在")
+ return
+ }
+ }
+ } else {
+ // create file
+ localDir := path.Dir(saveLocalFilePath)
+ dirFs,_ := os.Stat(localDir)
+ if dirFs != nil {
+ if !dirFs.IsDir() {
+ fmt.Println("指定的保存文件路径不合法")
+ return
+ }
+ } else {
+ er := os.MkdirAll(localDir, 0755)
+ if er != nil {
+ fmt.Println("创建本地文件夹出错")
+ return
+ }
+ }
+ realSaveFilePath = saveLocalFilePath
+ }
+
+ totalCount := 0
+ saveFile, err := os.OpenFile(realSaveFilePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
+ if err != nil {
+ log.Fatal(err)
+ return
+ }
+
+ for _,panPath := range panPaths {
+ panPath = activeUser.PathJoin(driveId, panPath)
+ panClient.FilesDirectoriesRecurseList(driveId, panPath, func(depth int, _ string, fd *aliyunpan.FileEntity, apiError *apierror.ApiError) bool {
+ if apiError != nil {
+ logger.Verbosef("%s\n", apiError)
+ return true
+ }
+
+ // 只需要存储文件即可
+ if !fd.IsFolder() {
+ item := newRapidUploadItemFromFileEntity(fd)
+ jstr := item.createRapidUploadLink(false)
+ if len(jstr) <= 0 {
+ logger.Verboseln("create rapid upload link err")
+ return false
+ }
+ saveFile.WriteString(jstr + "\n")
+ totalCount += 1
+ time.Sleep(time.Duration(100) * time.Millisecond)
+ fmt.Printf("\r导出文件数量: %d", totalCount)
+ }
+ return true
+ })
+ }
+
+ // close and save
+ if err := saveFile.Close(); err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("\r导出文件总数量: %d\n", totalCount)
+ fmt.Printf("导出文件保存路径: %s\n", realSaveFilePath)
+}
diff --git a/internal/command/import_file.go b/internal/command/import_file.go
new file mode 100644
index 0000000..63e067c
--- /dev/null
+++ b/internal/command/import_file.go
@@ -0,0 +1,277 @@
+// 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/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
+ "github.com/tickstep/aliyunpan/cmder"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/tickstep/library-go/logger"
+ "github.com/urfave/cli"
+ "io/ioutil"
+ "log"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+type (
+ dirFileListData struct {
+ Dir *aliyunpan.MkdirResult
+ FileList aliyunpan.FileList
+ }
+)
+
+const (
+ DefaultSaveToPanPath = "/aliyunpan"
+)
+
+func CmdImport() cli.Command {
+ return cli.Command{
+ Name: "import",
+ Usage: "导入文件",
+ UsageText: cmder.App().Name + " export <本地元数据文件路径>",
+ Description: `
+ 导入文件中记录的元数据文件到网盘。保存到网盘的文件会使用文件元数据记录的路径位置,如果没有指定云盘目录(saveto)则默认导入到目录 aliyunpan 中。
+ 导入的文件可以使用 export 命令获得。
+
+ 导入文件每一行是一个文件元数据,样例如下:
+ aliyunpan://file.dmg|752FCCBFB2436A6FFCA3B287831D4FAA5654B07E|7005440|pan_folder
+
+ 示例:
+ 导入文件 /Users/tickstep/Downloads/export_files.txt 存储的所有文件元数据项
+ aliyunpan import /Users/tickstep/Downloads/export_files.txt
+
+ 导入文件 /Users/tickstep/Downloads/export_files.txt 存储的所有文件元数据项并保存到目录 /my2021 中
+ aliyunpan import -saveto=/my2021 /Users/tickstep/Downloads/export_files.txt
+
+ 导入文件 /Users/tickstep/Downloads/export_files.txt 存储的所有文件元数据项并保存到网盘根目录 / 中
+ aliyunpan import -saveto=/ /Users/tickstep/Downloads/export_files.txt
+`,
+ Category: "阿里云盘",
+ Before: cmder.ReloadConfigFunc,
+ Action: func(c *cli.Context) error {
+ if c.NArg() < 1 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+
+ saveTo := ""
+ if c.String("saveto") != "" {
+ saveTo = filepath.Clean(c.String("saveto"))
+ }
+
+ subArgs := c.Args()
+ RunImportFiles(parseDriveId(c), c.Bool("ow"), saveTo, subArgs[0])
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.BoolFlag{
+ Name: "ow",
+ Usage: "overwrite, 覆盖已存在的网盘文件",
+ },
+ cli.StringFlag{
+ Name: "driveId",
+ Usage: "网盘ID",
+ Value: "",
+ },
+ cli.StringFlag{
+ Name: "saveto",
+ Usage: "将文件保存到指定的目录",
+ },
+ },
+ }
+}
+
+func RunImportFiles(driveId string, overwrite bool, panSavePath, localFilePath string) {
+ lfi,_ := os.Stat(localFilePath)
+ if lfi != nil {
+ if lfi.IsDir() {
+ fmt.Println("请指定导入文件")
+ return
+ }
+ } else {
+ // create file
+ fmt.Println("导入文件不存在")
+ return
+ }
+
+ if panSavePath == "" {
+ // use default
+ panSavePath = DefaultSaveToPanPath
+ }
+
+ fmt.Println("导入的文件会存储到目录:" + panSavePath)
+
+ importFile, err := os.OpenFile(localFilePath, os.O_RDONLY, 0755)
+ if err != nil {
+ log.Fatal(err)
+ return
+ }
+ defer importFile.Close()
+
+ fileData,err := ioutil.ReadAll(importFile)
+ if err != nil {
+ fmt.Println("读取文件出错")
+ return
+ }
+ fileText := string(fileData)
+ if len(fileText) == 0 {
+ fmt.Println("文件为空")
+ return
+ }
+ fileText = strings.TrimSpace(fileText)
+ fileLines := strings.Split(fileText, "\n")
+ importFileItems := []RapidUploadItem{}
+ for _,line := range fileLines {
+ line = strings.TrimSpace(line)
+ if item,e := newRapidUploadItem(line); e != nil {
+ fmt.Println(e)
+ continue
+ } else {
+ item.FilePath = strings.ReplaceAll(path.Join(panSavePath, item.FilePath), "\\", "/")
+ importFileItems = append(importFileItems, *item)
+ }
+ }
+ if len(importFileItems) == 0 {
+ fmt.Println("没有可以导入的文件项目")
+ return
+ }
+
+ fmt.Println("正在准备导入...")
+ dirMap := prepareMkdir(driveId, importFileItems)
+
+ fmt.Println("正在导入...")
+ successImportFiles := []RapidUploadItem{}
+ failedImportFiles := []RapidUploadItem{}
+ for _,item := range importFileItems {
+ fmt.Printf("正在处理导入: %s\n", item.FilePath)
+ result, abort := processOneImport(driveId, overwrite, dirMap, item)
+ if abort {
+ fmt.Println("导入任务终止了")
+ break
+ }
+ if result {
+ successImportFiles = append(successImportFiles, item)
+ } else {
+ failedImportFiles = append(failedImportFiles, item)
+ }
+ time.Sleep(time.Duration(200) * time.Millisecond)
+ }
+ if len(failedImportFiles) > 0 {
+ fmt.Println("\n以下文件导入失败")
+ for _,f := range failedImportFiles {
+ fmt.Printf("%s %s\n", f.FileSha1, f.FilePath)
+ }
+ fmt.Println("")
+ }
+ fmt.Printf("导入结果, 成功 %d, 失败 %d\n", len(successImportFiles), len(failedImportFiles))
+}
+
+func processOneImport(driveId string, isOverwrite bool, dirMap map[string]*dirFileListData, item RapidUploadItem) (result, abort bool) {
+ panClient := config.Config.ActiveUser().PanClient()
+ panDir,fileName := path.Split(item.FilePath)
+ dataItem := dirMap[path.Dir(panDir)]
+ if isOverwrite {
+ // 标记覆盖旧同名文件
+ // 检查同名文件是否存在
+ var efi *aliyunpan.FileEntity = nil
+ for _,fileItem := range dataItem.FileList {
+ if !fileItem.IsFolder() && fileItem.FileName == fileName {
+ efi = fileItem
+ break
+ }
+ }
+ if efi != nil && efi.FileId != "" {
+ // existed, delete it
+ fdr, err := panClient.FileDelete([]*aliyunpan.FileBatchActionParam{
+ {
+ DriveId:driveId,
+ FileId:efi.FileId,
+ },
+ })
+ if err != nil || fdr == nil || !fdr[0].Success {
+ fmt.Println("无法删除文件,请稍后重试")
+ return false, false
+ }
+ time.Sleep(time.Duration(500) * time.Millisecond)
+ fmt.Println("检测到同名文件,已移动到回收站")
+ }
+ }
+
+ appCreateUploadFileParam := &aliyunpan.CreateFileUploadParam{
+ DriveId: driveId,
+ Name: fileName,
+ Size: item.FileSize,
+ ContentHash: item.FileSha1,
+ ParentFileId: dataItem.Dir.FileId,
+ }
+ uploadOpEntity, apierr := panClient.CreateUploadFile(appCreateUploadFileParam)
+ if apierr != nil {
+ fmt.Println("创建秒传任务失败:" + apierr.Error())
+ return false, true
+ }
+
+ if uploadOpEntity.RapidUpload {
+ logger.Verboseln("秒传成功, 保存到网盘路径: ", path.Join(panDir, uploadOpEntity.FileName))
+ } else {
+ fmt.Println("失败,文件未曾上传,无法秒传")
+ return false, false
+ }
+ return true, false
+}
+
+func prepareMkdir(driveId string, importFileItems []RapidUploadItem) map[string]*dirFileListData {
+ panClient := config.Config.ActiveUser().PanClient()
+ resultMap := map[string]*dirFileListData{}
+ for _,item := range importFileItems {
+ var apierr *apierror.ApiError
+ var rs *aliyunpan.MkdirResult
+ panDir := path.Dir(item.FilePath)
+ if resultMap[panDir] != nil {
+ continue
+ }
+ if panDir != "/" {
+ rs, apierr = panClient.MkdirRecursive(driveId, "", "", 0, strings.Split(path.Clean(panDir), "/"))
+ if apierr != nil || rs.FileId == "" {
+ logger.Verboseln("创建云盘文件夹失败")
+ continue
+ }
+ } else {
+ rs = &aliyunpan.MkdirResult{}
+ rs.FileId = aliyunpan.DefaultRootParentFileId
+ }
+ dataItem := &dirFileListData{}
+ dataItem.Dir = rs
+
+ // files
+ param := &aliyunpan.FileListParam{}
+ param.DriveId = driveId
+ param.ParentFileId = rs.FileId
+ allFileInfo, err1 := panClient.FileListGetAll(param)
+ if err1 != nil {
+ logger.Verboseln("获取文件信息出错")
+ continue
+ }
+ dataItem.FileList = allFileInfo
+
+ resultMap[panDir] = dataItem
+ time.Sleep(time.Duration(500) * time.Millisecond)
+ }
+ return resultMap
+}
\ No newline at end of file
diff --git a/internal/command/login.go b/internal/command/login.go
new file mode 100644
index 0000000..612528d
--- /dev/null
+++ b/internal/command/login.go
@@ -0,0 +1,118 @@
+// 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/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan/cmder"
+ "github.com/tickstep/aliyunpan/internal/config"
+ _ "github.com/tickstep/library-go/requester"
+ "github.com/urfave/cli"
+)
+
+
+func CmdLogin() cli.Command {
+ return cli.Command{
+ Name: "login",
+ Usage: "登录阿里云盘账号",
+ Description: `
+ 示例:
+ aliyunpan login
+ aliyunpan login -RefreshToken=8B12CBBCE89CA8DFC3445985B63B511B5E7EC7...
+
+ 常规登录:
+ 按提示一步一步来即可.
+`,
+ Category: "阿里云盘账号",
+ Before: cmder.ReloadConfigFunc, // 每次进行登录动作的时候需要调用刷新配置
+ After: cmder.SaveConfigFunc, // 登录完成需要调用保存配置
+ Action: func(c *cli.Context) error {
+ webToken := aliyunpan.WebLoginToken{}
+ refreshToken := ""
+ var err error
+ refreshToken, webToken, err = RunLogin(c.String("RefreshToken"))
+ if err != nil {
+ fmt.Println(err)
+ return err
+ }
+
+ cloudUser, err := config.SetupUserByCookie(&webToken)
+ if cloudUser == nil {
+ fmt.Println("登录失败: ", err)
+ return nil
+ }
+ cloudUser.RefreshToken = refreshToken
+ config.Config.SetActiveUser(cloudUser)
+ fmt.Println("阿里云盘登录成功: ", cloudUser.Nickname)
+ return nil
+ },
+ // 命令的附加options参数说明,使用 help login 命令即可查看
+ Flags: []cli.Flag{
+ // aliyunpan login -RefreshToken=8B12CBBCE89CA8DFC3445985B63B511B5E7EC7...
+ cli.StringFlag{
+ Name: "RefreshToken",
+ Usage: "使用 RefreshToken Cookie来登录帐号",
+ },
+ },
+ }
+}
+
+func CmdLogout() cli.Command {
+ return cli.Command{
+ Name: "logout",
+ Usage: "退出阿里帐号",
+ Description: "退出当前登录的帐号",
+ Category: "阿里云盘账号",
+ Before: cmder.ReloadConfigFunc,
+ After: cmder.SaveConfigFunc,
+ Action: func(c *cli.Context) error {
+ if config.Config.NumLogins() == 0 {
+ fmt.Println("未设置任何帐号, 不能退出")
+ return nil
+ }
+
+ var (
+ confirm string
+ activeUser = config.Config.ActiveUser()
+ )
+
+ if !c.Bool("y") {
+ fmt.Printf("确认退出当前帐号: %s ? (y/n) > ", activeUser.Nickname)
+ _, err := fmt.Scanln(&confirm)
+ if err != nil || (confirm != "y" && confirm != "Y") {
+ return err
+ }
+ }
+
+ deletedUser, err := config.Config.DeleteUser(activeUser.UserId)
+ if err != nil {
+ fmt.Printf("退出用户 %s, 失败, 错误: %s\n", activeUser.Nickname, err)
+ }
+
+ fmt.Printf("退出用户成功: %s\n", deletedUser.Nickname)
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.BoolFlag{
+ Name: "y",
+ Usage: "确认退出帐号",
+ },
+ },
+ }
+}
+
+func RunLogin(refreshToken string) (refreshTokenStr string, webToken aliyunpan.WebLoginToken, error error) {
+ return cmder.DoLoginHelper(refreshToken)
+}
diff --git a/internal/command/ls_search.go b/internal/command/ls_search.go
new file mode 100644
index 0000000..ffa7ce2
--- /dev/null
+++ b/internal/command/ls_search.go
@@ -0,0 +1,182 @@
+// 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/cmder"
+ "github.com/tickstep/aliyunpan/cmder/cmdtable"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/tickstep/library-go/converter"
+ "github.com/tickstep/library-go/text"
+ "github.com/urfave/cli"
+ "os"
+ "strconv"
+)
+
+type (
+ // LsOptions 列目录可选项
+ LsOptions struct {
+ Total bool
+ }
+
+ // SearchOptions 搜索可选项
+ SearchOptions struct {
+ Total bool
+ Recurse bool
+ }
+)
+
+const (
+ opLs int = iota
+ opSearch
+)
+
+func CmdLs() cli.Command {
+ return cli.Command{
+ Name: "ls",
+ Aliases: []string{"l", "ll"},
+ Usage: "列出目录",
+ UsageText: cmder.App().Name + " ls <目录>",
+ Description: `
+ 列出当前工作目录内的文件和目录, 或指定目录内的文件和目录
+
+ 示例:
+
+ 列出 我的资源 内的文件和目录
+ aliyunpan ls 我的资源
+
+ 绝对路径
+ aliyunpan ls /我的资源
+
+ 详细列出 我的资源 内的文件和目录
+ aliyunpan ll /我的资源
+`,
+ Category: "阿里云盘",
+ Before: cmder.ReloadConfigFunc,
+ Action: func(c *cli.Context) error {
+ if config.Config.ActiveUser() == nil {
+ fmt.Println("未登录账号")
+ return nil
+ }
+
+ RunLs(parseDriveId(c), c.Args().Get(0), &LsOptions{
+ Total: c.Bool("l") || c.Parent().Args().Get(0) == "ll",
+ })
+
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "driveId",
+ Usage: "网盘ID",
+ Value: "",
+ },
+ },
+ }
+}
+
+func RunLs(driveId, targetPath string, lsOptions *LsOptions) {
+ activeUser := config.Config.ActiveUser()
+ targetPath = activeUser.PathJoin(driveId, targetPath)
+ if targetPath[len(targetPath) - 1] == '/' {
+ targetPath = text.Substr(targetPath, 0, len(targetPath) - 1)
+ }
+
+ targetPathInfo, err := activeUser.PanClient().FileInfoByPath(driveId, targetPath)
+ if err != nil {
+ fmt.Println(err)
+ return
+ }
+
+ fileList := aliyunpan.FileList{}
+ fileListParam := &aliyunpan.FileListParam{}
+ fileListParam.ParentFileId = targetPathInfo.FileId
+ fileListParam.DriveId = driveId
+ if targetPathInfo.IsFolder() {
+ fileResult, err := activeUser.PanClient().FileListGetAll(fileListParam)
+ if err != nil {
+ fmt.Println(err)
+ return
+ }
+ fileList = fileResult
+ } else {
+ fileList = append(fileList, targetPathInfo)
+ }
+ renderTable(opLs, lsOptions.Total, targetPath, fileList)
+}
+
+
+func renderTable(op int, isTotal bool, path string, files aliyunpan.FileList) {
+ tb := cmdtable.NewTable(os.Stdout)
+ var (
+ fN, dN int64
+ showPath string
+ )
+
+ switch op {
+ case opLs:
+ showPath = "文件(目录)"
+ case opSearch:
+ showPath = "路径"
+ }
+
+ if isTotal {
+ tb.SetHeader([]string{"#", "file_id", "文件大小", "文件SHA1", "文件大小(原始)", "创建日期", "修改日期", showPath})
+ tb.SetColumnAlignment([]int{tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_RIGHT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT})
+ for k, file := range files {
+ if file.IsFolder() {
+ tb.Append([]string{strconv.Itoa(k), file.FileId, "-", "-", "-", file.CreatedAt, file.UpdatedAt, file.FileName + aliyunpan.PathSeparator})
+ continue
+ }
+
+ switch op {
+ case opLs:
+ tb.Append([]string{strconv.Itoa(k), file.FileId, converter.ConvertFileSize(file.FileSize, 2), file.ContentHash, strconv.FormatInt(file.FileSize, 10), file.CreatedAt, file.UpdatedAt, file.FileName})
+ case opSearch:
+ tb.Append([]string{strconv.Itoa(k), file.FileId, converter.ConvertFileSize(file.FileSize, 2), file.ContentHash, strconv.FormatInt(file.FileSize, 10), file.CreatedAt, file.UpdatedAt, file.Path})
+ }
+ }
+ fN, dN = files.Count()
+ tb.Append([]string{"", "", "总: " + converter.ConvertFileSize(files.TotalSize(), 2), "", "", "", fmt.Sprintf("文件总数: %d, 目录总数: %d", fN, dN)})
+ } else {
+ tb.SetHeader([]string{"#", "文件大小", "修改日期", showPath})
+ tb.SetColumnAlignment([]int{tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_RIGHT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT})
+ for k, file := range files {
+ if file.IsFolder() {
+ tb.Append([]string{strconv.Itoa(k), "-", file.UpdatedAt, file.FileName + aliyunpan.PathSeparator})
+ continue
+ }
+
+ switch op {
+ case opLs:
+ tb.Append([]string{strconv.Itoa(k), converter.ConvertFileSize(file.FileSize, 2), file.UpdatedAt, file.FileName})
+ case opSearch:
+ tb.Append([]string{strconv.Itoa(k), converter.ConvertFileSize(file.FileSize, 2), file.UpdatedAt, file.Path})
+ }
+ }
+ fN, dN = files.Count()
+ tb.Append([]string{"", "总: " + converter.ConvertFileSize(files.TotalSize(), 2), "", fmt.Sprintf("文件总数: %d, 目录总数: %d", fN, dN)})
+ }
+
+ tb.Render()
+
+ if fN+dN >= 60 {
+ fmt.Printf("\n当前目录: %s\n", path)
+ }
+
+ fmt.Printf("----\n")
+}
diff --git a/internal/command/mkdir.go b/internal/command/mkdir.go
new file mode 100644
index 0000000..f1895d4
--- /dev/null
+++ b/internal/command/mkdir.go
@@ -0,0 +1,74 @@
+// 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/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
+ "github.com/tickstep/aliyunpan/cmder"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/urfave/cli"
+ "strings"
+)
+
+func CmdMkdir() cli.Command {
+ return cli.Command{
+ Name: "mkdir",
+ Usage: "创建目录",
+ UsageText: cmder.App().Name + " mkdir <目录>",
+ Category: "阿里云盘",
+ Before: cmder.ReloadConfigFunc,
+ Action: func(c *cli.Context) error {
+ if c.NArg() == 0 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+ if config.Config.ActiveUser() == nil {
+ fmt.Println("未登录账号")
+ return nil
+ }
+ RunMkdir(parseDriveId(c), c.Args().Get(0))
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "familyId",
+ Usage: "家庭云ID",
+ Value: "",
+ },
+ },
+ }
+}
+
+func RunMkdir(driveId, name string) {
+ activeUser := GetActiveUser()
+ fullpath := activeUser.PathJoin(driveId, name)
+ pathSlice := strings.Split(fullpath, "/")
+ rs := &aliyunpan.MkdirResult{}
+ err := apierror.NewFailedApiError("")
+
+ rs, err = activeUser.PanClient().MkdirRecursive(driveId,"", "", 0, pathSlice)
+
+ if err != nil {
+ fmt.Println("创建文件夹失败:" + err.Error())
+ return
+ }
+
+ if rs.FileId != "" {
+ fmt.Println("创建文件夹成功: ", fullpath)
+ } else {
+ fmt.Println("创建文件夹失败: ", fullpath)
+ }
+}
\ No newline at end of file
diff --git a/internal/command/mv.go b/internal/command/mv.go
new file mode 100644
index 0000000..0a89d9a
--- /dev/null
+++ b/internal/command/mv.go
@@ -0,0 +1,131 @@
+// 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/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan/cmder"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/urfave/cli"
+ "path"
+)
+
+func CmdMv() cli.Command {
+ return cli.Command{
+ Name: "mv",
+ Usage: "移动文件/目录",
+ UsageText: `移动:
+ aliyunpan mv <文件/目录1> <文件/目录2> <文件/目录3> ... <目标目录>`,
+ Description: `
+ 注意: 移动多个文件和目录时, 请确保每一个文件和目录都存在, 否则移动操作会失败.
+
+ 示例:
+
+ 将 /我的资源/1.mp4 移动到 根目录 /
+ aliyunpan mv /我的资源/1.mp4 /
+`,
+ Category: "阿里云盘",
+ Before: cmder.ReloadConfigFunc,
+ 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
+ }
+
+ RunMove(parseDriveId(c), c.Args()...)
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "driveId",
+ Usage: "网盘ID",
+ Value: "",
+ },
+ },
+ }
+}
+
+// RunMove 执行移动文件/目录
+func RunMove(driveId string, paths ...string) {
+ activeUser := GetActiveUser()
+ opFileList, targetFile, _, err := getFileInfo(driveId, paths...)
+ if err != nil {
+ fmt.Println(err)
+ return
+ }
+ if targetFile == nil {
+ fmt.Println("目标文件不存在")
+ return
+ }
+ if opFileList == nil || len(opFileList) == 0 {
+ fmt.Println("没有有效的文件可移动")
+ return
+ }
+
+ failedMoveFiles := []*aliyunpan.FileEntity{}
+ moveFileParamList := []*aliyunpan.FileMoveParam{}
+ fileId2FileEntity := map[string]*aliyunpan.FileEntity{}
+ for _,mfi := range opFileList {
+ fileId2FileEntity[mfi.FileId] = mfi
+ moveFileParamList = append(moveFileParamList,
+ &aliyunpan.FileMoveParam{
+ DriveId: driveId,
+ FileId: mfi.FileId,
+ ToDriveId: driveId,
+ ToParentFileId: targetFile.FileId,
+ })
+ }
+ fmr,er := activeUser.PanClient().FileMove(moveFileParamList)
+
+ for _,rs := range fmr {
+ if !rs.Success {
+ failedMoveFiles = append(failedMoveFiles, fileId2FileEntity[rs.FileId])
+ }
+ }
+
+ if len(failedMoveFiles) > 0 {
+ fmt.Println("以下文件移动失败:")
+ for _,f := range failedMoveFiles {
+ fmt.Println(f.FileName)
+ }
+ fmt.Println("")
+ }
+ if er == nil {
+ fmt.Println("操作成功, 已移动文件到目标目录: ", targetFile.Path)
+ } else {
+ fmt.Println("无法移动文件,请稍后重试")
+ }
+}
+
+func getFileInfo(driveId string, paths ...string) (opFileList []*aliyunpan.FileEntity, targetFile *aliyunpan.FileEntity, failedPaths []string, error error) {
+ if len(paths) <= 1 {
+ return nil, nil, nil, fmt.Errorf("请指定目标文件夹路径")
+ }
+ activeUser := GetActiveUser()
+ // the last one is the target file path
+ targetFilePath := path.Clean(paths[len(paths)-1])
+ absolutePath := activeUser.PathJoin(driveId, targetFilePath)
+ targetFile, err := activeUser.PanClient().FileInfoByPath(driveId, absolutePath)
+ if err != nil || !targetFile.IsFolder() {
+ return nil, nil, nil, fmt.Errorf("指定目标文件夹不存在")
+ }
+
+ opFileList, failedPaths, error = GetAppFileInfoByPaths(driveId, paths[:len(paths)-1]...)
+ return
+}
diff --git a/internal/command/quota.go b/internal/command/quota.go
new file mode 100644
index 0000000..9076d2c
--- /dev/null
+++ b/internal/command/quota.go
@@ -0,0 +1,64 @@
+// 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/tickstep/aliyunpan/cmder"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/tickstep/library-go/converter"
+ "github.com/urfave/cli"
+)
+
+type QuotaInfo struct {
+ // 已使用个人空间大小
+ UsedSize int64
+ // 个人空间总大小
+ Quota int64
+}
+
+func CmdQuota() cli.Command {
+ return cli.Command{
+ Name: "quota",
+ Usage: "获取当前帐号空间配额",
+ Description: "获取网盘的总储存空间, 和已使用的储存空间",
+ Category: "阿里云盘账号",
+ Before: cmder.ReloadConfigFunc,
+ Action: func(c *cli.Context) error {
+ if config.Config.ActiveUser() == nil {
+ fmt.Println("未登录账号")
+ return nil
+ }
+ q, err := RunGetQuotaInfo()
+ if err == nil {
+ fmt.Printf("账号: %s, uid: %s, 个人空间总额: %s, 个人空间已使用: %s, 比率: %f%%\n",
+ config.Config.ActiveUser().Nickname, config.Config.ActiveUser().UserId,
+ converter.ConvertFileSize(q.Quota, 2), converter.ConvertFileSize(q.UsedSize, 2),
+ 100*float64(q.UsedSize)/float64(q.Quota))
+ }
+ return nil
+ },
+ }
+}
+
+func RunGetQuotaInfo() (quotaInfo *QuotaInfo, error error) {
+ user, err := GetActivePanClient().GetUserInfo()
+ if err != nil {
+ return nil, err
+ }
+ return &QuotaInfo{
+ UsedSize: int64(user.UsedSize),
+ Quota: int64(user.TotalSize),
+ }, nil
+}
diff --git a/internal/command/recycle.go b/internal/command/recycle.go
new file mode 100644
index 0000000..c9dff83
--- /dev/null
+++ b/internal/command/recycle.go
@@ -0,0 +1,256 @@
+// 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/cmder"
+ "github.com/tickstep/aliyunpan/cmder/cmdtable"
+ "github.com/tickstep/library-go/converter"
+ "github.com/tickstep/library-go/logger"
+ "github.com/urfave/cli"
+ "os"
+ "strconv"
+)
+
+func CmdRecycle() cli.Command {
+ return cli.Command{
+ Name: "recycle",
+ Usage: "回收站",
+ Description: `
+ 回收站操作.
+
+ 示例:
+
+ 1. 从回收站还原两个文件, 其中的两个文件的 file_id 分别为 1013792297798440 和 643596340463870
+ aliyunpan recycle restore 1013792297798440 643596340463870
+
+ 2. 从回收站删除两个文件, 其中的两个文件的 file_id 分别为 1013792297798440 和 643596340463870
+ aliyunpan recycle delete 1013792297798440 643596340463870
+
+ 3. 清空回收站, 程序不会进行二次确认, 谨慎操作!!!
+ aliyunpan recycle delete -all
+`,
+ Category: "阿里云盘",
+ Before: cmder.ReloadConfigFunc,
+ Action: func(c *cli.Context) error {
+ if c.NumFlags() <= 0 || c.NArg() <= 0 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ }
+ return nil
+ },
+ Subcommands: []cli.Command{
+ {
+ Name: "list",
+ Aliases: []string{"ls", "l"},
+ Usage: "列出回收站文件列表",
+ UsageText: cmder.App().Name + " recycle list",
+ Action: func(c *cli.Context) error {
+ RunRecycleList(parseDriveId(c))
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "driveId",
+ Usage: "网盘ID",
+ Value: "",
+ },
+ },
+ },
+ {
+ Name: "restore",
+ Aliases: []string{"r"},
+ Usage: "还原回收站文件或目录",
+ UsageText: cmder.App().Name + " recycle restore ...",
+ Description: `根据文件/目录的 fs_id, 还原回收站指定的文件或目录`,
+ Action: func(c *cli.Context) error {
+ if c.NArg() <= 0 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+ RunRecycleRestore(parseDriveId(c), c.Args()...)
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "driveId",
+ Usage: "网盘ID",
+ Value: "",
+ },
+ },
+ },
+ {
+ Name: "delete",
+ Aliases: []string{"d"},
+ Usage: "删除回收站文件或目录 / 清空回收站",
+ UsageText: cmder.App().Name + " recycle delete [-all] ...",
+ Description: `根据文件/目录的 file_id 或 -all 参数, 删除回收站指定的文件或目录或清空回收站`,
+ Action: func(c *cli.Context) error {
+ if c.Bool("all") {
+ // 清空回收站
+ RunRecycleClear(parseDriveId(c))
+ return nil
+ }
+
+ if c.NArg() <= 0 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+ RunRecycleDelete(parseDriveId(c), c.Args()...)
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.BoolFlag{
+ Name: "all",
+ Usage: "清空回收站, 程序不会进行二次确认, 谨慎操作!!!",
+ },
+ cli.StringFlag{
+ Name: "driveId",
+ Usage: "网盘ID",
+ Value: "",
+ },
+ },
+ },
+ },
+ }
+}
+
+// RunRecycleList 执行列出回收站文件列表
+func RunRecycleList(driveId string) {
+ panClient := GetActivePanClient()
+ fdl, err := panClient.RecycleBinFileListGetAll(&aliyunpan.RecycleBinFileListParam{
+ DriveId: driveId,
+ Limit: 100,
+ })
+ if err != nil {
+ fmt.Println(err)
+ return
+ }
+
+ tb := cmdtable.NewTable(os.Stdout)
+ tb.SetHeader([]string{"#", "file_id", "文件/目录名", "文件大小", "创建日期", "修改日期"})
+ tb.SetColumnAlignment([]int{tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_RIGHT, tablewriter.ALIGN_RIGHT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT})
+ for k, file := range fdl {
+ fn := file.FileName
+ fs := converter.ConvertFileSize(file.FileSize, 2)
+ if file.IsFolder() {
+ fn = fn + "/"
+ fs = "-"
+ }
+ tb.Append([]string{strconv.Itoa(k), file.FileId, fn, fs, file.CreatedAt, file.UpdatedAt})
+ }
+
+ tb.Render()
+}
+
+// RunRecycleRestore 执行还原回收站文件或目录
+func RunRecycleRestore(driveId string, fidStrList ...string) {
+ panClient := GetActivePanClient()
+ restoreFileList := []*aliyunpan.FileBatchActionParam{}
+
+ for _,fid := range fidStrList {
+ restoreFileList = append(restoreFileList, &aliyunpan.FileBatchActionParam{
+ DriveId: driveId,
+ FileId: fid,
+ })
+ }
+
+ if len(restoreFileList) == 0 {
+ fmt.Printf("没有需要还原的文件")
+ return
+ }
+
+ rbfr, err := panClient.RecycleBinFileRestore(restoreFileList)
+ if rbfr != nil && len(rbfr) > 0 {
+ fmt.Printf("还原文件成功\n")
+ return
+ }
+
+ if len(rbfr) == 0 && err != nil {
+ fmt.Printf("还原文件失败:%s\n", err)
+ return
+ }
+}
+
+// RunRecycleDelete 执行删除回收站文件或目录
+func RunRecycleDelete(driveId string, fidStrList ...string) {
+ panClient := GetActivePanClient()
+ deleteFileList := []*aliyunpan.FileBatchActionParam{}
+
+ for _,fid := range fidStrList {
+ deleteFileList = append(deleteFileList, &aliyunpan.FileBatchActionParam{
+ DriveId: driveId,
+ FileId: fid,
+ })
+ }
+
+ if len(deleteFileList) == 0 {
+ fmt.Printf("没有需要删除的文件")
+ return
+ }
+
+ rbfr, err := panClient.RecycleBinFileDelete(deleteFileList)
+ if rbfr != nil && len(rbfr) > 0 {
+ fmt.Printf("彻底删除文件成功\n")
+ return
+ }
+
+ if len(rbfr) == 0 && err != nil {
+ fmt.Printf("彻底删除文件失败:%s\n", err)
+ return
+ }
+}
+
+// RunRecycleClear 清空回收站
+func RunRecycleClear(driveId string) {
+ panClient := GetActivePanClient()
+
+ for {
+ // get file list
+ fdl, err := panClient.RecycleBinFileListGetAll(&aliyunpan.RecycleBinFileListParam{
+ DriveId: driveId,
+ Limit: 100,
+ })
+ if err != nil {
+ logger.Verboseln(err)
+ break
+ }
+ if fdl == nil || len(fdl) == 0 {
+ break
+ }
+
+ // delete
+ deleteFileList := []*aliyunpan.FileBatchActionParam{}
+ for _,f := range fdl {
+ deleteFileList = append(deleteFileList, &aliyunpan.FileBatchActionParam{
+ DriveId: driveId,
+ FileId: f.FileId,
+ })
+ }
+
+ if len(deleteFileList) == 0 {
+ logger.Verboseln("没有需要删除的文件")
+ break
+ }
+
+ rbfr, err := panClient.RecycleBinFileDelete(deleteFileList)
+ if rbfr != nil && len(rbfr) > 0 {
+ logger.Verboseln("彻底删除文件成功")
+ }
+ }
+
+ fmt.Printf("清空回收站成功\n")
+}
diff --git a/internal/command/rename.go b/internal/command/rename.go
new file mode 100644
index 0000000..2d68ccf
--- /dev/null
+++ b/internal/command/rename.go
@@ -0,0 +1,105 @@
+// 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/tickstep/aliyunpan-api/aliyunpan/apiutil"
+ "github.com/tickstep/aliyunpan/cmder"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/urfave/cli"
+ "path"
+ "strings"
+)
+
+func CmdRename() cli.Command {
+ return cli.Command{
+ Name: "rename",
+ Usage: "重命名文件",
+ UsageText: `重命名文件:
+ aliyunpan rename <旧文件/目录名> <新文件/目录名>`,
+ Description: `
+ 示例:
+
+ 将文件 1.mp4 重命名为 2.mp4
+ aliyunpan rename 1.mp4 2.mp4
+
+ 将文件 /test/1.mp4 重命名为 /test/2.mp4
+ 要求必须是同一个文件目录内
+ aliyunpan rename /test/1.mp4 /test/2.mp4
+`,
+ Category: "阿里云盘",
+ Before: cmder.ReloadConfigFunc,
+ Action: func(c *cli.Context) error {
+ if c.NArg() != 2 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+ if config.Config.ActiveUser() == nil {
+ fmt.Println("未登录账号")
+ return nil
+ }
+ RunRename(parseDriveId(c), c.Args().Get(0), c.Args().Get(1))
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "driveId",
+ Usage: "网盘ID",
+ Value: "",
+ },
+ },
+ }
+}
+
+func RunRename(driveId string, oldName string, newName string) {
+ if oldName == "" {
+ fmt.Println("请指定命名文件")
+ return
+ }
+ if newName == "" {
+ fmt.Println("请指定文件新名称")
+ return
+ }
+ activeUser := GetActiveUser()
+ oldName = activeUser.PathJoin(driveId, strings.TrimSpace(oldName))
+ newName = activeUser.PathJoin(driveId, strings.TrimSpace(newName))
+ if path.Dir(oldName) != path.Dir(newName) {
+ fmt.Println("只能命名同一个目录的文件")
+ return
+ }
+ if !apiutil.CheckFileNameValid(path.Base(newName)) {
+ fmt.Println("文件名不能包含特殊字符:" + apiutil.FileNameSpecialChars)
+ return
+ }
+
+ fileId := ""
+ r, err := GetActivePanClient().FileInfoByPath(driveId, activeUser.PathJoin(driveId, oldName))
+ if err != nil {
+ fmt.Printf("原文件不存在: %s, %s\n", oldName, err)
+ return
+ }
+ fileId = r.FileId
+
+ b, e := activeUser.PanClient().FileRename(driveId, fileId, path.Base(newName))
+ if e != nil {
+ fmt.Println(e.Err)
+ return
+ }
+ if !b {
+ fmt.Println("重命名文件失败")
+ return
+ }
+ fmt.Printf("重命名文件成功:%s -> %s\n", path.Base(oldName), path.Base(newName))
+}
diff --git a/internal/command/rm.go b/internal/command/rm.go
new file mode 100644
index 0000000..d12e75a
--- /dev/null
+++ b/internal/command/rm.go
@@ -0,0 +1,125 @@
+// 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/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan/cmder"
+ "github.com/tickstep/aliyunpan/cmder/cmdtable"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/urfave/cli"
+ "os"
+ "path"
+ "strconv"
+)
+
+func CmdRm() cli.Command {
+ return cli.Command{
+ Name: "rm",
+ Usage: "删除文件/目录",
+ UsageText: cmder.App().Name + " rm <文件/目录的路径1> <文件/目录2> <文件/目录3> ...",
+ Description: `
+ 注意: 删除多个文件和目录时, 请确保每一个文件和目录都存在, 否则删除操作会失败.
+ 被删除的文件或目录可在网盘文件回收站找回.
+
+ 示例:
+
+ 删除 /我的资源/1.mp4
+ aliyunpan rm /我的资源/1.mp4
+
+ 删除 /我的资源/1.mp4 和 /我的资源/2.mp4
+ aliyunpan rm /我的资源/1.mp4 /我的资源/2.mp4
+
+ 删除 /我的资源 整个目录 !!
+ aliyunpan rm /我的资源
+`,
+ Category: "阿里云盘",
+ Before: cmder.ReloadConfigFunc,
+ Action: func(c *cli.Context) error {
+ if c.NArg() == 0 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+ if config.Config.ActiveUser() == nil {
+ fmt.Println("未登录账号")
+ return nil
+ }
+ RunRemove(parseDriveId(c), c.Args()...)
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "driveId",
+ Usage: "网盘ID",
+ Value: "",
+ },
+ },
+ }
+}
+
+// RunRemove 执行 批量删除文件/目录
+func RunRemove(driveId string, paths ...string) {
+ activeUser := GetActiveUser()
+
+ failedRmPaths := make([]string, 0, len(paths))
+ delFileInfos := []*aliyunpan.FileBatchActionParam{}
+ fileId2FileEntity := map[string]*aliyunpan.FileEntity{}
+
+ for _, p := range paths {
+ absolutePath := path.Clean(activeUser.PathJoin(driveId, p))
+ fe, err := activeUser.PanClient().FileInfoByPath(driveId, absolutePath)
+ if err != nil {
+ failedRmPaths = append(failedRmPaths, absolutePath)
+ continue
+ }
+ fe.Path = absolutePath
+ delFileInfos = append(delFileInfos, &aliyunpan.FileBatchActionParam{
+ DriveId:driveId,
+ FileId:fe.FileId,
+ })
+ fileId2FileEntity[fe.FileId] = fe
+ }
+
+ // delete
+ successDelFileEntity := []*aliyunpan.FileEntity{}
+ fdr, err := activeUser.PanClient().FileDelete(delFileInfos)
+ if fdr != nil {
+ for _,item := range fdr {
+ if !item.Success {
+ failedRmPaths = append(failedRmPaths, fileId2FileEntity[item.FileId].Path)
+ } else {
+ successDelFileEntity = append(successDelFileEntity, fileId2FileEntity[item.FileId])
+ }
+ }
+ }
+
+ pnt := func() {
+ tb := cmdtable.NewTable(os.Stdout)
+ tb.SetHeader([]string{"#", "文件/目录"})
+ for k := range successDelFileEntity {
+ tb.Append([]string{strconv.Itoa(k), successDelFileEntity[k].Path})
+ }
+ tb.Render()
+ }
+ if len(successDelFileEntity) > 0 {
+ fmt.Println("操作成功, 以下文件/目录已删除, 可在云盘文件回收站找回: ")
+ pnt()
+ }
+
+ if len(successDelFileEntity) == 0 && err != nil {
+ fmt.Println("无法删除文件,请稍后重试")
+ return
+ }
+}
diff --git a/internal/command/share.go b/internal/command/share.go
new file mode 100644
index 0000000..0b0c276
--- /dev/null
+++ b/internal/command/share.go
@@ -0,0 +1,320 @@
+// 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/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/tickstep/library-go/logger"
+ "github.com/urfave/cli"
+ "os"
+ "strconv"
+ "time"
+)
+
+func CmdShare() cli.Command {
+ return cli.Command{
+ Name: "share",
+ Usage: "分享文件/目录",
+ UsageText: cmder.App().Name + " share",
+ Category: "阿里云盘",
+ Before: cmder.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 1.mp4
+
+ 创建文件 1.mp4 的分享链接,并指定有效期为1天
+ aliyunpan share set -time 1 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
+ }
+ 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 := ""
+ modeFlag := "1"
+ if c.IsSet("mode") {
+ modeFlag = c.String("mode")
+ }
+ if modeFlag == "1" {
+ sharePwd = RandomStr(4)
+ } else {
+ sharePwd = ""
+ }
+ RunShareSet(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-公开分享",
+ Value: "1",
+ },
+ },
+ },
+ {
+ Name: "list",
+ Aliases: []string{"l"},
+ Usage: "列出已分享文件/目录",
+ UsageText: cmder.App().Name + " share list",
+ Action: func(c *cli.Context) error {
+ RunShareList()
+ return nil
+ },
+ Flags: []cli.Flag{
+ },
+ },
+ {
+ Name: "cancel",
+ Aliases: []string{"c"},
+ Usage: "取消分享文件/目录",
+ UsageText: cmder.App().Name + " share cancel ...",
+ Description: `目前只支持通过分享id (shareid) 来取消分享.`,
+ Action: func(c *cli.Context) error {
+ if c.NArg() < 1 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+ RunShareCancel(c.Args())
+ return nil
+ },
+ },
+ {
+ Name: "mc",
+ Aliases: []string{},
+ Usage: "创建秒传链接",
+ UsageText: cmder.App().Name + " share mc <文件/目录1> <文件/目录2> ...",
+ Description: `
+创建文件秒传链接,秒传链接只能是文件,如果是文件夹则会创建文件夹包含的所有文件的秒传链接。秒传链接可以通过RapidUpload命令或者Import命令进行导入到自己的网盘。
+示例:
+ 创建文件 1.mp4 的秒传链接
+ aliyunpan share mc 1.mp4
+
+ 创建文件 1.mp4 的秒传链接,但链接隐藏相对路径
+ aliyunpan share mc -hp 1.mp4
+
+ 创建文件夹 share_folder 下面所有文件的秒传链接
+ aliyunpan share mc share_folder/
+`,
+ 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
+ }
+ hp := false
+ if c.IsSet("hp") {
+ hp = c.Bool("hp")
+ }
+ RunShareMc(parseDriveId(c), hp, c.Args())
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.StringFlag{
+ Name: "driveId",
+ Usage: "网盘ID",
+ Value: "",
+ },
+ cli.BoolFlag{
+ Name: "hp",
+ Usage: "hide path, 隐藏相对目录",
+ },
+ },
+ },
+ },
+ }
+}
+
+// RunShareSet 执行分享
+func RunShareSet(driveId string, paths []string, expiredTime string, sharePwd string) {
+ panClient := GetActivePanClient()
+ fileList, _, err := GetFileInfoByPaths(paths[:len(paths)]...)
+ if err != nil {
+ fmt.Println(err)
+ return
+ }
+
+ fidList := []string{}
+ for _,f := range fileList {
+ fidList = append(fidList, f.FileId)
+ }
+
+ if len(fidList) == 0 {
+ fmt.Printf("没有指定有效的文件\n")
+ return
+ }
+
+ r, err1 := panClient.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", err)
+ }
+ 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().ShareLinkList(activeUser.UserId)
+ if err != nil {
+ fmt.Printf("获取分享列表失败: %s\n", err)
+ return
+ }
+
+ tb := cmdtable.NewTable(os.Stdout)
+ tb.SetHeader([]string{"#", "ShARE_ID", "分享链接", "提取码", "文件名", "FILE_ID", "过期时间", "状态"})
+ now := time.Now()
+ for k, record := range records {
+ et := "永久有效"
+ if len(record.Expiration) > 0 {
+ et = record.Expiration
+ }
+ status := "有效"
+ 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 = "已过期"
+ }
+ }
+ }
+ tb.Append([]string{strconv.Itoa(k), 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().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 RunShareMc(driveId string, hideRelativePath bool, panPaths []string) {
+ activeUser := config.Config.ActiveUser()
+ panClient := activeUser.PanClient()
+
+ totalCount := 0
+ for _,panPath := range panPaths {
+ panPath = activeUser.PathJoin(driveId, panPath)
+ panClient.FilesDirectoriesRecurseList(driveId, panPath, func(depth int, _ string, fd *aliyunpan.FileEntity, apiError *apierror.ApiError) bool {
+ if apiError != nil {
+ logger.Verbosef("%s\n", apiError)
+ return true
+ }
+
+ // 只需要文件即可
+ if !fd.IsFolder() {
+ item := newRapidUploadItemFromFileEntity(fd)
+ jstr := item.createRapidUploadLink(hideRelativePath)
+ if len(jstr) <= 0 {
+ logger.Verboseln("create rapid upload link err")
+ return false
+ }
+ // print
+ fmt.Println(jstr)
+ totalCount += 1
+ time.Sleep(time.Duration(100) * time.Millisecond)
+ }
+ return true
+ })
+ }
+ fmt.Printf("\n秒传链接总数量: %d\n", totalCount)
+}
\ No newline at end of file
diff --git a/internal/command/upload.go b/internal/command/upload.go
new file mode 100644
index 0000000..b7efe55
--- /dev/null
+++ b/internal/command/upload.go
@@ -0,0 +1,605 @@
+// 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/tickstep/aliyunpan-api/aliyunpan/apierror"
+ "github.com/tickstep/aliyunpan/cmder"
+ "io/ioutil"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/tickstep/library-go/logger"
+
+ "github.com/urfave/cli"
+
+ "github.com/tickstep/aliyunpan/cmder/cmdutil"
+
+ "github.com/oleiade/lane"
+ "github.com/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan/cmder/cmdtable"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/tickstep/aliyunpan/internal/functions/panupload"
+ "github.com/tickstep/aliyunpan/internal/localfile"
+ "github.com/tickstep/aliyunpan/internal/taskframework"
+ "github.com/tickstep/library-go/converter"
+)
+
+const (
+ // DefaultUploadMaxAllParallel 默认所有文件并发上传数量,即可以同时并发上传多少个文件
+ DefaultUploadMaxAllParallel = 1
+ // DefaultUploadMaxRetry 默认上传失败最大重试次数
+ DefaultUploadMaxRetry = 3
+)
+
+type (
+ // UploadOptions 上传可选项
+ UploadOptions struct {
+ AllParallel int // 所有文件并发上传数量,即可以同时并发上传多少个文件
+ Parallel int // 单个文件并发上传数量
+ MaxRetry int
+ NoRapidUpload bool
+ ShowProgress bool
+ IsOverwrite bool // 覆盖已存在的文件,如果同名文件已存在则移到回收站里
+ DriveId string
+ ExcludeNames []string // 排除的文件名,包括文件夹和文件。即这些文件/文件夹不进行上传,支持正则表达式
+ BlockSize int64 // 分片大小
+ }
+)
+
+var UploadFlags = []cli.Flag{
+ cli.IntFlag{
+ Name: "p",
+ Usage: "本次操作文件上传并发数量,即可以同时并发上传多少个文件。0代表跟从配置文件设置",
+ Value: 0,
+ },
+ cli.IntFlag{
+ Name: "retry",
+ Usage: "上传失败最大重试次数",
+ Value: DefaultUploadMaxRetry,
+ },
+ cli.BoolFlag{
+ Name: "np",
+ Usage: "no progress 不展示上传进度条",
+ },
+ cli.BoolFlag{
+ Name: "ow",
+ Usage: "overwrite, 覆盖已存在的同名文件,注意已存在的文件会被移到回收站",
+ },
+ cli.BoolFlag{
+ Name: "norapid",
+ Usage: "不检测秒传",
+ },
+ cli.StringFlag{
+ Name: "driveId",
+ Usage: "网盘ID",
+ Value: "",
+ },
+ cli.StringSliceFlag{
+ Name: "exn",
+ Usage: "exclude name,指定排除的文件夹或者文件的名称,只支持正则表达式。支持同时排除多个名称,每一个名称就是一个exn参数",
+ Value: nil,
+ },
+ cli.IntFlag{
+ Name: "bs",
+ Usage: "block size,上传分片大小,单位KB。推荐值:512 ~ 2048",
+ Value: 512,
+ },
+}
+
+func CmdUpload() cli.Command {
+ return cli.Command{
+ Name: "upload",
+ Aliases: []string{"u"},
+ Usage: "上传文件/目录",
+ UsageText: cmder.App().Name + " upload <本地文件/目录的路径1> <文件/目录2> <文件/目录3> ... <目标目录>",
+ Description: `
+ 上传指定的文件夹或者文件,上传的文件将会保存到 <目标目录>.
+
+ 示例:
+ 1. 将本地的 C:\Users\Administrator\Desktop\1.mp4 上传到网盘 /视频 目录
+ 注意区别反斜杠 "\" 和 斜杠 "/" !!!
+ aliyunpan upload C:/Users/Administrator/Desktop/1.mp4 /视频
+
+ 2. 将本地的 C:\Users\Administrator\Desktop\1.mp4 和 C:\Users\Administrator\Desktop\2.mp4 上传到网盘 /视频 目录
+ aliyunpan upload C:/Users/Administrator/Desktop/1.mp4 C:/Users/Administrator/Desktop/2.mp4 /视频
+
+ 3. 将本地的 C:\Users\Administrator\Desktop 整个目录上传到网盘 /视频 目录
+ aliyunpan upload C:/Users/Administrator/Desktop /视频
+
+ 4. 使用相对路径
+ aliyunpan upload 1.mp4 /视频
+
+ 5. 覆盖上传,已存在的同名文件会被移到回收站
+ aliyunpan upload -ow 1.mp4 /视频
+
+ 6. 将本地的 C:\Users\Administrator\Video 整个目录上传到网盘 /视频 目录,但是排除所有的.jpg文件
+ aliyunpan upload -exn "\.jpg$" C:/Users/Administrator/Video /视频
+
+ 7. 将本地的 C:\Users\Administrator\Video 整个目录上传到网盘 /视频 目录,但是排除所有的.jpg文件和.mp3文件,每一个排除项就是一个exn参数
+ aliyunpan upload -exn "\.jpg$" -exn "\.mp3$" C:/Users/Administrator/Video /视频
+
+ 8. 将本地的 C:\Users\Administrator\Video 整个目录上传到网盘 /视频 目录,但是排除所有的 @eadir 文件夹
+ aliyunpan upload -exn "^@eadir$" C:/Users/Administrator/Video /视频
+
+ 参考:
+ 以下是典型的排除特定文件或者文件夹的例子,注意:参数值必须是正则表达式。在正则表达式中,^表示匹配开头,$表示匹配结尾。
+ 1)排除@eadir文件或者文件夹:-exn "^@eadir$"
+ 2)排除.jpg文件:-exn "\.jpg$"
+ 3)排除.号开头的文件:-exn "^\."
+ 4)排除~号开头的文件:-exn "^~"
+ 5)排除 myfile.txt 文件:-exn "^myfile.txt$"
+`,
+ Category: "阿里云盘",
+ Before: cmder.ReloadConfigFunc,
+ Action: func(c *cli.Context) error {
+ if c.NArg() < 2 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+
+ subArgs := c.Args()
+ RunUpload(subArgs[:c.NArg()-1], subArgs[c.NArg()-1], &UploadOptions{
+ AllParallel: c.Int("p"), // 多文件上传的时候,允许同时并行上传的文件数量
+ Parallel: 1, // 一个文件同时多少个线程并发上传的数量。阿里云盘只支持单线程按顺序进行文件part数据上传,所以只能是1
+ MaxRetry: c.Int("retry"),
+ NoRapidUpload: c.Bool("norapid"),
+ ShowProgress: !c.Bool("np"),
+ IsOverwrite: c.Bool("ow"),
+ DriveId: parseDriveId(c),
+ ExcludeNames: c.StringSlice("exn"),
+ BlockSize: int64(c.Int("bs") * 1024),
+ })
+ return nil
+ },
+ Flags: UploadFlags,
+ }
+}
+
+func CmdRapidUpload() cli.Command {
+ return cli.Command{
+ Name: "rapidupload",
+ Aliases: []string{"ru"},
+ Usage: "手动秒传文件",
+ UsageText: cmder.App().Name + " rapidupload \"aliyunpan://file.dmg|752FCCBFB2436A6FFCA3B287831D4FAA5654B07E|7005440|pan_folder\"",
+ Description: `
+ 使用此功能秒传文件, 前提是知道文件的大小, sha1, 且网盘中存在一模一样的文件.
+ 上传的文件将会保存到网盘的目标目录。文件的秒传链接可以通过share或者export命令获取。
+
+ 链接格式说明:aliyunpan://文件名|sha1|文件大小|<相对路径>
+ "相对路径" 可以为空,为空代表存储到网盘根目录
+
+ 示例:
+ 1. 如果秒传成功, 则保存到网盘路径 /pan_folder/file.dmg
+ aliyunpan rapidupload "aliyunpan://file.dmg|752FCCBFB2436A6FFCA3B287831D4FAA5654B07E|7005440|pan_folder"
+
+ 2. 如果秒传成功, 则保存到网盘路径 /file.dmg
+ aliyunpan rapidupload "aliyunpan://file.dmg|752FCCBFB2436A6FFCA3B287831D4FAA5654B07E|7005440|"
+
+ 3. 同时秒传多个文件,如果秒传成功, 则保存到网盘路径 /pan_folder/file.dmg, /pan_folder/file1.dmg
+ aliyunpan rapidupload "aliyunpan://file.dmg|752FCCBFB2436A6FFCA3B287831D4FAA5654B07E|7005440|pan_folder" "aliyunpan://file1.dmg|752FCCBFB2436A6FFCA3B287831D4FAA5654B07E|7005440|pan_folder"
+`,
+ Category: "阿里云盘",
+ Before: cmder.ReloadConfigFunc,
+ Action: func(c *cli.Context) error {
+ if c.NArg() <= 0 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+ subArgs := c.Args()
+ RunRapidUpload(parseDriveId(c), c.Bool("ow"), subArgs, c.String("path"))
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.BoolFlag{
+ Name: "ow",
+ Usage: "overwrite, 覆盖已存在的文件。已存在的文件会并移到回收站",
+ },
+ cli.StringFlag{
+ Name: "path",
+ Usage: "存储到网盘目录,绝对路径,例如:/myfolder",
+ Value: "",
+ },
+ cli.StringFlag{
+ Name: "driveId",
+ Usage: "网盘ID",
+ Value: "",
+ },
+ },
+ }
+}
+
+// RunUpload 执行文件上传
+func RunUpload(localPaths []string, savePath string, opt *UploadOptions) {
+ activeUser := GetActiveUser()
+ // pan token expired checker
+ go func() {
+ cz := time.FixedZone("CST", 8*3600) // 东8区
+ for {
+ time.Sleep(time.Duration(1) * time.Minute)
+ expiredTime, _ := time.ParseInLocation("2006-01-02 15:04:05", activeUser.WebToken.ExpireTime, cz)
+ now := time.Now()
+ if (expiredTime.Unix() - now.Unix()) <= (10 * 60) {
+ // need refresh token
+ if wt, er := aliyunpan.GetAccessTokenFromRefreshToken(activeUser.RefreshToken); er == nil {
+ activeUser.WebToken = *wt
+ activeUser.PanClient().UpdateToken(*wt)
+ logger.Verboseln("update access token for upload task")
+ }
+ }
+ }
+ }()
+
+ if opt == nil {
+ opt = &UploadOptions{}
+ }
+
+ // 检测opt
+ if opt.AllParallel <= 0 {
+ opt.AllParallel = config.Config.MaxUploadParallel
+ }
+ if opt.Parallel <= 0 {
+ opt.Parallel = 1
+ }
+
+ if opt.MaxRetry < 0 {
+ opt.MaxRetry = DefaultUploadMaxRetry
+ }
+
+ savePath = activeUser.PathJoin(opt.DriveId, savePath)
+ _, err1 := activeUser.PanClient().FileInfoByPath(opt.DriveId, savePath)
+ if err1 != nil {
+ fmt.Printf("警告: 上传文件, 获取云盘路径 %s 错误, %s\n", savePath, err1)
+ }
+
+ switch len(localPaths) {
+ case 0:
+ fmt.Printf("本地路径为空\n")
+ return
+ }
+
+ // 打开上传状态
+ uploadDatabase, err := panupload.NewUploadingDatabase()
+ if err != nil {
+ fmt.Printf("打开上传未完成数据库错误: %s\n", err)
+ return
+ }
+ defer uploadDatabase.Close()
+
+ var (
+ // 使用 task framework
+ executor = &taskframework.TaskExecutor{
+ IsFailedDeque: true, // 失败统计
+ }
+ // 统计
+ statistic = &panupload.UploadStatistic{}
+
+ folderCreateMutex = &sync.Mutex{}
+ )
+ executor.SetParallel(opt.AllParallel)
+ statistic.StartTimer() // 开始计时
+
+ // 遍历指定的文件并创建上传任务
+ for _, curPath := range localPaths {
+ var walkFunc filepath.WalkFunc
+ var db panupload.SyncDb
+ curPath = filepath.Clean(curPath)
+ localPathDir := filepath.Dir(curPath)
+
+ // 是否排除上传
+ if isExcludeFile(curPath, opt) {
+ fmt.Printf("排除文件: %s\n", curPath)
+ continue
+ }
+
+ // 避免去除文件名开头的"."
+ if localPathDir == "." {
+ localPathDir = ""
+ }
+
+ if fi, err := os.Stat(curPath); err == nil && fi.IsDir() {
+ //使用绝对路径避免异常
+ dbpath, err := filepath.Abs(curPath)
+ if err != nil {
+ dbpath = curPath
+ }
+ dbpath += string(os.PathSeparator) + BackupMetaDirName
+ if di, err := os.Stat(dbpath); err == nil && di.IsDir() {
+ db, err = panupload.OpenSyncDb(dbpath+string(os.PathSeparator) + "db", BackupMetaBucketName)
+ if db != nil {
+ defer func(syncDb panupload.SyncDb) {
+ db.Close()
+ }(db)
+ } else {
+ fmt.Println(curPath, "同步数据库打开失败,跳过该目录的备份", err)
+ continue
+ }
+ }
+ }
+
+ walkFunc = func(file string, fi os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ // 是否排除上传
+ if isExcludeFile(file, opt) {
+ fmt.Printf("排除文件: %s\n", file)
+ return filepath.SkipDir
+ }
+
+ if fi.Mode()&os.ModeSymlink != 0 { // 读取 symbol link
+ err = WalkAllFile(file+string(os.PathSeparator), walkFunc)
+ return err
+ }
+
+ subSavePath := strings.TrimPrefix(file, localPathDir)
+
+ // 针对 windows 的目录处理
+ if os.PathSeparator == '\\' {
+ subSavePath = cmdutil.ConvertToUnixPathSeparator(subSavePath)
+ }
+
+ subSavePath = path.Clean(savePath + aliyunpan.PathSeparator + subSavePath)
+ var ufm *panupload.UploadedFileMeta
+
+ if db != nil {
+ if ufm = db.Get(subSavePath); ufm.Size == fi.Size() && ufm.ModTime == fi.ModTime().Unix() {
+ logger.Verbosef("文件未修改跳过:%s\n", file)
+ return nil
+ }
+ }
+
+ if fi.IsDir() { // 备份目录处理
+ if strings.HasPrefix(fi.Name(), BackupMetaDirName) {
+ return filepath.SkipDir
+ }
+ //不存在同步数据库时跳过
+ if db == nil || ufm.FileId != "" {
+ return nil
+ }
+ panClient := activeUser.PanClient()
+ fmt.Println(subSavePath, "云盘文件夹预创建")
+ //首先尝试直接创建文件夹
+ if ufm = db.Get(path.Dir(subSavePath)); ufm.IsFolder == true && ufm.FileId != "" {
+ rs, err := panClient.Mkdir(opt.DriveId, ufm.FileId, fi.Name())
+ if err == nil && rs != nil && rs.FileId != "" {
+ db.Put(subSavePath, &panupload.UploadedFileMeta{FileId: rs.FileId, IsFolder: true, ModTime: fi.ModTime().Unix(), ParentId: rs.ParentFileId})
+ return nil
+ }
+ }
+ rs, err := panClient.MkdirRecursive(opt.DriveId, "", "", 0, strings.Split(path.Clean(subSavePath), "/"))
+ if err == nil && rs != nil && rs.FileId != "" {
+ db.Put(subSavePath, &panupload.UploadedFileMeta{FileId: rs.FileId, IsFolder: true, ModTime: fi.ModTime().Unix(), ParentId: rs.ParentFileId})
+ return nil
+ }
+ fmt.Println(subSavePath, "创建云盘文件夹失败", err)
+ return filepath.SkipDir
+ }
+
+ taskinfo := executor.Append(&panupload.UploadTaskUnit{
+ LocalFileChecksum: localfile.NewLocalFileEntity(file),
+ SavePath: subSavePath,
+ DriveId: opt.DriveId,
+ PanClient: activeUser.PanClient(),
+ UploadingDatabase: uploadDatabase,
+ FolderCreateMutex: folderCreateMutex,
+ Parallel: opt.Parallel,
+ NoRapidUpload: opt.NoRapidUpload,
+ BlockSize: opt.BlockSize,
+ UploadStatistic: statistic,
+ ShowProgress: opt.ShowProgress,
+ IsOverwrite: opt.IsOverwrite,
+ FolderSyncDb: db,
+ }, opt.MaxRetry)
+
+ fmt.Printf("%s [%s] 加入上传队列: %s\n", time.Now().Format("2006-01-02 15:04:05"), taskinfo.Id(), file)
+ return nil
+ }
+ if err := WalkAllFile(curPath, walkFunc); err != nil {
+ fmt.Printf("警告: 遍历错误: %s\n", err)
+ }
+ }
+
+ // 执行上传任务
+ var failedList []*lane.Deque
+ executor.Execute()
+ failed := executor.FailedDeque()
+ if failed.Size() > 0 {
+ failedList = append(failedList, failed)
+ }
+
+ fmt.Printf("\n")
+ fmt.Printf("上传结束, 时间: %s, 总大小: %s\n", statistic.Elapsed()/1e6*1e6, converter.ConvertFileSize(statistic.TotalSize()))
+
+ // 输出上传失败的文件列表
+ for _, failed := range failedList {
+ if failed.Size() != 0 {
+ fmt.Printf("以下文件上传失败: \n")
+ tb := cmdtable.NewTable(os.Stdout)
+ for e := failed.Shift(); e != nil; e = failed.Shift() {
+ item := e.(*taskframework.TaskInfoItem)
+ tb.Append([]string{item.Info.Id(), item.Unit.(*panupload.UploadTaskUnit).LocalFileChecksum.Path})
+ }
+ tb.Render()
+ }
+ }
+
+}
+
+// 是否是排除上传的文件
+func isExcludeFile(filePath string, opt *UploadOptions) bool {
+ if opt == nil || len(opt.ExcludeNames) == 0{
+ return false
+ }
+
+ for _,pattern := range opt.ExcludeNames {
+ fileName := path.Base(filePath)
+
+ m,_ := regexp.MatchString(pattern, fileName)
+ if m {
+ return true
+ }
+ }
+ return false
+}
+
+func WalkAllFile(dirPath string, walkFn filepath.WalkFunc) error {
+ info, err := os.Lstat(dirPath)
+ if err != nil {
+ err = walkFn(dirPath, nil, err)
+ } else {
+ err = walkAllFile(dirPath, info, walkFn)
+ }
+ return err
+}
+
+func walkAllFile(dirPath string, info os.FileInfo, walkFn filepath.WalkFunc) error {
+ if !info.IsDir() {
+ return walkFn(dirPath, info, nil)
+ }
+
+ files, err := ioutil.ReadDir(dirPath)
+ if err != nil {
+ return walkFn(dirPath, nil, err)
+ }
+ for _, fi := range files {
+ subFilePath := dirPath + "/" + fi.Name()
+ err := walkFn(subFilePath, fi, err)
+ if err != nil && err != filepath.SkipDir {
+ return err
+ }
+ if fi.IsDir() {
+ if err == filepath.SkipDir {
+ continue
+ }
+ err := walkAllFile(subFilePath, fi, walkFn)
+ if err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+// RunRapidUpload 秒传
+func RunRapidUpload(driveId string, isOverwrite bool, fileMetaList []string, savePanPath string) {
+ activeUser := GetActiveUser()
+ savePanPath = activeUser.PathJoin(driveId, savePanPath)
+
+ if len(fileMetaList) == 0 {
+ fmt.Println("秒传链接为空")
+ return
+ }
+
+ items := []*RapidUploadItem{}
+ // parse file meta strings
+ for _,fileMeta := range fileMetaList {
+ item,e := newRapidUploadItem(fileMeta)
+ if e != nil {
+ fmt.Println(e)
+ continue
+ }
+ if item == nil {
+ fmt.Println("秒传链接格式错误: ", fileMeta)
+ continue
+ }
+
+ // pan path
+ item.FilePath = path.Join(savePanPath, item.FilePath)
+
+ // append
+ items = append(items, item)
+ }
+
+ // upload one by one
+ for _,item := range items {
+ fmt.Println("准备秒传:", item.FilePath)
+ if ee := doRapidUpload(driveId, isOverwrite, item); ee != nil {
+ fmt.Println(ee)
+ } else {
+ fmt.Printf("秒传成功, 保存到网盘路径:%s\n", item.FilePath)
+ }
+ }
+}
+
+func doRapidUpload(driveId string, isOverwrite bool, item *RapidUploadItem) error {
+ activeUser := GetActiveUser()
+ panClient := activeUser.PanClient()
+
+ var apierr *apierror.ApiError
+ var rs *aliyunpan.MkdirResult
+ var appCreateUploadFileParam *aliyunpan.CreateFileUploadParam
+ var saveFilePath string
+
+ panDir, panFileName := path.Split(item.FilePath)
+ saveFilePath = item.FilePath
+ if panDir != "/" {
+ rs, apierr = panClient.MkdirRecursive(driveId, "", "", 0, strings.Split(path.Clean(panDir), "/"))
+ if apierr != nil || rs.FileId == "" {
+ return fmt.Errorf("创建云盘文件夹失败")
+ }
+ } else {
+ rs = &aliyunpan.MkdirResult{}
+ rs.FileId = aliyunpan.DefaultRootParentFileId
+ }
+ time.Sleep(time.Duration(2) * time.Second)
+
+ if isOverwrite {
+ // 标记覆盖旧同名文件
+ // 检查同名文件是否存在
+ efi, apierr := panClient.FileInfoByPath(driveId, saveFilePath)
+ if apierr != nil && apierr.Code != apierror.ApiCodeFileNotFoundCode {
+ return fmt.Errorf("检测同名文件失败,请稍后重试")
+ }
+ if efi != nil && efi.FileId != "" {
+ // existed, delete it
+ fileDeleteResult, err1 := panClient.FileDelete([]*aliyunpan.FileBatchActionParam{{DriveId:efi.DriveId, FileId:efi.FileId}})
+ if err1 != nil || len(fileDeleteResult) == 0 {
+ return fmt.Errorf("无法删除文件,请稍后重试")
+ }
+ time.Sleep(time.Duration(500) * time.Millisecond)
+ if fileDeleteResult[0].Success {
+ logger.Verboseln("检测到同名文件,已移动到回收站: ", saveFilePath)
+ } else {
+ return fmt.Errorf("无法删除文件,请稍后重试")
+ }
+ }
+ }
+
+ appCreateUploadFileParam = &aliyunpan.CreateFileUploadParam{
+ DriveId: driveId,
+ Name: panFileName,
+ Size: item.FileSize,
+ ContentHash: item.FileSha1,
+ ParentFileId: rs.FileId,
+ }
+ uploadOpEntity, apierr := panClient.CreateUploadFile(appCreateUploadFileParam)
+ if apierr != nil {
+ return fmt.Errorf("创建秒传任务失败:" + apierr.Error())
+ }
+
+ if uploadOpEntity.RapidUpload {
+ logger.Verboseln("秒传成功, 保存到网盘路径: ", path.Join(panDir, uploadOpEntity.FileName))
+ } else {
+ return fmt.Errorf("失败,文件未曾上传,无法秒传")
+ }
+ return nil
+}
\ No newline at end of file
diff --git a/internal/command/user_info.go b/internal/command/user_info.go
new file mode 100644
index 0000000..49d3d84
--- /dev/null
+++ b/internal/command/user_info.go
@@ -0,0 +1,140 @@
+// 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/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan/cmder"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/urfave/cli"
+ "strconv"
+)
+
+func CmdLoglist() cli.Command {
+ return cli.Command{
+ Name: "loglist",
+ Usage: "列出帐号列表",
+ Description: "列出所有已登录的阿里账号",
+ Category: "阿里云盘账号",
+ Before: cmder.ReloadConfigFunc,
+ Action: func(c *cli.Context) error {
+ fmt.Println(config.Config.UserList.String())
+ return nil
+ },
+ }
+}
+
+func CmdSu() cli.Command {
+ return cli.Command{
+ Name: "su",
+ Usage: "切换阿里账号",
+ Description: `
+ 切换已登录的阿里账号:
+ 如果运行该条命令没有提供参数, 程序将会列出所有的帐号, 供选择切换.
+
+ 示例:
+ aliyunpan su
+ aliyunpan su
+`,
+ Category: "阿里云盘账号",
+ Before: cmder.ReloadConfigFunc,
+ After: cmder.SaveConfigFunc,
+ Action: func(c *cli.Context) error {
+ if c.NArg() >= 2 {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ return nil
+ }
+
+ numLogins := config.Config.NumLogins()
+
+ if numLogins == 0 {
+ fmt.Printf("未设置任何帐号, 不能切换\n")
+ return nil
+ }
+
+ var (
+ inputData = c.Args().Get(0)
+ uid string
+ )
+
+ if c.NArg() == 1 {
+ // 直接切换
+ uid = inputData
+ } else if c.NArg() == 0 {
+ // 输出所有帐号供选择切换
+ cli.HandleAction(cmder.App().Command("loglist").Action, c)
+
+ // 提示输入 index
+ var index string
+ fmt.Printf("输入要切换帐号的 # 值 > ")
+ _, err := fmt.Scanln(&index)
+ if err != nil {
+ return nil
+ }
+
+ if n, err := strconv.Atoi(index); err == nil && n >= 0 && n < numLogins {
+ uid = config.Config.UserList[n].UserId
+ } else {
+ fmt.Printf("切换用户失败, 请检查 # 值是否正确\n")
+ return nil
+ }
+ } else {
+ cli.ShowCommandHelp(c, c.Command.Name)
+ }
+
+ switchedUser, err := config.Config.SwitchUser(uid, inputData)
+ if err != nil {
+ fmt.Printf("切换用户失败, %s\n", err)
+ return nil
+ }
+
+ if switchedUser == nil {
+ switchedUser = cmder.TryLogin()
+ }
+
+ if switchedUser != nil {
+ fmt.Printf("切换用户: %s\n", switchedUser.Nickname)
+ } else {
+ fmt.Printf("切换用户失败\n")
+ }
+
+ return nil
+ },
+ }
+}
+
+func CmdWho() cli.Command {
+ return cli.Command{
+ Name: "who",
+ Usage: "获取当前帐号",
+ Description: "获取当前帐号的信息",
+ Category: "阿里云盘账号",
+ Before: cmder.ReloadConfigFunc,
+ Action: func(c *cli.Context) error {
+ if config.Config.ActiveUser() == nil {
+ fmt.Println("未登录账号")
+ return nil
+ }
+ 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)
+ return nil
+ },
+ }
+}
+
+func RunGetUserInfo() (userInfo *aliyunpan.UserInfo, error error) {
+ return GetActivePanClient().GetUserInfo()
+}
diff --git a/internal/command/utils.go b/internal/command/utils.go
new file mode 100644
index 0000000..5e88a8a
--- /dev/null
+++ b/internal/command/utils.go
@@ -0,0 +1,95 @@
+// 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/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/tickstep/library-go/logger"
+ "math/rand"
+ "path"
+ "strings"
+ "time"
+)
+
+var (
+ panCommandVerbose = logger.New("PANCOMMAND", config.EnvVerbose)
+)
+
+const(
+ // 备份数据库桶分区标志
+ BackupMetaBucketName = "adrive"
+
+ // 备份数据文件夹目录名称,隐藏目录
+ BackupMetaDirName = ".adrive"
+)
+
+// GetFileInfoByPaths 获取指定文件路径的文件详情信息
+func GetAppFileInfoByPaths(driveId string, paths ...string) (fileInfoList []*aliyunpan.FileEntity, failedPaths []string, error error) {
+ if len(paths) <= 0 {
+ return nil, nil, fmt.Errorf("请指定文件路径")
+ }
+ activeUser := GetActiveUser()
+
+ for idx := 0; idx < len(paths); idx++ {
+ absolutePath := path.Clean(activeUser.PathJoin(driveId, paths[idx]))
+ fe, err := activeUser.PanClient().FileInfoByPath(driveId, absolutePath)
+ if err != nil {
+ failedPaths = append(failedPaths, absolutePath)
+ continue
+ }
+ fileInfoList = append(fileInfoList, fe)
+ }
+ return
+}
+
+// GetFileInfoByPaths 获取指定文件路径的文件详情信息
+func GetFileInfoByPaths(paths ...string) (fileInfoList []*aliyunpan.FileEntity, failedPaths []string, error error) {
+ if len(paths) <= 0 {
+ return nil, nil, fmt.Errorf("请指定文件路径")
+ }
+ activeUser := GetActiveUser()
+
+ for idx := 0; idx < len(paths); idx++ {
+ absolutePath := path.Clean(activeUser.PathJoin(activeUser.ActiveDriveId, paths[idx]))
+ fe, err := activeUser.PanClient().FileInfoByPath(activeUser.ActiveDriveId, absolutePath)
+ if err != nil {
+ failedPaths = append(failedPaths, absolutePath)
+ continue
+ }
+ fileInfoList = append(fileInfoList, fe)
+ }
+ return
+}
+
+func matchPathByShellPattern(driveId string, patterns ...string) (panpaths []string, err error) {
+ acUser := GetActiveUser()
+ for k := range patterns {
+ ps := acUser.PathJoin(driveId, patterns[k])
+ panpaths = append(panpaths, ps)
+ }
+ return panpaths, nil
+}
+
+func RandomStr(count int) string {
+ //STR_SET := "abcdefjhijklmnopqrstuvwxyzABCDEFJHIJKLMNOPQRSTUVWXYZ1234567890"
+ STR_SET := "abcdefjhijklmnopqrstuvwxyz1234567890"
+ rand.Seed(time.Now().UnixNano())
+ str := strings.Builder{}
+ for i := 0; i < count; i++ {
+ str.WriteByte(byte(STR_SET[rand.Intn(len(STR_SET))]))
+ }
+ return str.String()
+}
\ No newline at end of file
diff --git a/internal/config/errors.go b/internal/config/errors.go
new file mode 100644
index 0000000..58c130d
--- /dev/null
+++ b/internal/config/errors.go
@@ -0,0 +1,31 @@
+// 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 config
+
+import (
+ "errors"
+)
+
+var (
+ //ErrNotLogin 未登录帐号错误
+ ErrNotLogin = errors.New("user not login")
+ //ErrConfigFilePathNotSet 未设置配置文件
+ ErrConfigFilePathNotSet = errors.New("config file not set")
+ //ErrConfigFileNotExist 未设置Config, 未初始化
+ ErrConfigFileNotExist = errors.New("config file not exist")
+ //ErrConfigFileNoPermission Config文件无权限访问
+ ErrConfigFileNoPermission = errors.New("config file permission denied")
+ //ErrConfigContentsParseError 解析Config数据错误
+ ErrConfigContentsParseError = errors.New("config contents parse error")
+)
diff --git a/internal/config/pan_config.go b/internal/config/pan_config.go
new file mode 100644
index 0000000..cdad5e3
--- /dev/null
+++ b/internal/config/pan_config.go
@@ -0,0 +1,383 @@
+// 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 config
+
+import (
+ "fmt"
+ jsoniter "github.com/json-iterator/go"
+ "github.com/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan/cmder/cmdutil"
+ "github.com/tickstep/aliyunpan/cmder/cmdutil/jsonhelper"
+ "github.com/tickstep/library-go/logger"
+ "github.com/tickstep/library-go/requester"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+)
+
+const (
+ // EnvVerbose 启用调试环境变量
+ EnvVerbose = "ALIYUNPAN_VERBOSE"
+ // EnvConfigDir 配置路径环境变量
+ EnvConfigDir = "ALIYUNPAN_CONFIG_DIR"
+ // ConfigName 配置文件名
+ ConfigName = "aliyunpan_config.json"
+ // ConfigVersion 配置文件版本
+ ConfigVersion string = "1.0"
+)
+
+var (
+ CmdConfigVerbose = logger.New("CONFIG", EnvVerbose)
+ configFilePath = filepath.Join(GetConfigDir(), ConfigName)
+
+ // Config 配置信息, 由外部调用
+ Config = NewConfig(configFilePath)
+
+ AppVersion string
+)
+
+type UpdateCheckInfo struct {
+ PreferUpdateSrv string `json:"preferUpdateSrv"` // 优先更新服务器,github | tickstep
+ LatestVer string `json:"latestVer"` // 最后检测到的版本
+ CheckTime int64 `json:"checkTime"` // 最后检测的时间戳,单位为秒
+}
+
+// PanConfig 配置详情
+type PanConfig struct {
+ ConfigVer string `json:"configVer"`
+ ActiveUID string `json:"activeUID"`
+
+ UserList PanUserList `json:"userList"`
+
+ CacheSize int `json:"cacheSize"` // 下载缓存
+ MaxDownloadParallel int `json:"maxDownloadParallel"` // 最大下载并发量
+ MaxUploadParallel int `json:"maxUploadParallel"` // 最大上传并发量,即同时上传文件最大数量
+ MaxDownloadLoad int `json:"maxDownloadLoad"` // 同时进行下载文件的最大数量
+
+ MaxDownloadRate int64 `json:"maxDownloadRate"` // 限制最大下载速度,单位 B/s, 即字节/每秒
+ MaxUploadRate int64 `json:"maxUploadRate"` // 限制最大上传速度,单位 B/s, 即字节/每秒
+
+ SaveDir string `json:"saveDir"` // 下载储存路径
+
+ Proxy string `json:"proxy"` // 代理
+ LocalAddrs string `json:"localAddrs"` // 本地网卡地址
+ UpdateCheckInfo UpdateCheckInfo `json:"updateCheckInfo"`
+
+ configFilePath string
+ configFile *os.File
+ fileMu sync.Mutex
+ activeUser *PanUser
+}
+
+// NewConfig 返回 PanConfig 指针对象
+func NewConfig(configFilePath string) *PanConfig {
+ c := &PanConfig{
+ configFilePath: configFilePath,
+ }
+ return c
+}
+
+// Init 初始化配置
+func (c *PanConfig) Init() error {
+ return c.init()
+}
+
+// Reload 从文件重载配置
+func (c *PanConfig) Reload() error {
+ return c.init()
+}
+
+// Close 关闭配置文件
+func (c *PanConfig) Close() error {
+ if c.configFile != nil {
+ err := c.configFile.Close()
+ c.configFile = nil
+ return err
+ }
+ return nil
+}
+
+// Save 保存配置信息到配置文件
+func (c *PanConfig) Save() error {
+ // 检测配置项是否合法, 不合法则自动修复
+ c.fix()
+
+ err := c.lazyOpenConfigFile()
+ if err != nil {
+ return err
+ }
+
+ c.fileMu.Lock()
+ defer c.fileMu.Unlock()
+
+ data, err := jsoniter.MarshalIndent(c, "", " ")
+ if err != nil {
+ // json数据生成失败
+ panic(err)
+ }
+
+ // 减掉多余的部分
+ err = c.configFile.Truncate(int64(len(data)))
+ if err != nil {
+ return err
+ }
+
+ _, err = c.configFile.Seek(0, os.SEEK_SET)
+ if err != nil {
+ return err
+ }
+
+ _, err = c.configFile.Write(data)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *PanConfig) init() error {
+ if c.configFilePath == "" {
+ return ErrConfigFileNotExist
+ }
+
+ c.initDefaultConfig()
+ err := c.loadConfigFromFile()
+ if err != nil {
+ return err
+ }
+
+ // 设置全局代理
+ if c.Proxy != "" {
+ requester.SetGlobalProxy(c.Proxy)
+ }
+ // 设置本地网卡地址
+ if c.LocalAddrs != "" {
+ requester.SetLocalTCPAddrList(strings.Split(c.LocalAddrs, ",")...)
+ }
+
+ return nil
+}
+
+// lazyOpenConfigFile 打开配置文件
+func (c *PanConfig) lazyOpenConfigFile() (err error) {
+ if c.configFile != nil {
+ return nil
+ }
+
+ c.fileMu.Lock()
+ os.MkdirAll(filepath.Dir(c.configFilePath), 0700)
+ c.configFile, err = os.OpenFile(c.configFilePath, os.O_CREATE|os.O_RDWR, 0600)
+ c.fileMu.Unlock()
+
+ if err != nil {
+ if os.IsPermission(err) {
+ return ErrConfigFileNoPermission
+ }
+ if os.IsExist(err) {
+ return ErrConfigFileNotExist
+ }
+ return err
+ }
+ return nil
+}
+
+// loadConfigFromFile 载入配置
+func (c *PanConfig) loadConfigFromFile() (err error) {
+ err = c.lazyOpenConfigFile()
+ if err != nil {
+ return err
+ }
+
+ // 未初始化
+ info, err := c.configFile.Stat()
+ if err != nil {
+ return err
+ }
+
+ if info.Size() == 0 {
+ err = c.Save()
+ return err
+ }
+
+ c.fileMu.Lock()
+ defer c.fileMu.Unlock()
+
+ _, err = c.configFile.Seek(0, os.SEEK_SET)
+ if err != nil {
+ return err
+ }
+
+ err = jsonhelper.UnmarshalData(c.configFile, c)
+ if err != nil {
+ return ErrConfigContentsParseError
+ }
+ return nil
+}
+
+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.ConfigVer = ConfigVersion
+}
+
+// GetConfigDir 获取配置路径
+func GetConfigDir() string {
+ // 从环境变量读取
+ configDir, ok := os.LookupEnv(EnvConfigDir)
+ if ok {
+ if filepath.IsAbs(configDir) {
+ return configDir
+ }
+ // 如果不是绝对路径, 从程序目录寻找
+ return cmdutil.ExecutablePathJoin(configDir)
+ }
+ return cmdutil.ExecutablePathJoin(configDir)
+}
+
+func (c *PanConfig) ActiveUser() *PanUser {
+ if c.activeUser == nil {
+ if c.UserList == nil {
+ return nil
+ }
+ if c.ActiveUID == "" {
+ return nil
+ }
+ for _, u := range c.UserList {
+ if u.UserId == c.ActiveUID {
+ if u.PanClient() == nil {
+ // restore client
+ user, err := SetupUserByCookie(&u.WebToken)
+ if err != nil {
+ logger.Verboseln("setup user error")
+ return nil
+ }
+ u.panClient = user.panClient
+ u.Nickname = user.Nickname
+
+ u.DriveList = user.DriveList
+ // check workdir valid or not
+ if user.IsFileDriveActive() {
+ fe, err1 := u.PanClient().FileInfoByPath(u.ActiveDriveId, u.Workdir)
+ if err1 != nil {
+ // default to root
+ u.Workdir = "/"
+ u.WorkdirFileEntity = *aliyunpan.NewFileEntityForRootDir()
+ } else {
+ u.WorkdirFileEntity = *fe
+ }
+ } else if user.IsAlbumDriveActive() {
+ fe, err1 := u.PanClient().FileInfoByPath(u.ActiveDriveId, u.AlbumWorkdir)
+ if err1 != nil {
+ // default to root
+ u.AlbumWorkdir = "/"
+ u.AlbumWorkdirFileEntity = *aliyunpan.NewFileEntityForRootDir()
+ } else {
+ u.AlbumWorkdirFileEntity = *fe
+ }
+ }
+ }
+ c.activeUser = u
+ return u
+ }
+ }
+ return &PanUser{}
+ }
+ return c.activeUser
+}
+
+func (c *PanConfig) SetActiveUser(user *PanUser) *PanUser {
+ needToInsert := true
+ for _, u := range c.UserList {
+ if u.UserId == user.UserId {
+ // update user info
+ u.Nickname = user.Nickname
+ u.WebToken = user.WebToken
+ u.RefreshToken = user.RefreshToken
+ needToInsert = false
+ break
+ }
+ }
+ if needToInsert {
+ // insert
+ c.UserList = append(c.UserList, user)
+ }
+
+ // setup active user
+ c.ActiveUID = user.UserId
+ // clear active user cache
+ c.activeUser = nil
+ // reload
+ return c.ActiveUser()
+}
+
+func (c *PanConfig) fix() {
+
+}
+
+// NumLogins 获取登录的用户数量
+func (c *PanConfig) NumLogins() int {
+ return len(c.UserList)
+}
+
+// SwitchUser 切换登录用户
+func (c *PanConfig) SwitchUser(uid, username string) (*PanUser, error) {
+ for _, u := range c.UserList {
+ if u.UserId == uid || u.AccountName == username {
+ return c.SetActiveUser(u), nil
+ }
+ }
+ return nil, fmt.Errorf("未找到指定的账号")
+}
+
+// DeleteUser 删除用户,并自动切换登录用户为用户列表第一个
+func (c *PanConfig) DeleteUser(uid string) (*PanUser, error) {
+ for idx, u := range c.UserList {
+ if u.UserId == uid {
+ // delete user from user list
+ c.UserList = append(c.UserList[:idx], c.UserList[idx+1:]...)
+ c.ActiveUID = ""
+ c.activeUser = nil
+ if len(c.UserList) > 0 {
+ c.SwitchUser(c.UserList[0].UserId, "")
+ }
+ return u, nil
+ }
+ }
+ return nil, fmt.Errorf("未找到指定的账号")
+}
+
+// HTTPClient 返回设置好的 HTTPClient
+func (c *PanConfig) HTTPClient(ua string) *requester.HTTPClient {
+ client := requester.NewHTTPClient()
+ if ua != "" {
+ client.SetUserAgent(ua)
+ }
+ return client
+}
\ No newline at end of file
diff --git a/internal/config/pan_config_export.go b/internal/config/pan_config_export.go
new file mode 100644
index 0000000..221afe8
--- /dev/null
+++ b/internal/config/pan_config_export.go
@@ -0,0 +1,87 @@
+// 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 config
+
+import (
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/olekukonko/tablewriter"
+ "github.com/tickstep/aliyunpan/cmder/cmdtable"
+ "github.com/tickstep/library-go/converter"
+ "github.com/tickstep/library-go/requester"
+)
+
+// SetProxy 设置代理
+func (c *PanConfig) SetProxy(proxy string) {
+ c.Proxy = proxy
+ requester.SetGlobalProxy(proxy)
+}
+
+// SetLocalAddrs 设置localAddrs
+func (c *PanConfig) SetLocalAddrs(localAddrs string) {
+ c.LocalAddrs = localAddrs
+ requester.SetLocalTCPAddrList(strings.Split(localAddrs, ",")...)
+}
+
+// SetCacheSizeByStr 设置cache_size
+func (c *PanConfig) SetCacheSizeByStr(sizeStr string) error {
+ size, err := converter.ParseFileSizeStr(sizeStr)
+ if err != nil {
+ return err
+ }
+ c.CacheSize = int(size)
+ return nil
+}
+
+// SetMaxDownloadRateByStr 设置 max_download_rate
+func (c *PanConfig) SetMaxDownloadRateByStr(sizeStr string) error {
+ size, err := converter.ParseFileSizeStr(stripPerSecond(sizeStr))
+ if err != nil {
+ return err
+ }
+ c.MaxDownloadRate = size
+ return nil
+}
+
+// SetMaxUploadRateByStr 设置 max_upload_rate
+func (c *PanConfig) SetMaxUploadRateByStr(sizeStr string) error {
+ size, err := converter.ParseFileSizeStr(stripPerSecond(sizeStr))
+ if err != nil {
+ return err
+ }
+ c.MaxUploadRate = size
+ return nil
+}
+
+// PrintTable 输出表格
+func (c *PanConfig) PrintTable() {
+ tb := cmdtable.NewTable(os.Stdout)
+ tb.SetHeader([]string{"名称", "值", "建议值", "描述"})
+ tb.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
+ 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 ~ 64", "最大下载并发量"},
+ []string{"max_upload_parallel", strconv.Itoa(c.MaxUploadParallel), "1 ~ 100", "最大上传并发量,即同时上传文件最大数量"},
+ []string{"max_download_load", strconv.Itoa(c.MaxDownloadLoad), "1 ~ 5", "同时进行下载文件的最大数量"},
+ []string{"max_download_rate", showMaxRate(c.MaxDownloadRate), "", "限制最大下载速度, 0代表不限制"},
+ []string{"max_upload_rate", showMaxRate(c.MaxUploadRate), "", "限制最大上传速度, 0代表不限制"},
+ []string{"savedir", c.SaveDir, "", "下载文件的储存目录"},
+ []string{"proxy", c.Proxy, "", "设置代理, 支持 http/socks5 代理,例如:http://127.0.0.1:8888"},
+ []string{"local_addrs", c.LocalAddrs, "", "设置本地网卡地址, 多个地址用逗号隔开"},
+ })
+ tb.Render()
+}
diff --git a/internal/config/pan_user.go b/internal/config/pan_user.go
new file mode 100644
index 0000000..0c12060
--- /dev/null
+++ b/internal/config/pan_user.go
@@ -0,0 +1,205 @@
+// 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 config
+
+import (
+ "fmt"
+ "github.com/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
+ "github.com/tickstep/library-go/logger"
+ "path"
+ "path/filepath"
+)
+
+type DriveInfo struct {
+ DriveId string `json:"driveId"`
+ DriveName string `json:"driveName"`
+ DriveTag string `json:"driveTag"`
+}
+type DriveInfoList []*DriveInfo
+
+type PanUser struct {
+ UserId string `json:"userId"`
+ Nickname string `json:"nickname"`
+ AccountName string `json:"accountName"`
+
+ Workdir string `json:"workdir"`
+ WorkdirFileEntity aliyunpan.FileEntity `json:"workdirFileEntity"`
+
+ AlbumWorkdir string `json:"albumWorkdir"`
+ AlbumWorkdirFileEntity aliyunpan.FileEntity `json:"albumWorkdirFileEntity"`
+
+ ActiveDriveId string `json:"activeDriveId"`
+ DriveList DriveInfoList `json:"driveList"`
+
+ RefreshToken string `json:"refreshToken"`
+ WebToken aliyunpan.WebLoginToken `json:"webToken"`
+
+ panClient *aliyunpan.PanClient
+}
+
+type PanUserList []*PanUser
+
+func SetupUserByCookie(webToken *aliyunpan.WebLoginToken) (user *PanUser, err *apierror.ApiError) {
+ tryRefreshWebToken := true
+
+ if webToken == nil {
+ return nil, apierror.NewFailedApiError("web token is empty")
+ }
+
+doLoginAct:
+ panClient := aliyunpan.NewPanClient(*webToken, aliyunpan.AppLoginToken{})
+ u := &PanUser{
+ WebToken: *webToken,
+ panClient: panClient,
+ Workdir: "/",
+ WorkdirFileEntity: *aliyunpan.NewFileEntityForRootDir(),
+ }
+
+ // web api token maybe expired
+ userInfo, err := panClient.GetUserInfo()
+ if err != nil {
+ if err.Code == apierror.ApiCodeTokenExpiredCode && tryRefreshWebToken {
+ tryRefreshWebToken = false
+ webCookie,_ := aliyunpan.GetAccessTokenFromRefreshToken(webToken.RefreshToken)
+ if webCookie != nil {
+ webToken = webCookie
+ goto doLoginAct
+ }
+ }
+ return nil, err
+ }
+ name := "Unknown"
+ if userInfo != nil {
+ if userInfo.Nickname != "" {
+ name = userInfo.Nickname
+ }
+
+ // update user
+ u.UserId = userInfo.UserId
+ u.Nickname = name
+ u.AccountName = userInfo.UserName
+
+ // default file drive
+ if u.ActiveDriveId == "" {
+ u.ActiveDriveId = userInfo.FileDriveId
+ }
+
+ // drive list
+ u.DriveList = DriveInfoList{
+ {DriveId: userInfo.FileDriveId, DriveTag: "File", DriveName: "文件"},
+ {DriveId: userInfo.AlbumDriveId, DriveTag: "Album", DriveName: "相册"},
+ }
+ } else {
+ // error, maybe the token has expired
+ return nil, apierror.NewFailedApiError("cannot get user info, the token has expired")
+ }
+ return u, nil
+}
+
+func (pu *PanUser) PanClient() *aliyunpan.PanClient {
+ return pu.panClient
+}
+
+// PathJoin 合并工作目录和相对路径p, 若p为绝对路径则忽略
+func (pu *PanUser) PathJoin(driveId, p string) string {
+ if path.IsAbs(p) {
+ return p
+ }
+ wd := "/"
+ di := pu.GetDriveById(driveId)
+ if di != nil {
+ if di.IsFileDrive() {
+ wd = pu.Workdir
+ } else if di.IsAlbumDrive() {
+ wd = pu.AlbumWorkdir
+ }
+ }
+ return path.Join(wd, p)
+}
+
+func (pu *PanUser) FreshWorkdirInfo() {
+ if pu.IsFileDriveActive() {
+ fe, err := pu.PanClient().FileInfoById(pu.ActiveDriveId, pu.WorkdirFileEntity.FileId)
+ if err != nil {
+ logger.Verboseln("刷新工作目录信息失败")
+ return
+ }
+ pu.WorkdirFileEntity = *fe
+ } else if pu.IsAlbumDriveActive() {
+ fe, err := pu.PanClient().FileInfoById(pu.ActiveDriveId, pu.AlbumWorkdirFileEntity.FileId)
+ if err != nil {
+ logger.Verboseln("刷新工作目录信息失败")
+ return
+ }
+ pu.AlbumWorkdirFileEntity = *fe
+ }
+
+}
+
+// GetSavePath 根据提供的网盘文件路径 panpath, 返回本地储存路径,
+// 返回绝对路径, 获取绝对路径出错时才返回相对路径...
+func (pu *PanUser) GetSavePath(filePanPath string) string {
+ dirStr := filepath.Join(Config.SaveDir, fmt.Sprintf("%s", pu.UserId), filePanPath)
+ dir, err := filepath.Abs(dirStr)
+ if err != nil {
+ dir = filepath.Clean(dirStr)
+ }
+ return dir
+}
+
+func (pu *PanUser) GetDriveByTag(tag string) *DriveInfo {
+ for _,item := range pu.DriveList {
+ if item.DriveTag == tag {
+ return item
+ }
+ }
+ return nil
+}
+
+func (pu *PanUser) GetDriveById(id string) *DriveInfo {
+ for _,item := range pu.DriveList {
+ if item.DriveId == id {
+ return item
+ }
+ }
+ return nil
+}
+
+func (pu *PanUser) GetActiveDriveInfo() *DriveInfo {
+ for _,item := range pu.DriveList {
+ if item.DriveId == pu.ActiveDriveId {
+ return item
+ }
+ }
+ return nil
+}
+
+func (pu *PanUser) IsFileDriveActive() bool {
+ d := pu.GetActiveDriveInfo()
+ return d != nil && d.IsFileDrive()
+}
+
+func (pu *PanUser) IsAlbumDriveActive() bool {
+ d := pu.GetActiveDriveInfo()
+ return d != nil && d.IsAlbumDrive()
+}
+
+func (di *DriveInfo) IsFileDrive() bool {
+ return di.DriveTag == "File"
+}
+
+func (di *DriveInfo) IsAlbumDrive() bool {
+ return di.DriveTag == "Album"
+}
\ No newline at end of file
diff --git a/internal/config/utils.go b/internal/config/utils.go
new file mode 100644
index 0000000..9f9000f
--- /dev/null
+++ b/internal/config/utils.go
@@ -0,0 +1,107 @@
+// 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 config
+
+import (
+ "encoding/hex"
+ "github.com/olekukonko/tablewriter"
+ "github.com/tickstep/aliyunpan/cmder/cmdtable"
+ "github.com/tickstep/library-go/converter"
+ "github.com/tickstep/library-go/crypto"
+ "github.com/tickstep/library-go/ids"
+ "github.com/tickstep/library-go/logger"
+ "strconv"
+ "strings"
+)
+
+func (pl *PanUserList) String() string {
+ builder := &strings.Builder{}
+
+ tb := cmdtable.NewTable(builder)
+ tb.SetColumnAlignment([]int{tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_RIGHT, tablewriter.ALIGN_CENTER, tablewriter.ALIGN_CENTER, tablewriter.ALIGN_CENTER})
+ tb.SetHeader([]string{"#", "uid", "用户名", "昵称"})
+
+ for k, userInfo := range *pl {
+ tb.Append([]string{strconv.Itoa(k), userInfo.UserId, userInfo.AccountName, userInfo.Nickname})
+ }
+
+ tb.Render()
+
+ return builder.String()
+}
+
+// AverageParallel 返回平均的下载最大并发量
+func AverageParallel(parallel, downloadLoad int) int {
+ if downloadLoad < 1 {
+ return 1
+ }
+
+ p := parallel / downloadLoad
+ if p < 1 {
+ return 1
+ }
+ return p
+}
+
+func stripPerSecond(sizeStr string) string {
+ i := strings.LastIndex(sizeStr, "/")
+ if i < 0 {
+ return sizeStr
+ }
+ return sizeStr[:i]
+}
+
+func showMaxRate(size int64) string {
+ if size <= 0 {
+ return "不限制"
+ }
+ return converter.ConvertFileSize(size, 2) + "/s"
+}
+
+// EncryptString 加密
+func EncryptString(text string) string {
+ if text == "" {
+ return ""
+ }
+ d := []byte(text)
+ key := []byte(ids.GetUniqueId("cloudpan189", 16))
+ r, e := crypto.EncryptAES(d, key)
+ if e != nil {
+ return text
+ }
+ return hex.EncodeToString(r)
+}
+
+// DecryptString 解密
+func DecryptString(text string) string {
+ defer func() {
+ if err := recover(); err != nil {
+ logger.Verboseln("decrypt string failed, maybe the key has been changed")
+ }
+ }()
+
+ if text == "" {
+ return ""
+ }
+ d, _ := hex.DecodeString(text)
+
+ // use the machine unique id as the key
+ // but in some OS, this key will be changed if you reinstall the OS
+ key := []byte(ids.GetUniqueId("cloudpan189", 16))
+ r, e := crypto.DecryptAES(d, key)
+ if e != nil {
+ return text
+ }
+ return string(r)
+}
diff --git a/internal/config/utils_test.go b/internal/config/utils_test.go
new file mode 100644
index 0000000..02b7987
--- /dev/null
+++ b/internal/config/utils_test.go
@@ -0,0 +1,27 @@
+// 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 config
+
+import (
+ "fmt"
+ "testing"
+)
+
+func TestEncryptString(t *testing.T) {
+ fmt.Println(EncryptString("131687xxxxx@189.cn"))
+}
+
+func TestDecryptString(t *testing.T) {
+ fmt.Println(DecryptString("75b3c8d21607440c0e8a70f4a4861c8669774cc69c70ce2a2c8acb815b6d5d3b"))
+}
diff --git a/internal/file/downloader/config.go b/internal/file/downloader/config.go
new file mode 100644
index 0000000..9405170
--- /dev/null
+++ b/internal/file/downloader/config.go
@@ -0,0 +1,63 @@
+// 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 downloader
+
+import (
+ "github.com/tickstep/aliyunpan/library/requester/transfer"
+)
+
+const (
+ //CacheSize 默认的下载缓存
+ CacheSize = 8192
+)
+
+var (
+ // MinParallelSize 单个线程最小的数据量
+ MinParallelSize int64 = 128 * 1024 // 128kb
+)
+
+//Config 下载配置
+type Config struct {
+ Mode transfer.RangeGenMode // 下载Range分配模式
+ MaxParallel int // 最大下载并发量
+ CacheSize int // 下载缓冲
+ BlockSize int64 // 每个Range区块的大小, RangeGenMode 为 RangeGenMode2 时才有效
+ MaxRate int64 // 限制最大下载速度
+ InstanceStateStorageFormat InstanceStateStorageFormat // 断点续传储存类型
+ InstanceStatePath string // 断点续传信息路径
+ TryHTTP bool // 是否尝试使用 http 连接
+ ShowProgress bool // 是否展示下载进度条
+}
+
+//NewConfig 返回默认配置
+func NewConfig() *Config {
+ return &Config{
+ MaxParallel: 5,
+ CacheSize: CacheSize,
+ }
+}
+
+//Fix 修复配置信息, 使其合法
+func (cfg *Config) Fix() {
+ fixCacheSize(&cfg.CacheSize)
+ if cfg.MaxParallel < 1 {
+ cfg.MaxParallel = 1
+ }
+}
+
+//Copy 拷贝新的配置
+func (cfg *Config) Copy() *Config {
+ newCfg := *cfg
+ return &newCfg
+}
diff --git a/internal/file/downloader/downloader.go b/internal/file/downloader/downloader.go
new file mode 100644
index 0000000..5b76c95
--- /dev/null
+++ b/internal/file/downloader/downloader.go
@@ -0,0 +1,517 @@
+// 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 downloader
+
+import (
+ "context"
+ "errors"
+ "github.com/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
+ "github.com/tickstep/aliyunpan/cmder/cmdutil"
+ "github.com/tickstep/aliyunpan/internal/waitgroup"
+ "github.com/tickstep/library-go/cachepool"
+ "github.com/tickstep/library-go/logger"
+ "github.com/tickstep/library-go/prealloc"
+ "github.com/tickstep/library-go/requester"
+ "github.com/tickstep/library-go/requester/rio/speeds"
+ "github.com/tickstep/aliyunpan/library/requester/transfer"
+ "io"
+ "net/http"
+ "sync"
+ "time"
+)
+
+const (
+ // DefaultAcceptRanges 默认的 Accept-Ranges
+ DefaultAcceptRanges = "bytes"
+)
+
+type (
+ // Downloader 下载
+ Downloader struct {
+ onExecuteEvent requester.Event //开始下载事件
+ onSuccessEvent requester.Event //成功下载事件
+ onFinishEvent requester.Event //结束下载事件
+ onPauseEvent requester.Event //暂停下载事件
+ onResumeEvent requester.Event //恢复下载事件
+ onCancelEvent requester.Event //取消下载事件
+ onDownloadStatusEvent DownloadStatusFunc //状态处理事件
+
+ monitorCancelFunc context.CancelFunc
+
+ fileInfo *aliyunpan.FileEntity // 下载的文件信息
+ driveId string
+ loadBalancerCompareFunc LoadBalancerCompareFunc // 负载均衡检测函数
+ durlCheckFunc DURLCheckFunc // 下载url检测函数
+ statusCodeBodyCheckFunc StatusCodeBodyCheckFunc
+ executeTime time.Time
+ loadBalansers []string
+ writer io.WriterAt
+ client *requester.HTTPClient
+ panClient *aliyunpan.PanClient
+ config *Config
+ monitor *Monitor
+ instanceState *InstanceState
+ }
+
+ // DURLCheckFunc 下载URL检测函数
+ DURLCheckFunc func(client *requester.HTTPClient, durl string) (contentLength int64, resp *http.Response, err error)
+ // StatusCodeBodyCheckFunc 响应状态码出错的检查函数
+ StatusCodeBodyCheckFunc func(respBody io.Reader) error
+)
+
+//NewDownloader 初始化Downloader
+func NewDownloader(writer io.WriterAt, config *Config, p *aliyunpan.PanClient) (der *Downloader) {
+ der = &Downloader{
+ config: config,
+ writer: writer,
+ panClient: p,
+ }
+
+ return
+}
+
+//SetClient 设置http客户端
+func (der *Downloader) SetFileInfo(f *aliyunpan.FileEntity) {
+ der.fileInfo = f
+}
+
+func (der *Downloader) SetDriveId(driveId string) {
+ der.driveId = driveId
+}
+
+//SetClient 设置http客户端
+func (der *Downloader) SetClient(client *requester.HTTPClient) {
+ der.client = client
+}
+
+// SetLoadBalancerCompareFunc 设置负载均衡检测函数
+func (der *Downloader) SetLoadBalancerCompareFunc(f LoadBalancerCompareFunc) {
+ der.loadBalancerCompareFunc = f
+}
+
+//SetStatusCodeBodyCheckFunc 设置响应状态码出错的检查函数, 当FirstCheckMethod不为HEAD时才有效
+func (der *Downloader) SetStatusCodeBodyCheckFunc(f StatusCodeBodyCheckFunc) {
+ der.statusCodeBodyCheckFunc = f
+}
+
+func (der *Downloader) lazyInit() {
+ if der.config == nil {
+ der.config = NewConfig()
+ }
+ if der.client == nil {
+ der.client = requester.NewHTTPClient()
+ der.client.SetTimeout(20 * time.Minute)
+ }
+ if der.monitor == nil {
+ der.monitor = NewMonitor()
+ }
+ if der.durlCheckFunc == nil {
+ der.durlCheckFunc = DefaultDURLCheckFunc
+ }
+ if der.loadBalancerCompareFunc == nil {
+ der.loadBalancerCompareFunc = DefaultLoadBalancerCompareFunc
+ }
+}
+
+// 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
+ } else if isRange {
+ parallel = len(instanceRangeList)
+ } else {
+ parallel = der.config.MaxParallel
+ if int64(parallel) > totalSize/int64(MinParallelSize) {
+ parallel = int(totalSize/int64(MinParallelSize)) + 1
+ }
+ }
+
+ if parallel < 1 {
+ parallel = 1
+ }
+ return
+}
+
+// SelectBlockSizeAndInitRangeGen 获取合适的 BlockSize, 和初始化 RangeGen
+func (der *Downloader) SelectBlockSizeAndInitRangeGen(single bool, status *transfer.DownloadStatus, parallel int) (blockSize int64, initErr error) {
+ // Range 生成器
+ if single { // 单线程
+ blockSize = -1
+ return
+ }
+ gen := status.RangeListGen()
+ if gen == nil {
+ switch der.config.Mode {
+ case transfer.RangeGenMode_Default:
+ gen = transfer.NewRangeListGenDefault(status.TotalSize(), 0, 0, parallel)
+ blockSize = gen.LoadBlockSize()
+ case transfer.RangeGenMode_BlockSize:
+ b2 := status.TotalSize()/int64(parallel) + 1
+ if b2 > der.config.BlockSize { // 选小的BlockSize, 以更高并发
+ blockSize = der.config.BlockSize
+ } else {
+ blockSize = b2
+ }
+
+ gen = transfer.NewRangeListGenBlockSize(status.TotalSize(), 0, blockSize)
+ default:
+ initErr = transfer.ErrUnknownRangeGenMode
+ return
+ }
+ } else {
+ blockSize = gen.LoadBlockSize()
+ }
+ status.SetRangeListGen(gen)
+ return
+}
+
+// SelectCacheSize 获取合适的 cacheSize
+func (der *Downloader) SelectCacheSize(confCacheSize int, blockSize int64) (cacheSize int) {
+ if blockSize > 0 && int64(confCacheSize) > blockSize {
+ // 如果 cache size 过高, 则调低
+ cacheSize = int(blockSize)
+ } else {
+ cacheSize = confCacheSize
+ }
+ return
+}
+
+// DefaultDURLCheckFunc 默认的 DURLCheckFunc
+func DefaultDURLCheckFunc(client *requester.HTTPClient, durl string) (contentLength int64, resp *http.Response, err error) {
+ resp, err = client.Req(http.MethodGet, durl, nil, nil)
+ if err != nil {
+ if resp != nil {
+ resp.Body.Close()
+ }
+ return 0, nil, err
+ }
+ return resp.ContentLength, resp, nil
+}
+
+func (der *Downloader) checkLoadBalancers() *LoadBalancerResponseList {
+ var (
+ loadBalancerResponses = make([]*LoadBalancerResponse, 0, len(der.loadBalansers)+1)
+ handleLoadBalancer = func(req *http.Request) {
+ if req == nil {
+ return
+ }
+
+ if der.config.TryHTTP {
+ req.URL.Scheme = "http"
+ }
+
+ loadBalancer := &LoadBalancerResponse{
+ URL: req.URL.String(),
+ }
+
+ loadBalancerResponses = append(loadBalancerResponses, loadBalancer)
+ logger.Verbosef("DEBUG: load balance task: URL: %s", loadBalancer.URL)
+ }
+ )
+
+ // 加入第一个
+ loadBalancerResponses = append(loadBalancerResponses, &LoadBalancerResponse{
+ URL: "der.durl",
+ })
+
+ // 负载均衡
+ wg := waitgroup.NewWaitGroup(10)
+ privTimeout := der.client.Client.Timeout
+ der.client.SetTimeout(5 * time.Second)
+ for _, loadBalanser := range der.loadBalansers {
+ wg.AddDelta()
+ go func(loadBalanser string) {
+ defer wg.Done()
+
+ subContentLength, subResp, subErr := der.durlCheckFunc(der.client, loadBalanser)
+ if subResp != nil {
+ subResp.Body.Close() // 不读Body, 马上关闭连接
+ }
+ if subErr != nil {
+ logger.Verbosef("DEBUG: loadBalanser Error: %s\n", subErr)
+ return
+ }
+
+ // 检测状态码
+ switch subResp.StatusCode / 100 {
+ case 2: // succeed
+ case 4, 5: // error
+ var err error
+ if der.statusCodeBodyCheckFunc != nil {
+ err = der.statusCodeBodyCheckFunc(subResp.Body)
+ } else {
+ err = errors.New(subResp.Status)
+ }
+ logger.Verbosef("DEBUG: loadBalanser Status Error: %s\n", err)
+ return
+ }
+
+ // 检测长度
+ if der.fileInfo.FileSize != subContentLength {
+ logger.Verbosef("DEBUG: loadBalanser Content-Length not equal to main server\n")
+ return
+ }
+
+ //if !der.loadBalancerCompareFunc(der.firstInfo.ToMap(), subResp) {
+ // logger.Verbosef("DEBUG: loadBalanser not equal to main server\n")
+ // return
+ //}
+
+ handleLoadBalancer(subResp.Request)
+ }(loadBalanser)
+ }
+ wg.Wait()
+ der.client.SetTimeout(privTimeout)
+
+ loadBalancerResponseList := NewLoadBalancerResponseList(loadBalancerResponses)
+ return loadBalancerResponseList
+}
+
+//Execute 开始任务
+func (der *Downloader) Execute() error {
+ der.lazyInit()
+
+ var (
+ loadBalancerResponseList = der.checkLoadBalancers()
+ bii *transfer.DownloadInstanceInfo
+ )
+
+ err := der.initInstanceState(der.config.InstanceStateStorageFormat)
+ if err != nil {
+ return err
+ }
+ bii = der.instanceState.Get()
+
+ var (
+ isInstance = bii != nil // 是否存在断点信息
+ status *transfer.DownloadStatus
+ single = false // 开启多线程下载
+ )
+ if !isInstance {
+ bii = &transfer.DownloadInstanceInfo{}
+ }
+
+ if bii.DownloadStatus != nil {
+ // 使用断点信息的状态
+ status = bii.DownloadStatus
+ } else {
+ // 新建状态
+ status = transfer.NewDownloadStatus()
+ status.SetTotalSize(der.fileInfo.FileSize)
+ }
+
+ // 设置限速
+ if der.config.MaxRate > 0 {
+ rl := speeds.NewRateLimit(der.config.MaxRate)
+ status.SetRateLimit(rl)
+ defer rl.Stop()
+ }
+
+ // 数据处理
+ parallel := der.SelectParallel(single, der.config.MaxParallel, status.TotalSize(), bii.Ranges) // 实际的下载并行量
+ blockSize, err := der.SelectBlockSizeAndInitRangeGen(single, status, parallel) // 实际的BlockSize
+ if err != nil {
+ return err
+ }
+
+ cacheSize := der.SelectCacheSize(der.config.CacheSize, blockSize) // 实际下载缓存
+ cachepool.SetSyncPoolSize(cacheSize) // 调整pool大小
+
+ logger.Verbosef("DEBUG: download task CREATED: parallel: %d, cache size: %d\n", parallel, cacheSize)
+
+ der.monitor.InitMonitorCapacity(parallel)
+
+ var writer Writer
+ // 尝试修剪文件
+ if fder, ok := der.writer.(Fder); ok {
+ err = prealloc.PreAlloc(fder.Fd(), status.TotalSize())
+ if err != nil {
+ logger.Verbosef("DEBUG: truncate file error: %s\n", err)
+ }
+ }
+ writer = der.writer
+
+ // 数据平均分配给各个线程
+ isRange := bii.Ranges != nil && len(bii.Ranges) > 0
+ if !isRange {
+ // 没有使用断点续传
+ // 分配线程
+ bii.Ranges = make(transfer.RangeList, 0, parallel)
+ if single { // 单线程
+ bii.Ranges = append(bii.Ranges, &transfer.Range{Begin: 0, End: der.fileInfo.FileSize})
+ } else {
+ gen := status.RangeListGen()
+ for i := 0; i < cap(bii.Ranges); i++ {
+ _, r := gen.GenRange()
+ if r == nil {
+ break
+ }
+ bii.Ranges = append(bii.Ranges, r)
+ }
+ }
+ }
+
+ var (
+ writeMu = &sync.Mutex{}
+ )
+ for k, r := range bii.Ranges {
+ loadBalancer := loadBalancerResponseList.SequentialGet()
+ if loadBalancer == nil {
+ continue
+ }
+
+ // 获取下载链接
+ var apierr *apierror.ApiError
+ durl, apierr := der.panClient.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)
+ continue
+ }
+ logger.Verbosef("work id: %d, download url: %s\n", k, durl)
+ client := requester.NewHTTPClient()
+ client.SetKeepAlive(true)
+ client.SetTimeout(10 * time.Minute)
+
+ worker := NewWorker(k, der.driveId, der.fileInfo.FileId, durl.Url, writer)
+ worker.SetClient(client)
+ worker.SetPanClient(der.panClient)
+ worker.SetWriteMutex(writeMu)
+ worker.SetTotalSize(der.fileInfo.FileSize)
+
+ worker.SetAcceptRange("bytes")
+ worker.SetRange(r) // 分配Range
+ der.monitor.Append(worker)
+ }
+
+ der.monitor.SetStatus(status)
+
+ // 服务器不支持断点续传, 或者单线程下载, 都不重载worker
+ der.monitor.SetReloadWorker(parallel > 1)
+
+ moniterCtx, moniterCancelFunc := context.WithCancel(context.Background())
+ der.monitorCancelFunc = moniterCancelFunc
+
+ der.monitor.SetInstanceState(der.instanceState)
+
+ // 开始执行
+ der.executeTime = time.Now()
+ cmdutil.Trigger(der.onExecuteEvent)
+ der.downloadStatusEvent() // 启动执行状态处理事件
+ der.monitor.Execute(moniterCtx)
+
+ // 检查错误
+ err = der.monitor.Err()
+ if err == nil { // 成功
+ cmdutil.Trigger(der.onSuccessEvent)
+ der.removeInstanceState() // 移除断点续传文件
+ } else {
+ if err == ErrNoWokers && der.fileInfo.FileSize == 0 {
+ cmdutil.Trigger(der.onSuccessEvent)
+ der.removeInstanceState() // 移除断点续传文件
+ }
+ }
+
+ // 执行结束
+ cmdutil.Trigger(der.onFinishEvent)
+ return err
+}
+
+//downloadStatusEvent 执行状态处理事件
+func (der *Downloader) downloadStatusEvent() {
+ if der.onDownloadStatusEvent == nil {
+ return
+ }
+
+ status := der.monitor.Status()
+ go func() {
+ ticker := time.NewTicker(1 * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-der.monitor.completed:
+ return
+ case <-ticker.C:
+ der.onDownloadStatusEvent(status, der.monitor.RangeWorker)
+ }
+ }
+ }()
+}
+
+//Pause 暂停
+func (der *Downloader) Pause() {
+ if der.monitor == nil {
+ return
+ }
+ cmdutil.Trigger(der.onPauseEvent)
+ der.monitor.Pause()
+}
+
+//Resume 恢复
+func (der *Downloader) Resume() {
+ if der.monitor == nil {
+ return
+ }
+ cmdutil.Trigger(der.onResumeEvent)
+ der.monitor.Resume()
+}
+
+//Cancel 取消
+func (der *Downloader) Cancel() {
+ if der.monitor == nil {
+ return
+ }
+ cmdutil.Trigger(der.onCancelEvent)
+ cmdutil.Trigger(der.monitorCancelFunc)
+}
+
+//OnExecute 设置开始下载事件
+func (der *Downloader) OnExecute(onExecuteEvent requester.Event) {
+ der.onExecuteEvent = onExecuteEvent
+}
+
+//OnSuccess 设置成功下载事件
+func (der *Downloader) OnSuccess(onSuccessEvent requester.Event) {
+ der.onSuccessEvent = onSuccessEvent
+}
+
+//OnFinish 设置结束下载事件
+func (der *Downloader) OnFinish(onFinishEvent requester.Event) {
+ der.onFinishEvent = onFinishEvent
+}
+
+//OnPause 设置暂停下载事件
+func (der *Downloader) OnPause(onPauseEvent requester.Event) {
+ der.onPauseEvent = onPauseEvent
+}
+
+//OnResume 设置恢复下载事件
+func (der *Downloader) OnResume(onResumeEvent requester.Event) {
+ der.onResumeEvent = onResumeEvent
+}
+
+//OnCancel 设置取消下载事件
+func (der *Downloader) OnCancel(onCancelEvent requester.Event) {
+ der.onCancelEvent = onCancelEvent
+}
+
+//OnDownloadStatusEvent 设置状态处理函数
+func (der *Downloader) OnDownloadStatusEvent(f DownloadStatusFunc) {
+ der.onDownloadStatusEvent = f
+}
diff --git a/internal/file/downloader/instance_state.go b/internal/file/downloader/instance_state.go
new file mode 100644
index 0000000..cfcaa4b
--- /dev/null
+++ b/internal/file/downloader/instance_state.go
@@ -0,0 +1,173 @@
+// 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 downloader
+
+import (
+ "errors"
+ "github.com/json-iterator/go"
+ "github.com/tickstep/library-go/cachepool"
+ "github.com/tickstep/library-go/crypto"
+ "github.com/tickstep/library-go/logger"
+ "github.com/tickstep/aliyunpan/library/requester/transfer"
+ "os"
+ "sync"
+)
+
+type (
+ //InstanceState 状态, 断点续传信息
+ InstanceState struct {
+ saveFile *os.File
+ format InstanceStateStorageFormat
+ ii *transfer.DownloadInstanceInfoExport
+ mu sync.Mutex
+ }
+
+ // InstanceStateStorageFormat 断点续传储存类型
+ InstanceStateStorageFormat int
+)
+
+const (
+ // InstanceStateStorageFormatJSON json 格式
+ InstanceStateStorageFormatJSON = iota
+ // InstanceStateStorageFormatProto3 protobuf 格式
+ InstanceStateStorageFormatProto3
+)
+
+//NewInstanceState 初始化InstanceState
+func NewInstanceState(saveFile *os.File, format InstanceStateStorageFormat) *InstanceState {
+ return &InstanceState{
+ saveFile: saveFile,
+ format: format,
+ }
+}
+
+func (is *InstanceState) checkSaveFile() bool {
+ return is.saveFile != nil
+}
+
+func (is *InstanceState) getSaveFileContents() []byte {
+ if !is.checkSaveFile() {
+ return nil
+ }
+
+ finfo, err := is.saveFile.Stat()
+ if err != nil {
+ panic(err)
+ }
+
+ size := finfo.Size()
+ if size > 0xffffffff {
+ panic("savePath too large")
+ }
+ intSize := int(size)
+
+ buf := cachepool.RawMallocByteSlice(intSize)
+
+ n, _ := is.saveFile.ReadAt(buf, 0)
+ return crypto.Base64Decode(buf[:n])
+}
+
+//Get 获取断点续传信息
+func (is *InstanceState) Get() (eii *transfer.DownloadInstanceInfo) {
+ if !is.checkSaveFile() {
+ return nil
+ }
+
+ is.mu.Lock()
+ defer is.mu.Unlock()
+
+ contents := is.getSaveFileContents()
+ if len(contents) <= 0 {
+ return
+ }
+
+ is.ii = &transfer.DownloadInstanceInfoExport{}
+ var err error
+ err = jsoniter.Unmarshal(contents, is.ii)
+
+ if err != nil {
+ logger.Verbosef("DEBUG: InstanceInfo unmarshal error: %s\n", err)
+ return
+ }
+
+ eii = is.ii.GetInstanceInfo()
+ return
+}
+
+//Put 提交断点续传信息
+func (is *InstanceState) Put(eii *transfer.DownloadInstanceInfo) {
+ if !is.checkSaveFile() {
+ return
+ }
+
+ is.mu.Lock()
+ defer is.mu.Unlock()
+
+ if is.ii == nil {
+ is.ii = &transfer.DownloadInstanceInfoExport{}
+ }
+ is.ii.SetInstanceInfo(eii)
+ var (
+ data []byte
+ err error
+ )
+ data, err = jsoniter.Marshal(is.ii)
+ if err != nil {
+ panic(err)
+ }
+
+ err = is.saveFile.Truncate(int64(len(data)))
+ if err != nil {
+ logger.Verbosef("DEBUG: truncate file error: %s\n", err)
+ }
+
+ _, err = is.saveFile.WriteAt(crypto.Base64Encode(data), 0)
+ if err != nil {
+ logger.Verbosef("DEBUG: write instance state error: %s\n", err)
+ }
+}
+
+//Close 关闭
+func (is *InstanceState) Close() error {
+ if !is.checkSaveFile() {
+ return nil
+ }
+
+ return is.saveFile.Close()
+}
+
+func (der *Downloader) initInstanceState(format InstanceStateStorageFormat) (err error) {
+ if der.instanceState != nil {
+ return errors.New("already initInstanceState")
+ }
+
+ var saveFile *os.File
+ if der.config.InstanceStatePath != "" {
+ saveFile, err = os.OpenFile(der.config.InstanceStatePath, os.O_RDWR|os.O_CREATE, 0777)
+ if err != nil {
+ return err
+ }
+ }
+
+ der.instanceState = NewInstanceState(saveFile, format)
+ return nil
+}
+
+func (der *Downloader) removeInstanceState() error {
+ der.instanceState.Close()
+ if der.config.InstanceStatePath != "" {
+ return os.Remove(der.config.InstanceStatePath)
+ }
+ return nil
+}
diff --git a/internal/file/downloader/loadbalance.go b/internal/file/downloader/loadbalance.go
new file mode 100644
index 0000000..024b39f
--- /dev/null
+++ b/internal/file/downloader/loadbalance.go
@@ -0,0 +1,81 @@
+// 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 downloader
+
+import (
+ "net/http"
+ "sync/atomic"
+)
+
+type (
+ // LoadBalancerResponse 负载均衡响应状态
+ LoadBalancerResponse struct {
+ URL string
+ }
+
+ // LoadBalancerResponseList 负载均衡列表
+ LoadBalancerResponseList struct {
+ lbr []*LoadBalancerResponse
+ cursor int32
+ }
+
+ LoadBalancerCompareFunc func(info map[string]string, subResp *http.Response) bool
+)
+
+// NewLoadBalancerResponseList 初始化负载均衡列表
+func NewLoadBalancerResponseList(lbr []*LoadBalancerResponse) *LoadBalancerResponseList {
+ return &LoadBalancerResponseList{
+ lbr: lbr,
+ }
+}
+
+// SequentialGet 顺序获取
+func (lbrl *LoadBalancerResponseList) SequentialGet() *LoadBalancerResponse {
+ if len(lbrl.lbr) == 0 {
+ return nil
+ }
+
+ if int(lbrl.cursor) >= len(lbrl.lbr) {
+ lbrl.cursor = 0
+ }
+
+ lbr := lbrl.lbr[int(lbrl.cursor)]
+ atomic.AddInt32(&lbrl.cursor, 1)
+ return lbr
+}
+
+// RandomGet 随机获取
+func (lbrl *LoadBalancerResponseList) RandomGet() *LoadBalancerResponse {
+ return lbrl.lbr[RandomNumber(0, len(lbrl.lbr))]
+}
+
+// AddLoadBalanceServer 增加负载均衡服务器
+func (der *Downloader) AddLoadBalanceServer(urls ...string) {
+ der.loadBalansers = append(der.loadBalansers, urls...)
+}
+
+// DefaultLoadBalancerCompareFunc 检测负载均衡的服务器是否一致
+func DefaultLoadBalancerCompareFunc(info map[string]string, subResp *http.Response) bool {
+ if info == nil || subResp == nil {
+ return false
+ }
+
+ for headerKey, value := range info {
+ if value != subResp.Header.Get(headerKey) {
+ return false
+ }
+ }
+
+ return true
+}
diff --git a/internal/file/downloader/monitor.go b/internal/file/downloader/monitor.go
new file mode 100644
index 0000000..d1fb021
--- /dev/null
+++ b/internal/file/downloader/monitor.go
@@ -0,0 +1,447 @@
+// 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 downloader
+
+import (
+ "context"
+ "errors"
+ "github.com/tickstep/library-go/logger"
+ "github.com/tickstep/aliyunpan/library/requester/transfer"
+ "sort"
+ "time"
+)
+
+var (
+ //ErrNoWokers no workers
+ ErrNoWokers = errors.New("no workers")
+)
+
+type (
+ //Monitor 线程监控器
+ Monitor struct {
+ workers WorkerList
+ status *transfer.DownloadStatus
+ instanceState *InstanceState
+ completed chan struct{}
+ err error
+ resetController *ResetController
+ isReloadWorker bool //是否重载worker, 单线程模式不重载
+
+ // 临时变量
+ lastAvaliableIndex int
+ }
+
+ // RangeWorkerFunc 遍历workers的函数
+ RangeWorkerFunc func(key int, worker *Worker) bool
+)
+
+//NewMonitor 初始化Monitor
+func NewMonitor() *Monitor {
+ monitor := &Monitor{}
+ return monitor
+}
+
+func (mt *Monitor) lazyInit() {
+ if mt.workers == nil {
+ mt.workers = make(WorkerList, 0, 100)
+ }
+ if mt.status == nil {
+ mt.status = transfer.NewDownloadStatus()
+ }
+ if mt.resetController == nil {
+ mt.resetController = NewResetController(80)
+ }
+}
+
+//InitMonitorCapacity 初始化workers, 用于Append
+func (mt *Monitor) InitMonitorCapacity(capacity int) {
+ mt.workers = make(WorkerList, 0, capacity)
+}
+
+//Append 增加Worker
+func (mt *Monitor) Append(worker *Worker) {
+ if worker == nil {
+ return
+ }
+ mt.workers = append(mt.workers, worker)
+}
+
+//SetWorkers 设置workers, 此操作会覆盖原有的workers
+func (mt *Monitor) SetWorkers(workers WorkerList) {
+ mt.workers = workers
+}
+
+//SetStatus 设置DownloadStatus
+func (mt *Monitor) SetStatus(status *transfer.DownloadStatus) {
+ mt.status = status
+}
+
+//SetInstanceState 设置状态
+func (mt *Monitor) SetInstanceState(instanceState *InstanceState) {
+ mt.instanceState = instanceState
+}
+
+//Status 返回DownloadStatus
+func (mt *Monitor) Status() *transfer.DownloadStatus {
+ return mt.status
+}
+
+//Err 返回遇到的错误
+func (mt *Monitor) Err() error {
+ return mt.err
+}
+
+//CompletedChan 获取completed chan
+func (mt *Monitor) CompletedChan() <-chan struct{} {
+ return mt.completed
+}
+
+//GetAvailableWorker 获取空闲的worker
+func (mt *Monitor) GetAvailableWorker() *Worker {
+ workerCount := len(mt.workers)
+ for i := mt.lastAvaliableIndex; i < mt.lastAvaliableIndex+workerCount; i++ {
+ index := i % workerCount
+ worker := mt.workers[index]
+ if worker.Completed() {
+ mt.lastAvaliableIndex = index
+ return worker
+ }
+ }
+ return nil
+}
+
+//GetAllWorkersRange 获取所有worker的范围
+func (mt *Monitor) GetAllWorkersRange() transfer.RangeList {
+ allWorkerRanges := make(transfer.RangeList, 0, len(mt.workers))
+ for _, worker := range mt.workers {
+ allWorkerRanges = append(allWorkerRanges, worker.GetRange())
+ }
+ return allWorkerRanges
+}
+
+//NumLeftWorkers 剩余的worker数量
+func (mt *Monitor) NumLeftWorkers() (num int) {
+ for _, worker := range mt.workers {
+ if !worker.Completed() {
+ num++
+ }
+ }
+ return
+}
+
+//SetReloadWorker 是否重载worker
+func (mt *Monitor) SetReloadWorker(b bool) {
+ mt.isReloadWorker = b
+}
+
+//IsLeftWorkersAllFailed 剩下的线程是否全部失败
+func (mt *Monitor) IsLeftWorkersAllFailed() bool {
+ failedNum := 0
+ for _, worker := range mt.workers {
+ if worker.Completed() {
+ continue
+ }
+
+ if !worker.Failed() {
+ failedNum++
+ return false
+ }
+ }
+ return failedNum != 0
+}
+
+//registerAllCompleted 全部完成则发送消息
+func (mt *Monitor) registerAllCompleted() {
+ mt.completed = make(chan struct{}, 0)
+ var (
+ workerNum = len(mt.workers)
+ completeNum = 0
+ )
+
+ go func() {
+ for {
+ time.Sleep(1 * time.Second)
+
+ completeNum = 0
+ for _, worker := range mt.workers {
+ switch worker.GetStatus().StatusCode() {
+ case StatusCodeInternalError:
+ // 检测到内部错误
+ // 马上停止执行
+ mt.err = worker.Err()
+ close(mt.completed)
+ return
+ case StatusCodeSuccessed, StatusCodeCanceled:
+ completeNum++
+ }
+ }
+ // status 在 lazyInit 之后, 不可能为空
+ // 完成条件: 所有worker 都已经完成, 且 rangeGen 已生成完毕
+ gen := mt.status.RangeListGen()
+ if completeNum >= workerNum && (gen == nil || gen.IsDone()) { // 已完成
+ close(mt.completed)
+ return
+ }
+ }
+ }()
+}
+
+//ResetFailedAndNetErrorWorkers 重设部分网络错误的worker
+func (mt *Monitor) ResetFailedAndNetErrorWorkers() {
+ for k := range mt.workers {
+ if !mt.resetController.CanReset() {
+ continue
+ }
+
+ switch mt.workers[k].GetStatus().StatusCode() {
+ case StatusCodeNetError:
+ logger.Verbosef("DEBUG: monitor: ResetFailedAndNetErrorWorkers: reset StatusCodeNetError worker, id: %d\n", mt.workers[k].id)
+ goto reset
+ case StatusCodeFailed:
+ logger.Verbosef("DEBUG: monitor: ResetFailedAndNetErrorWorkers: reset StatusCodeFailed worker, id: %d\n", mt.workers[k].id)
+ goto reset
+ default:
+ continue
+ }
+
+ reset:
+ mt.workers[k].Reset()
+ mt.resetController.AddResetNum()
+ }
+}
+
+//RangeWorker 遍历worker
+func (mt *Monitor) RangeWorker(f RangeWorkerFunc) {
+ for k := range mt.workers {
+ if !f(k, mt.workers[k]) {
+ break
+ }
+ }
+}
+
+//Pause 暂停所有的下载
+func (mt *Monitor) Pause() {
+ for k := range mt.workers {
+ mt.workers[k].Pause()
+ }
+}
+
+//Resume 恢复所有的下载
+func (mt *Monitor) Resume() {
+ for k := range mt.workers {
+ mt.workers[k].Resume()
+ }
+}
+
+// TryAddNewWork 尝试加入新range
+func (mt *Monitor) TryAddNewWork() {
+ if mt.status == nil {
+ return
+ }
+ gen := mt.status.RangeListGen()
+ if gen == nil || gen.IsDone() {
+ return
+ }
+
+ if !mt.resetController.CanReset() { //能否建立新连接
+ return
+ }
+
+ availableWorker := mt.GetAvailableWorker()
+ if availableWorker == nil {
+ return
+ }
+
+ // 有空闲的range, 执行
+ _, r := gen.GenRange()
+ if r == nil {
+ // 没有range了
+ return
+ }
+
+ availableWorker.SetRange(r)
+ availableWorker.ClearStatus()
+
+ mt.resetController.AddResetNum()
+ logger.Verbosef("MONITER: worker[%d] add new range: %s\n", availableWorker.ID(), r.ShowDetails())
+ go availableWorker.Execute()
+}
+
+// DynamicSplitWorker 动态分配线程
+func (mt *Monitor) DynamicSplitWorker(worker *Worker) {
+ if !mt.resetController.CanReset() {
+ return
+ }
+
+ switch worker.status.statusCode {
+ case StatusCodeDownloading, StatusCodeFailed, StatusCodeNetError:
+ //pass
+ default:
+ return
+ }
+
+ // 筛选空闲的Worker
+ availableWorker := mt.GetAvailableWorker()
+ if availableWorker == nil || worker == availableWorker { // 没有空的
+ return
+ }
+
+ workerRange := worker.GetRange()
+
+ end := workerRange.LoadEnd()
+ middle := (workerRange.LoadBegin() + end) / 2
+
+ if end-middle < MinParallelSize/5 { // 如果线程剩余的下载量太少, 不分配空闲线程
+ return
+ }
+
+ // 折半
+ availableWorkerRange := availableWorker.GetRange()
+ availableWorkerRange.StoreBegin(middle) // middle不能加1
+ availableWorkerRange.StoreEnd(end)
+ availableWorker.ClearStatus()
+
+ workerRange.StoreEnd(middle)
+
+ mt.resetController.AddResetNum()
+ logger.Verbosef("MONITOR: worker duplicated: %d <- %d\n", availableWorker.ID(), worker.ID())
+ go availableWorker.Execute()
+}
+
+// ResetWorker 重设长时间无响应, 和下载速度为 0 的 Worker
+func (mt *Monitor) ResetWorker(worker *Worker) {
+ if !mt.resetController.CanReset() { //达到最大重载次数
+ return
+ }
+
+ if worker.Completed() {
+ return
+ }
+
+ // 忽略正在写入数据到硬盘的
+ // 过滤速度有变化的线程
+ status := worker.GetStatus()
+ speeds := worker.GetSpeedsPerSecond()
+ if speeds != 0 {
+ return
+ }
+
+ switch status.StatusCode() {
+ case StatusCodePending, StatusCodeReseted:
+ fallthrough
+ case StatusCodeWaitToWrite: // 正在写入数据
+ fallthrough
+ case StatusCodePaused: // 已暂停
+ // 忽略, 返回
+ return
+ case StatusCodeDownloadUrlExpired: // 下载链接已经过期
+ worker.RefreshDownloadUrl()
+ break
+ }
+
+ mt.resetController.AddResetNum()
+
+ // 重设连接
+ logger.Verbosef("MONITOR: worker[%d] reload\n", worker.ID())
+ worker.Reset()
+}
+
+//Execute 执行任务
+func (mt *Monitor) Execute(cancelCtx context.Context) {
+ if len(mt.workers) == 0 {
+ mt.err = ErrNoWokers
+ return
+ }
+
+ mt.lazyInit()
+ for _, worker := range mt.workers {
+ worker.SetDownloadStatus(mt.status)
+ go worker.Execute()
+ }
+
+ mt.registerAllCompleted() // 注册completed
+ ticker := time.NewTicker(990 * time.Millisecond)
+ defer ticker.Stop()
+
+ //开始监控
+ for {
+ select {
+ case <-cancelCtx.Done():
+ for _, worker := range mt.workers {
+ err := worker.Cancel()
+ if err != nil {
+ logger.Verbosef("DEBUG: cancel failed, worker id: %d, err: %s\n", worker.ID(), err)
+ }
+ }
+ return
+ case <-mt.completed:
+ return
+ case <-ticker.C:
+ // 初始化监控工作
+ mt.ResetFailedAndNetErrorWorkers()
+
+ mt.status.UpdateSpeeds() // 更新速度
+
+ // 保存断点信息到文件
+ if mt.instanceState != nil {
+ mt.instanceState.Put(&transfer.DownloadInstanceInfo{
+ DownloadStatus: mt.status,
+ Ranges: mt.GetAllWorkersRange(),
+ })
+ }
+
+ // 加入新range
+ mt.TryAddNewWork()
+
+ // 是否有失败的worker
+ for _, w := range mt.workers {
+ if w.status.statusCode == StatusCodeDownloadUrlExpired {
+ mt.ResetWorker(w)
+ }
+ }
+
+ // 不重载worker
+ if !mt.isReloadWorker {
+ continue
+ }
+
+ // 更新maxSpeeds
+ mt.status.SetMaxSpeeds(mt.status.SpeedsPerSecond())
+
+ // 速度减慢或者全部失败, 开始监控
+ // 只有一个worker时不重设连接
+ isLeftWorkersAllFailed := mt.IsLeftWorkersAllFailed()
+ if mt.status.SpeedsPerSecond() < mt.status.MaxSpeeds()/6 || isLeftWorkersAllFailed {
+ if isLeftWorkersAllFailed {
+ logger.Verbosef("DEBUG: monitor: All workers failed\n")
+ }
+ mt.status.ClearMaxSpeeds() //清空最大速度的统计
+
+ // 先进行动态分配线程
+ logger.Verbosef("DEBUG: monitor: start duplicate.\n")
+ sort.Sort(ByLeftDesc{mt.workers})
+ for _, worker := range mt.workers {
+ //动态分配线程
+ mt.DynamicSplitWorker(worker)
+ }
+
+ // 重设长时间无响应, 和下载速度为 0 的线程
+ logger.Verbosef("DEBUG: monitor: start reload.\n")
+ for _, worker := range mt.workers {
+ mt.ResetWorker(worker)
+ }
+ } // end if
+ } //end select
+ } //end for
+}
diff --git a/internal/file/downloader/resetcontroler.go b/internal/file/downloader/resetcontroler.go
new file mode 100644
index 0000000..f8b11f4
--- /dev/null
+++ b/internal/file/downloader/resetcontroler.go
@@ -0,0 +1,61 @@
+// 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 downloader
+
+import (
+ "github.com/tickstep/library-go/expires"
+ "sync"
+ "time"
+)
+
+// ResetController 网络连接控制器
+type ResetController struct {
+ mu sync.Mutex
+ currentTime time.Time
+ maxResetNum int
+ resetEntity map[expires.Expires]struct{}
+}
+
+// NewResetController 初始化*ResetController
+func NewResetController(maxResetNum int) *ResetController {
+ return &ResetController{
+ currentTime: time.Now(),
+ maxResetNum: maxResetNum,
+ resetEntity: map[expires.Expires]struct{}{},
+ }
+}
+
+func (rc *ResetController) update() {
+ for k := range rc.resetEntity {
+ if k.IsExpires() {
+ delete(rc.resetEntity, k)
+ }
+ }
+}
+
+// AddResetNum 增加连接
+func (rc *ResetController) AddResetNum() {
+ rc.mu.Lock()
+ defer rc.mu.Unlock()
+ rc.update()
+ rc.resetEntity[expires.NewExpires(9*time.Second)] = struct{}{}
+}
+
+// CanReset 是否可以建立连接
+func (rc *ResetController) CanReset() bool {
+ rc.mu.Lock()
+ defer rc.mu.Unlock()
+ rc.update()
+ return len(rc.resetEntity) < rc.maxResetNum
+}
diff --git a/internal/file/downloader/sort.go b/internal/file/downloader/sort.go
new file mode 100644
index 0000000..c159d88
--- /dev/null
+++ b/internal/file/downloader/sort.go
@@ -0,0 +1,36 @@
+// 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 downloader
+
+type (
+ // ByLeftDesc 根据剩余下载量倒序排序
+ ByLeftDesc struct {
+ WorkerList
+ }
+)
+
+// Len 返回长度
+func (wl WorkerList) Len() int {
+ return len(wl)
+}
+
+// Swap 交换
+func (wl WorkerList) Swap(i, j int) {
+ wl[i], wl[j] = wl[j], wl[i]
+}
+
+// Less 实现倒序
+func (wl ByLeftDesc) Less(i, j int) bool {
+ return wl.WorkerList[i].wrange.Len() > wl.WorkerList[j].wrange.Len()
+}
diff --git a/internal/file/downloader/status.go b/internal/file/downloader/status.go
new file mode 100644
index 0000000..a6cc36d
--- /dev/null
+++ b/internal/file/downloader/status.go
@@ -0,0 +1,120 @@
+// 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 downloader
+
+import (
+ "github.com/tickstep/aliyunpan/library/requester/transfer"
+)
+
+type (
+ //WorkerStatuser 状态
+ WorkerStatuser interface {
+ StatusCode() StatusCode //状态码
+ StatusText() string
+ }
+
+ //StatusCode 状态码
+ StatusCode int
+
+ //WorkerStatus worker状态
+ WorkerStatus struct {
+ statusCode StatusCode
+ }
+
+ // DownloadStatusFunc 下载状态处理函数
+ DownloadStatusFunc func(status transfer.DownloadStatuser, workersCallback func(RangeWorkerFunc))
+)
+
+const (
+ //StatusCodeInit 初始化
+ StatusCodeInit StatusCode = iota
+ //StatusCodeSuccessed 成功
+ StatusCodeSuccessed
+ //StatusCodePending 等待响应
+ StatusCodePending
+ //StatusCodeDownloading 下载中
+ StatusCodeDownloading
+ //StatusCodeWaitToWrite 等待写入数据
+ StatusCodeWaitToWrite
+ //StatusCodeInternalError 内部错误
+ StatusCodeInternalError
+ //StatusCodeTooManyConnections 连接数太多
+ StatusCodeTooManyConnections
+ //StatusCodeNetError 网络错误
+ StatusCodeNetError
+ //StatusCodeFailed 下载失败
+ StatusCodeFailed
+ //StatusCodePaused 已暂停
+ StatusCodePaused
+ //StatusCodeReseted 已重设连接
+ StatusCodeReseted
+ //StatusCodeCanceled 已取消
+ StatusCodeCanceled
+ //StatusCodeDownloadUrlExpired 下载链接已过期
+ StatusCodeDownloadUrlExpired
+)
+
+//GetStatusText 根据状态码获取状态信息
+func GetStatusText(sc StatusCode) string {
+ switch sc {
+ case StatusCodeInit:
+ return "初始化"
+ case StatusCodeSuccessed:
+ return "成功"
+ case StatusCodePending:
+ return "等待响应"
+ case StatusCodeDownloading:
+ return "下载中"
+ case StatusCodeWaitToWrite:
+ return "等待写入数据"
+ case StatusCodeInternalError:
+ return "内部错误"
+ case StatusCodeTooManyConnections:
+ return "连接数太多"
+ case StatusCodeNetError:
+ return "网络错误"
+ case StatusCodeFailed:
+ return "下载失败"
+ case StatusCodePaused:
+ return "已暂停"
+ case StatusCodeReseted:
+ return "已重设连接"
+ case StatusCodeCanceled:
+ return "已取消"
+ default:
+ return "未知状态码"
+ }
+}
+
+//NewWorkerStatus 初始化WorkerStatus
+func NewWorkerStatus() *WorkerStatus {
+ return &WorkerStatus{
+ statusCode: StatusCodeInit,
+ }
+}
+
+//SetStatusCode 设置worker状态码
+func (ws *WorkerStatus) SetStatusCode(sc StatusCode) {
+ ws.statusCode = sc
+}
+
+//StatusCode 返回状态码
+func (ws *WorkerStatus) StatusCode() StatusCode {
+ return ws.statusCode
+}
+
+//StatusText 返回状态信息
+func (ws *WorkerStatus) StatusText() string {
+ return GetStatusText(ws.statusCode)
+}
diff --git a/internal/file/downloader/utils.go b/internal/file/downloader/utils.go
new file mode 100644
index 0000000..b0c538e
--- /dev/null
+++ b/internal/file/downloader/utils.go
@@ -0,0 +1,97 @@
+// 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 downloader
+
+import (
+ "github.com/tickstep/library-go/logger"
+ "github.com/tickstep/library-go/requester"
+ mathrand "math/rand"
+ "mime"
+ "net/url"
+ "path"
+ "regexp"
+ "strconv"
+ "time"
+)
+
+var (
+ // ContentRangeRE Content-Range 正则
+ ContentRangeRE = regexp.MustCompile(`^.*? \d*?-\d*?/(\d*?)$`)
+
+ // ranSource 随机数种子
+ ranSource = mathrand.NewSource(time.Now().UnixNano())
+
+ // ran 一个随机数实例
+ ran = mathrand.New(ranSource)
+)
+
+// RandomNumber 生成指定区间随机数
+func RandomNumber(min, max int) int {
+ if min > max {
+ min, max = max, min
+ }
+ return ran.Intn(max-min) + min
+}
+
+// GetFileName 获取文件名
+func GetFileName(uri string, client *requester.HTTPClient) (filename string, err error) {
+ if client == nil {
+ client = requester.NewHTTPClient()
+ }
+
+ resp, err := client.Req("HEAD", uri, nil, nil)
+ if resp != nil {
+ defer resp.Body.Close()
+ }
+ if err != nil {
+ return "", err
+ }
+
+ _, params, err := mime.ParseMediaType(resp.Header.Get("Content-Disposition"))
+ if err != nil {
+ logger.Verbosef("DEBUG: GetFileName ParseMediaType error: %s\n", err)
+ return path.Base(uri), nil
+ }
+
+ filename, err = url.QueryUnescape(params["filename"])
+ if err != nil {
+ return
+ }
+
+ if filename == "" {
+ filename = path.Base(uri)
+ }
+
+ return
+}
+
+// ParseContentRange 解析Content-Range
+func ParseContentRange(contentRange string) (contentLength int64) {
+ raw := ContentRangeRE.FindStringSubmatch(contentRange)
+ if len(raw) < 2 {
+ return -1
+ }
+
+ c, err := strconv.ParseInt(raw[1], 10, 64)
+ if err != nil {
+ return -1
+ }
+ return c
+}
+
+func fixCacheSize(size *int) {
+ if *size < 1024 {
+ *size = 1024
+ }
+}
diff --git a/internal/file/downloader/worker.go b/internal/file/downloader/worker.go
new file mode 100644
index 0000000..854b0d6
--- /dev/null
+++ b/internal/file/downloader/worker.go
@@ -0,0 +1,491 @@
+// 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 downloader
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "github.com/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
+ "github.com/tickstep/library-go/cachepool"
+ "github.com/tickstep/library-go/logger"
+ "github.com/tickstep/library-go/requester"
+ "github.com/tickstep/library-go/requester/rio/speeds"
+ "github.com/tickstep/aliyunpan/library/requester/transfer"
+ "io"
+ "net/http"
+ "sync"
+)
+
+type (
+ //Worker 工作单元
+ Worker struct {
+ totalSize int64 // 整个文件的大小, worker请求range时会获取尝试获取该值, 如果不匹配, 则返回错误
+ wrange *transfer.Range
+ speedsStat *speeds.Speeds
+ id int // work id
+ fileId string // 文件ID
+ driveId string
+ url string // 下载地址
+ acceptRanges string
+ panClient *aliyunpan.PanClient
+ client *requester.HTTPClient
+ writerAt io.WriterAt
+ writeMu *sync.Mutex
+ execMu sync.Mutex
+
+ pauseChan chan struct{}
+ workerCancelFunc context.CancelFunc
+ resetFunc context.CancelFunc
+ readRespBodyCancelFunc func()
+ err error // 错误信息
+ status WorkerStatus
+ downloadStatus *transfer.DownloadStatus // 总的下载状态
+ }
+
+ // WorkerList worker列表
+ WorkerList []*Worker
+)
+
+// Duplicate 构造新的列表
+func (wl WorkerList) Duplicate() WorkerList {
+ n := make(WorkerList, len(wl))
+ copy(n, wl)
+ return n
+}
+
+//NewWorker 初始化Worker
+func NewWorker(id int, driveId string, fileId, durl string, writerAt io.WriterAt) *Worker {
+ return &Worker{
+ id: id,
+ url: durl,
+ writerAt: writerAt,
+ fileId: fileId,
+ driveId: driveId,
+ }
+}
+
+//ID 返回worker ID
+func (wer *Worker) ID() int {
+ return wer.id
+}
+
+func (wer *Worker) lazyInit() {
+ if wer.client == nil {
+ wer.client = requester.NewHTTPClient()
+ }
+ if wer.pauseChan == nil {
+ wer.pauseChan = make(chan struct{})
+ }
+ if wer.wrange == nil {
+ wer.wrange = &transfer.Range{}
+ }
+ if wer.wrange.LoadBegin() == 0 && wer.wrange.LoadEnd() == 0 {
+ // 取消多线程下载
+ wer.acceptRanges = ""
+ wer.wrange.StoreEnd(-2)
+ }
+ if wer.speedsStat == nil {
+ wer.speedsStat = &speeds.Speeds{}
+ }
+}
+
+// SetTotalSize 设置整个文件的大小, worker请求range时会获取尝试获取该值, 如果不匹配, 则返回错误
+func (wer *Worker) SetTotalSize(size int64) {
+ wer.totalSize = size
+}
+
+//SetClient 设置http客户端
+func (wer *Worker) SetClient(c *requester.HTTPClient) {
+ wer.client = c
+}
+
+func (wer *Worker) SetPanClient(p *aliyunpan.PanClient) {
+ wer.panClient = p
+}
+
+//SetAcceptRange 设置AcceptRange
+func (wer *Worker) SetAcceptRange(acceptRanges string) {
+ wer.acceptRanges = acceptRanges
+}
+
+//SetRange 设置请求范围
+func (wer *Worker) SetRange(r *transfer.Range) {
+ if wer.wrange == nil {
+ wer.wrange = r
+ return
+ }
+ wer.wrange.StoreBegin(r.LoadBegin())
+ wer.wrange.StoreEnd(r.LoadEnd())
+}
+
+//SetWriteMutex 设置数据写锁
+func (wer *Worker) SetWriteMutex(mu *sync.Mutex) {
+ wer.writeMu = mu
+}
+
+//SetDownloadStatus 增加其他需要统计的数据
+func (wer *Worker) SetDownloadStatus(downloadStatus *transfer.DownloadStatus) {
+ wer.downloadStatus = downloadStatus
+}
+
+//GetStatus 返回下载状态
+func (wer *Worker) GetStatus() WorkerStatuser {
+ // 空接口与空指针不等价
+ return &wer.status
+}
+
+//GetRange 返回worker范围
+func (wer *Worker) GetRange() *transfer.Range {
+ return wer.wrange
+}
+
+//GetSpeedsPerSecond 获取每秒的速度
+func (wer *Worker) GetSpeedsPerSecond() int64 {
+ return wer.speedsStat.GetSpeeds()
+}
+
+//Pause 暂停下载
+func (wer *Worker) Pause() {
+ wer.lazyInit()
+ if wer.acceptRanges == "" {
+ logger.Verbosef("WARNING: worker unsupport pause")
+ return
+ }
+
+ if wer.status.statusCode == StatusCodePaused {
+ return
+ }
+ wer.pauseChan <- struct{}{}
+ wer.status.statusCode = StatusCodePaused
+}
+
+//Resume 恢复下载
+func (wer *Worker) Resume() {
+ if wer.status.statusCode != StatusCodePaused {
+ return
+ }
+ go wer.Execute()
+}
+
+//Cancel 取消下载
+func (wer *Worker) Cancel() error {
+ if wer.workerCancelFunc == nil {
+ return errors.New("cancelFunc not set")
+ }
+ wer.workerCancelFunc()
+ if wer.readRespBodyCancelFunc != nil {
+ wer.readRespBodyCancelFunc()
+ }
+ return nil
+}
+
+//Reset 重设连接
+func (wer *Worker) Reset() {
+ if wer.resetFunc == nil {
+ logger.Verbosef("DEBUG: worker: resetFunc not set")
+ return
+ }
+ wer.resetFunc()
+ if wer.readRespBodyCancelFunc != nil {
+ wer.readRespBodyCancelFunc()
+ }
+ wer.ClearStatus()
+ go wer.Execute()
+}
+
+// RefreshDownloadUrl 重新刷新下载链接
+func (wer *Worker) RefreshDownloadUrl() {
+ var apierr *apierror.ApiError
+
+ durl, apierr := wer.panClient.GetFileDownloadUrl(&aliyunpan.GetFileDownloadUrlParam{DriveId: wer.driveId, FileId: wer.fileId})
+ if apierr != nil {
+ wer.status.statusCode = StatusCodeTooManyConnections
+ return
+ }
+ wer.url = durl.Url
+}
+
+// Canceled 是否已经取消
+func (wer *Worker) Canceled() bool {
+ return wer.status.statusCode == StatusCodeCanceled
+}
+
+//Completed 是否已经完成
+func (wer *Worker) Completed() bool {
+ switch wer.status.statusCode {
+ case StatusCodeSuccessed, StatusCodeCanceled:
+ return true
+ default:
+ return false
+ }
+}
+
+//Failed 是否失败
+func (wer *Worker) Failed() bool {
+ switch wer.status.statusCode {
+ case StatusCodeFailed, StatusCodeInternalError, StatusCodeTooManyConnections, StatusCodeNetError:
+ return true
+ default:
+ return false
+ }
+}
+
+//ClearStatus 清空状态
+func (wer *Worker) ClearStatus() {
+ wer.status.statusCode = StatusCodeInit
+}
+
+//Err 返回worker错误
+func (wer *Worker) Err() error {
+ return wer.err
+}
+
+//Execute 执行任务
+func (wer *Worker) Execute() {
+ wer.lazyInit()
+
+ wer.execMu.Lock()
+ defer wer.execMu.Unlock()
+
+ wer.status.statusCode = StatusCodeInit
+ single := wer.acceptRanges == ""
+
+ // 如果已暂停, 退出
+ if wer.status.statusCode == StatusCodePaused {
+ return
+ }
+
+ if !single {
+ // 已完成
+ if rlen := wer.wrange.Len(); rlen <= 0 {
+ if rlen < 0 {
+ logger.Verbosef("DEBUG: RangeLen is negative at begin: %v, %d\n", wer.wrange, wer.wrange.Len())
+ }
+ wer.status.statusCode = StatusCodeSuccessed
+ return
+ }
+ }
+
+ // zero size file
+ if wer.totalSize == 0 {
+ wer.status.statusCode = StatusCodeSuccessed
+ return
+ }
+
+ workerCancelCtx, workerCancelFunc := context.WithCancel(context.Background())
+ wer.workerCancelFunc = workerCancelFunc
+ resetCtx, resetFunc := context.WithCancel(context.Background())
+ wer.resetFunc = resetFunc
+
+ wer.status.statusCode = StatusCodePending
+
+ var resp *http.Response
+
+ apierr := wer.panClient.DownloadFileData(wer.url, aliyunpan.FileDownloadRange{
+ Offset: wer.wrange.Begin,
+ End: wer.wrange.End - 1,
+ }, func(httpMethod, fullUrl string, headers map[string]string) (*http.Response, error) {
+ resp, wer.err = wer.client.Req(httpMethod, fullUrl, nil, headers)
+ if wer.err != nil {
+ return nil, wer.err
+ }
+ return resp, wer.err
+ })
+
+ if resp != nil {
+ defer func() {
+ resp.Body.Close()
+ }()
+ wer.readRespBodyCancelFunc = func() {
+ resp.Body.Close()
+ }
+ }
+ if wer.err != nil || apierr != nil {
+ wer.status.statusCode = StatusCodeNetError
+ return
+ }
+
+ // 判断响应状态
+ switch resp.StatusCode {
+ case 200, 206:
+ // do nothing, continue
+ wer.status.statusCode = StatusCodeDownloading
+ break
+ case 416: //Requested Range Not Satisfiable
+ fallthrough
+ case 403: // Forbidden
+ fallthrough
+ case 406: // Not Acceptable
+ wer.status.statusCode = StatusCodeNetError
+ wer.err = errors.New(resp.Status)
+ return
+ case 404:
+ wer.status.statusCode = StatusCodeDownloadUrlExpired
+ wer.err = errors.New(resp.Status)
+ return
+ case 429, 509: // Too Many Requests
+ wer.status.SetStatusCode(StatusCodeTooManyConnections)
+ wer.err = errors.New(resp.Status)
+ return
+ default:
+ wer.status.statusCode = StatusCodeNetError
+ wer.err = fmt.Errorf("unexpected http status code, %d, %s", resp.StatusCode, resp.Status)
+ return
+ }
+
+ var (
+ contentLength = resp.ContentLength
+ rangeLength = wer.wrange.Len()
+ )
+
+ if !single {
+ // 检查请求长度
+ if contentLength != rangeLength {
+ wer.status.statusCode = StatusCodeNetError
+ wer.err = fmt.Errorf("Content-Length is unexpected: %d, need %d", contentLength, rangeLength)
+ return
+ }
+ // 检查总大小
+ if wer.totalSize > 0 {
+ total := ParseContentRange(resp.Header.Get("Content-Range"))
+ if total > 0 {
+ if total != wer.totalSize {
+ wer.status.statusCode = StatusCodeInternalError // 这里设置为内部错误, 强制停止下载
+ wer.err = fmt.Errorf("Content-Range total length is unexpected: %d, need %d", total, wer.totalSize)
+ return
+ }
+ }
+ }
+ }
+
+ var (
+ buf = cachepool.SyncPool.Get().([]byte)
+ n, nn int
+ n64, nn64 int64
+ )
+ defer cachepool.SyncPool.Put(buf)
+
+ for {
+ select {
+ case <-workerCancelCtx.Done(): //取消
+ wer.status.statusCode = StatusCodeCanceled
+ return
+ case <-resetCtx.Done(): //重设连接
+ wer.status.statusCode = StatusCodeReseted
+ return
+ case <-wer.pauseChan: //暂停
+ return
+ default:
+ wer.status.statusCode = StatusCodeDownloading
+
+ // 初始化数据
+ var readErr error
+ n = 0
+
+ // 读取数据
+ for n < len(buf) && readErr == nil && (single || wer.wrange.Len() > 0) {
+ nn, readErr = resp.Body.Read(buf[n:])
+ nn64 = int64(nn)
+
+ // 更新速度统计
+ if wer.downloadStatus != nil {
+ wer.downloadStatus.AddSpeedsDownloaded(nn64) // 限速在这里阻塞
+ }
+ wer.speedsStat.Add(nn64)
+ n += nn
+ }
+
+ if n > 0 && readErr == io.EOF {
+ readErr = io.ErrUnexpectedEOF
+ }
+
+ n64 = int64(n)
+
+ // 非单线程模式下
+ if !single {
+ rangeLength = wer.wrange.Len()
+
+ // 已完成
+ if rangeLength <= 0 {
+ wer.status.statusCode = StatusCodeCanceled
+ wer.err = errors.New("worker already complete")
+ return
+ }
+
+ if n64 > rangeLength {
+ // 数据大小不正常
+ n64 = rangeLength
+ n = int(rangeLength)
+ readErr = io.EOF
+ }
+ }
+
+ // 写入数据
+ if wer.writerAt != nil {
+ wer.status.statusCode = StatusCodeWaitToWrite
+ if wer.writeMu != nil {
+ wer.writeMu.Lock() // 加锁, 减轻硬盘的压力
+ }
+ _, wer.err = wer.writerAt.WriteAt(buf[:n], wer.wrange.Begin) // 写入数据
+ if wer.err != nil {
+ if wer.writeMu != nil {
+ wer.writeMu.Unlock() //解锁
+ }
+ wer.status.statusCode = StatusCodeInternalError
+ return
+ }
+
+ if wer.writeMu != nil {
+ wer.writeMu.Unlock() //解锁
+ }
+ wer.status.statusCode = StatusCodeDownloading
+ }
+
+ // 更新下载统计数据
+ wer.wrange.AddBegin(n64)
+ if wer.downloadStatus != nil {
+ wer.downloadStatus.AddDownloaded(n64)
+ if single {
+ wer.downloadStatus.AddTotalSize(n64)
+ }
+ }
+
+ if readErr != nil {
+ rlen := wer.wrange.Len()
+ switch {
+ case single && readErr == io.ErrUnexpectedEOF:
+ // 单线程判断下载成功
+ fallthrough
+ case readErr == io.EOF:
+ fallthrough
+ case rlen <= 0:
+ // 下载完成
+ // 小于0可能是因为 worker 被 duplicate
+ wer.status.statusCode = StatusCodeSuccessed
+ if rlen < 0 {
+ logger.Verbosef("DEBUG: RangeLen is negative at end: %v, %d\n", wer.wrange, wer.wrange.Len())
+ }
+ return
+ default:
+ // 其他错误, 返回
+ wer.status.statusCode = StatusCodeFailed
+ wer.err = readErr
+ return
+ }
+ }
+ }
+ }
+}
diff --git a/internal/file/downloader/writer.go b/internal/file/downloader/writer.go
new file mode 100644
index 0000000..5b47304
--- /dev/null
+++ b/internal/file/downloader/writer.go
@@ -0,0 +1,42 @@
+// 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 downloader
+
+import (
+ "io"
+ "os"
+)
+
+type (
+ // Fder 获取fd接口
+ Fder interface {
+ Fd() uintptr
+ }
+
+ // Writer 下载器数据输出接口
+ Writer interface {
+ io.WriterAt
+ }
+)
+
+// NewDownloaderWriterByFilename 创建下载器数据输出接口, 类似于os.OpenFile
+func NewDownloaderWriterByFilename(name string, flag int, perm os.FileMode) (writer Writer, file *os.File, err error) {
+ file, err = os.OpenFile(name, flag, perm)
+ if err != nil {
+ return
+ }
+
+ writer = file
+ return
+}
diff --git a/internal/file/uploader/block.go b/internal/file/uploader/block.go
new file mode 100644
index 0000000..0ec33b6
--- /dev/null
+++ b/internal/file/uploader/block.go
@@ -0,0 +1,144 @@
+// 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 uploader
+
+import (
+ "bufio"
+ "fmt"
+ "github.com/tickstep/library-go/requester/rio/speeds"
+ "github.com/tickstep/aliyunpan/library/requester/transfer"
+ "io"
+ "os"
+ "sync"
+)
+
+type (
+ // SplitUnit 将 io.ReaderAt 分割单元
+ SplitUnit interface {
+ Readed64
+ io.Seeker
+ Range() transfer.Range
+ Left() int64
+ }
+
+ fileBlock struct {
+ readRange transfer.Range
+ readed int64
+ readerAt io.ReaderAt
+ speedsStatRef *speeds.Speeds
+ rateLimit *speeds.RateLimit
+ mu sync.Mutex
+ }
+
+ bufioFileBlock struct {
+ *fileBlock
+ bufio *bufio.Reader
+ }
+)
+
+// SplitBlock 文件分块
+func SplitBlock(fileSize, blockSize int64) (blockList []*BlockState) {
+ gen := transfer.NewRangeListGenBlockSize(fileSize, 0, blockSize)
+ rangeCount := gen.RangeCount()
+ blockList = make([]*BlockState, 0, rangeCount)
+ for i := 0; i < rangeCount; i++ {
+ id, r := gen.GenRange()
+ blockList = append(blockList, &BlockState{
+ ID: id,
+ Range: *r,
+ })
+ }
+ return
+}
+
+// NewBufioSplitUnit io.ReaderAt实现SplitUnit接口, 有Buffer支持
+func NewBufioSplitUnit(readerAt io.ReaderAt, readRange transfer.Range, speedsStat *speeds.Speeds, rateLimit *speeds.RateLimit) SplitUnit {
+ su := &fileBlock{
+ readerAt: readerAt,
+ readRange: readRange,
+ speedsStatRef: speedsStat,
+ rateLimit: rateLimit,
+ }
+ return &bufioFileBlock{
+ fileBlock: su,
+ bufio: bufio.NewReaderSize(su, BufioReadSize),
+ }
+}
+
+func (bfb *bufioFileBlock) Read(b []byte) (n int, err error) {
+ return bfb.bufio.Read(b) // 间接调用fileBlock 的Read
+}
+
+// Read 只允许一个线程读同一个文件
+func (fb *fileBlock) Read(b []byte) (n int, err error) {
+ fb.mu.Lock()
+ defer fb.mu.Unlock()
+
+ left := int(fb.Left())
+ if left <= 0 {
+ return 0, io.EOF
+ }
+
+ if len(b) > left {
+ n, err = fb.readerAt.ReadAt(b[:left], fb.readed+fb.readRange.Begin)
+ } else {
+ n, err = fb.readerAt.ReadAt(b, fb.readed+fb.readRange.Begin)
+ }
+
+ n64 := int64(n)
+ fb.readed += n64
+ if fb.rateLimit != nil {
+ fb.rateLimit.Add(n64) // 限速阻塞
+ }
+ if fb.speedsStatRef != nil {
+ fb.speedsStatRef.Add(n64)
+ }
+ return
+}
+
+func (fb *fileBlock) Seek(offset int64, whence int) (int64, error) {
+ fb.mu.Lock()
+ defer fb.mu.Unlock()
+
+ switch whence {
+ case os.SEEK_SET:
+ fb.readed = offset
+ case os.SEEK_CUR:
+ fb.readed += offset
+ case os.SEEK_END:
+ fb.readed = fb.readRange.End - fb.readRange.Begin + offset
+ default:
+ return 0, fmt.Errorf("unsupport whence: %d", whence)
+ }
+ if fb.readed < 0 {
+ fb.readed = 0
+ }
+ return fb.readed, nil
+}
+
+func (fb *fileBlock) Len() int64 {
+ return fb.readRange.End - fb.readRange.Begin
+}
+
+func (fb *fileBlock) Left() int64 {
+ return fb.readRange.End - fb.readRange.Begin - fb.readed
+}
+
+func (fb *fileBlock) Range() transfer.Range {
+ return fb.readRange
+}
+
+func (fb *fileBlock) Readed() int64 {
+ return fb.readed
+}
diff --git a/internal/file/uploader/block_test.go b/internal/file/uploader/block_test.go
new file mode 100644
index 0000000..0a425c6
--- /dev/null
+++ b/internal/file/uploader/block_test.go
@@ -0,0 +1,52 @@
+// 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 uploader_test
+
+import (
+ "fmt"
+ "github.com/tickstep/library-go/cachepool"
+ "github.com/tickstep/library-go/requester/rio"
+ "github.com/tickstep/aliyunpan/library/requester/transfer"
+ "github.com/tickstep/aliyunpan/internal/file/uploader"
+ "io"
+ "testing"
+)
+
+var (
+ blockList = uploader.SplitBlock(10000, 999)
+)
+
+func TestSplitBlock(t *testing.T) {
+ for k, e := range blockList {
+ fmt.Printf("%d %#v\n", k, e)
+ }
+}
+
+func TestSplitUnitRead(t *testing.T) {
+ var size int64 = 65536*2+3432
+ buffer := rio.NewBuffer(cachepool.RawMallocByteSlice(int(size)))
+ unit := uploader.NewBufioSplitUnit(buffer, transfer.Range{Begin: 2, End: size}, nil, nil)
+
+ buf := cachepool.RawMallocByteSlice(1022)
+ for {
+ n, err := unit.Read(buf)
+ if err != nil {
+ if err == io.EOF {
+ break
+ }
+ t.Fatalf("read error: %s\n", err)
+ }
+ fmt.Printf("n: %d, left: %d\n", n, unit.Left())
+ }
+}
diff --git a/internal/file/uploader/error.go b/internal/file/uploader/error.go
new file mode 100644
index 0000000..933e570
--- /dev/null
+++ b/internal/file/uploader/error.go
@@ -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 uploader
+
+type (
+ // MultiError 多线程上传的错误
+ MultiError struct {
+ Err error
+ // IsRetry 是否重试,
+ Terminated bool
+ NeedStartOver bool // 是否从头开始上传
+ }
+)
+
+func (me *MultiError) Error() string {
+ return me.Err.Error()
+}
diff --git a/internal/file/uploader/instance_state.go b/internal/file/uploader/instance_state.go
new file mode 100644
index 0000000..2682625
--- /dev/null
+++ b/internal/file/uploader/instance_state.go
@@ -0,0 +1,59 @@
+// 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 uploader
+
+import (
+ "github.com/tickstep/aliyunpan/library/requester/transfer"
+)
+
+type (
+ // BlockState 文件区块信息
+ BlockState struct {
+ ID int `json:"id"`
+ Range transfer.Range `json:"range"`
+ UploadDone bool `json:"upload_done"`
+ }
+
+ // InstanceState 上传断点续传信息
+ InstanceState struct {
+ BlockList []*BlockState `json:"block_list"`
+ }
+)
+
+func (muer *MultiUploader) getWorkerListByInstanceState(is *InstanceState) workerList {
+ workers := make(workerList, 0, len(is.BlockList))
+ for _, blockState := range is.BlockList {
+ if !blockState.UploadDone {
+ workers = append(workers, &worker{
+ id: blockState.ID,
+ partOffset: blockState.Range.Begin,
+ splitUnit: NewBufioSplitUnit(muer.file, blockState.Range, muer.speedsStat, muer.rateLimit),
+ 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,
+ },
+ uploadDone: true,
+ })
+ }
+ }
+ return workers
+}
diff --git a/internal/file/uploader/multiuploader.go b/internal/file/uploader/multiuploader.go
new file mode 100644
index 0000000..06f5248
--- /dev/null
+++ b/internal/file/uploader/multiuploader.go
@@ -0,0 +1,222 @@
+// 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 uploader
+
+import (
+ "context"
+ "github.com/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/library-go/converter"
+ "github.com/tickstep/library-go/requester"
+ "github.com/tickstep/library-go/requester/rio"
+ "github.com/tickstep/library-go/requester/rio/speeds"
+ "github.com/tickstep/aliyunpan/internal/utils"
+ "sync"
+ "time"
+)
+
+type (
+ // MultiUpload 支持多线程的上传, 可用于断点续传
+ MultiUpload interface {
+ Precreate() (perr error)
+ UploadFile(ctx context.Context, partseq int, partOffset int64, partEnd int64, readerlen64 rio.ReaderLen64) (uploadDone bool, terr error)
+ CommitFile() (cerr error)
+ }
+
+ // MultiUploader 多线程上传
+ MultiUploader struct {
+ onExecuteEvent requester.Event //开始上传事件
+ onSuccessEvent requester.Event //成功上传事件
+ onFinishEvent requester.Event //结束上传事件
+ onCancelEvent requester.Event //取消上传事件
+ onErrorEvent requester.EventOnError //上传出错事件
+ onUploadStatusEvent UploadStatusFunc //上传状态事件
+
+ instanceState *InstanceState
+
+ multiUpload MultiUpload // 上传体接口
+ file rio.ReaderAtLen64 // 上传
+ config *MultiUploaderConfig
+ workers workerList
+ speedsStat *speeds.Speeds
+ rateLimit *speeds.RateLimit
+
+ executeTime time.Time
+ finished chan struct{}
+ canceled chan struct{}
+ closeCanceledOnce sync.Once
+ updateInstanceStateChan chan struct{}
+
+ // 网盘上传参数
+ UploadOpEntity *aliyunpan.CreateFileUploadResult `json:"uploadOpEntity"`
+ }
+
+ // MultiUploaderConfig 多线程上传配置
+ MultiUploaderConfig struct {
+ Parallel int // 上传并发量
+ BlockSize int64 // 上传分块
+ MaxRate int64 // 限制最大上传速度
+ }
+)
+
+// NewMultiUploader 初始化上传
+func NewMultiUploader(multiUpload MultiUpload, file rio.ReaderAtLen64, config *MultiUploaderConfig, uploadOpEntity *aliyunpan.CreateFileUploadResult) *MultiUploader {
+ return &MultiUploader{
+ multiUpload: multiUpload,
+ file: file,
+ config: config,
+ UploadOpEntity: uploadOpEntity,
+ }
+}
+
+// SetInstanceState 设置InstanceState, 断点续传信息
+func (muer *MultiUploader) SetInstanceState(is *InstanceState) {
+ muer.instanceState = is
+}
+
+func (muer *MultiUploader) lazyInit() {
+ if muer.finished == nil {
+ muer.finished = make(chan struct{}, 1)
+ }
+ if muer.canceled == nil {
+ muer.canceled = make(chan struct{})
+ }
+ if muer.updateInstanceStateChan == nil {
+ muer.updateInstanceStateChan = make(chan struct{}, 1)
+ }
+ if muer.config == nil {
+ muer.config = &MultiUploaderConfig{}
+ }
+ if muer.config.Parallel <= 0 {
+ muer.config.Parallel = 4
+ }
+ if muer.config.BlockSize <= 0 {
+ muer.config.BlockSize = 1 * converter.GB
+ }
+ if muer.speedsStat == nil {
+ muer.speedsStat = &speeds.Speeds{}
+ }
+}
+
+func (muer *MultiUploader) check() {
+ if muer.file == nil {
+ panic("file is nil")
+ }
+ if muer.multiUpload == nil {
+ panic("multiUpload is nil")
+ }
+ if muer.UploadOpEntity == nil {
+ panic("upload parameter is nil")
+ }
+}
+
+// Execute 执行上传
+func (muer *MultiUploader) Execute() {
+ muer.check()
+ muer.lazyInit()
+
+ // 初始化限速
+ if muer.config.MaxRate > 0 {
+ muer.rateLimit = speeds.NewRateLimit(muer.config.MaxRate)
+ defer muer.rateLimit.Stop()
+ }
+
+ // 分配任务
+ if muer.instanceState != nil {
+ muer.workers = muer.getWorkerListByInstanceState(muer.instanceState)
+ uploaderVerbose.Infof("upload task CREATED from instance state\n")
+ } else {
+ muer.workers = muer.getWorkerListByInstanceState(&InstanceState{
+ BlockList: SplitBlock(muer.file.Len(), muer.config.BlockSize),
+ })
+
+ uploaderVerbose.Infof("upload task CREATED: block size: %d, num: %d\n", muer.config.BlockSize, len(muer.workers))
+ }
+
+ // 开始上传
+ muer.executeTime = time.Now()
+ utils.Trigger(muer.onExecuteEvent)
+
+ // 通知更新
+ if muer.updateInstanceStateChan != nil {
+ muer.updateInstanceStateChan <- struct{}{}
+ }
+ muer.uploadStatusEvent()
+
+ err := muer.upload()
+
+ // 完成
+ muer.finished <- struct{}{}
+ if err != nil {
+ if err == context.Canceled {
+ if muer.onCancelEvent != nil {
+ muer.onCancelEvent()
+ }
+ } else if muer.onErrorEvent != nil {
+ muer.onErrorEvent(err)
+ }
+ } else {
+ utils.TriggerOnSync(muer.onSuccessEvent)
+ }
+ utils.TriggerOnSync(muer.onFinishEvent)
+}
+
+// InstanceState 返回断点续传信息
+func (muer *MultiUploader) InstanceState() *InstanceState {
+ blockStates := make([]*BlockState, 0, len(muer.workers))
+ for _, wer := range muer.workers {
+ blockStates = append(blockStates, &BlockState{
+ ID: wer.id,
+ Range: wer.splitUnit.Range(),
+ UploadDone: wer.uploadDone,
+ })
+ }
+ return &InstanceState{
+ BlockList: blockStates,
+ }
+}
+
+// Cancel 取消上传
+func (muer *MultiUploader) Cancel() {
+ close(muer.canceled)
+}
+
+//OnExecute 设置开始上传事件
+func (muer *MultiUploader) OnExecute(onExecuteEvent requester.Event) {
+ muer.onExecuteEvent = onExecuteEvent
+}
+
+//OnSuccess 设置成功上传事件
+func (muer *MultiUploader) OnSuccess(onSuccessEvent requester.Event) {
+ muer.onSuccessEvent = onSuccessEvent
+}
+
+//OnFinish 设置结束上传事件
+func (muer *MultiUploader) OnFinish(onFinishEvent requester.Event) {
+ muer.onFinishEvent = onFinishEvent
+}
+
+//OnCancel 设置取消上传事件
+func (muer *MultiUploader) OnCancel(onCancelEvent requester.Event) {
+ muer.onCancelEvent = onCancelEvent
+}
+
+//OnError 设置上传发生错误事件
+func (muer *MultiUploader) OnError(onErrorEvent requester.EventOnError) {
+ muer.onErrorEvent = onErrorEvent
+}
+
+//OnUploadStatusEvent 设置上传状态事件
+func (muer *MultiUploader) OnUploadStatusEvent(f UploadStatusFunc) {
+ muer.onUploadStatusEvent = f
+}
diff --git a/internal/file/uploader/multiworker.go b/internal/file/uploader/multiworker.go
new file mode 100644
index 0000000..025f09d
--- /dev/null
+++ b/internal/file/uploader/multiworker.go
@@ -0,0 +1,167 @@
+// 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 uploader
+
+import (
+ "context"
+ "github.com/tickstep/aliyunpan/internal/waitgroup"
+ "github.com/oleiade/lane"
+ "os"
+ "strconv"
+)
+
+type (
+ worker struct {
+ id int
+ partOffset int64
+ splitUnit SplitUnit
+ uploadDone bool
+ }
+
+ workerList []*worker
+)
+
+func (werl *workerList) Readed() int64 {
+ var readed int64
+ for _, wer := range *werl {
+ readed += wer.splitUnit.Readed()
+ }
+ return readed
+}
+
+func (muer *MultiUploader) upload() (uperr error) {
+ err := muer.multiUpload.Precreate()
+ if err != nil {
+ return err
+ }
+
+ var (
+ uploadDeque = lane.NewDeque()
+ )
+
+ // 加入队列
+ // 一个worker对应一个分片
+ // 这里跳过已经上传成功的分片
+ for _, wer := range muer.workers {
+ if !wer.uploadDone {
+ uploadDeque.Append(wer)
+ }
+ }
+
+ for {
+ // 阿里云盘只支持分片按顺序上传,这里正常应该是parallel = 1
+ wg := waitgroup.NewWaitGroup(muer.config.Parallel)
+ for {
+ e := uploadDeque.Shift()
+ if e == nil { // 任务为空
+ break
+ }
+
+ wer := e.(*worker)
+ wg.AddDelta()
+ go func() {
+ defer wg.Done()
+
+ var (
+ ctx, cancel = context.WithCancel(context.Background())
+ doneChan = make(chan struct{})
+ uploadDone bool
+ terr error
+ )
+ go func() {
+ if !wer.uploadDone {
+ uploaderVerbose.Info("begin to upload part: " + strconv.Itoa(wer.id))
+ uploadDone, terr = muer.multiUpload.UploadFile(ctx, int(wer.id), wer.partOffset, wer.splitUnit.Range().End, wer.splitUnit)
+ } else {
+ uploadDone = true
+ }
+ close(doneChan)
+ }()
+ select {
+ case <-muer.canceled:
+ cancel()
+ return
+ case <-doneChan:
+ // continue
+ uploaderVerbose.Info("multiUpload worker upload file done")
+ }
+ cancel()
+ if terr != nil {
+ if me, ok := terr.(*MultiError); ok {
+ if me.Terminated { // 终止
+ muer.closeCanceledOnce.Do(func() { // 只关闭一次
+ close(muer.canceled)
+ })
+ uperr = me.Err
+ return
+ } else if me.NeedStartOver {
+ uploaderVerbose.Warnf("upload start over: %d\n", wer.id)
+ // 从头开始上传
+ uploadDeque = lane.NewDeque()
+ for _,item := range muer.workers {
+ item.uploadDone = false
+ uploadDeque.Append(item)
+ }
+ return
+ }
+ }
+
+ uploaderVerbose.Warnf("upload err: %s, id: %d\n", terr, wer.id)
+ wer.splitUnit.Seek(0, os.SEEK_SET)
+ uploadDeque.Append(wer)
+ return
+ }
+ wer.uploadDone = uploadDone
+
+ // 通知更新
+ if muer.updateInstanceStateChan != nil && len(muer.updateInstanceStateChan) < cap(muer.updateInstanceStateChan) {
+ muer.updateInstanceStateChan <- struct{}{}
+ }
+ }()
+ }
+ wg.Wait()
+
+ // 没有任务了
+ if uploadDeque.Size() == 0 {
+ break
+ }
+ }
+
+ select {
+ case <-muer.canceled:
+ if uperr != nil {
+ return uperr
+ }
+ return context.Canceled
+ default:
+ }
+
+ // upload file commit
+ // 检测是否全部分片上传成功
+ allSuccess := true
+ for _, wer := range muer.workers {
+ allSuccess = allSuccess && wer.uploadDone
+ }
+ if allSuccess {
+ e := muer.multiUpload.CommitFile()
+ if e != nil {
+ uploaderVerbose.Warn("upload file commit failed: " + e.Error())
+ return e
+ }
+ } else {
+ uploaderVerbose.Warn("upload file not all success: " + muer.UploadOpEntity.FileId)
+ }
+
+ return
+}
diff --git a/internal/file/uploader/readed.go b/internal/file/uploader/readed.go
new file mode 100644
index 0000000..822db5d
--- /dev/null
+++ b/internal/file/uploader/readed.go
@@ -0,0 +1,50 @@
+// 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 uploader
+
+import (
+ "github.com/tickstep/library-go/requester/rio"
+ "sync/atomic"
+)
+
+type (
+ // Readed64 增加获取已读取数据量, 用于统计速度
+ Readed64 interface {
+ rio.ReaderLen64
+ Readed() int64
+ }
+
+ readed64 struct {
+ readed int64
+ rio.ReaderLen64
+ }
+)
+
+// NewReaded64 实现Readed64接口
+func NewReaded64(rl rio.ReaderLen64) Readed64 {
+ return &readed64{
+ readed: 0,
+ ReaderLen64: rl,
+ }
+}
+
+func (r64 *readed64) Read(p []byte) (n int, err error) {
+ n, err = r64.ReaderLen64.Read(p)
+ atomic.AddInt64(&r64.readed, int64(n))
+ return n, err
+}
+
+func (r64 *readed64) Readed() int64 {
+ return atomic.LoadInt64(&r64.readed)
+}
diff --git a/internal/file/uploader/status.go b/internal/file/uploader/status.go
new file mode 100644
index 0000000..f275004
--- /dev/null
+++ b/internal/file/uploader/status.go
@@ -0,0 +1,115 @@
+// 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 uploader
+
+import (
+ "time"
+)
+
+type (
+ // Status 上传状态接口
+ Status interface {
+ TotalSize() int64 // 总大小
+ Uploaded() int64 // 已上传数据
+ SpeedsPerSecond() int64 // 每秒的上传速度
+ TimeElapsed() time.Duration // 上传时间
+ }
+
+ // UploadStatus 上传状态
+ UploadStatus struct {
+ totalSize int64 // 总大小
+ uploaded int64 // 已上传数据
+ speedsPerSecond int64 // 每秒的上传速度
+ timeElapsed time.Duration // 上传时间
+ }
+
+ UploadStatusFunc func(status Status, updateChan <-chan struct{})
+)
+
+// TotalSize 返回总大小
+func (us *UploadStatus) TotalSize() int64 {
+ return us.totalSize
+}
+
+// Uploaded 返回已上传数据
+func (us *UploadStatus) Uploaded() int64 {
+ return us.uploaded
+}
+
+// SpeedsPerSecond 返回每秒的上传速度
+func (us *UploadStatus) SpeedsPerSecond() int64 {
+ return us.speedsPerSecond
+}
+
+// TimeElapsed 返回上传时间
+func (us *UploadStatus) TimeElapsed() time.Duration {
+ return us.timeElapsed
+}
+
+// GetStatusChan 获取上传状态
+func (u *Uploader) GetStatusChan() <-chan Status {
+ c := make(chan Status)
+
+ go func() {
+ for {
+ select {
+ case <-u.finished:
+ close(c)
+ return
+ default:
+ if !u.executed {
+ time.Sleep(1 * time.Second)
+ continue
+ }
+
+ old := u.readed64.Readed()
+ time.Sleep(1 * time.Second) // 每秒统计
+
+ readed := u.readed64.Readed()
+ c <- &UploadStatus{
+ totalSize: u.readed64.Len(),
+ uploaded: readed,
+ speedsPerSecond: readed - old,
+ timeElapsed: time.Since(u.executeTime) / 1e7 * 1e7,
+ }
+ }
+ }
+ }()
+ return c
+}
+
+func (muer *MultiUploader) uploadStatusEvent() {
+ if muer.onUploadStatusEvent == nil {
+ return
+ }
+
+ go func() {
+ ticker := time.NewTicker(1 * time.Second) // 每秒统计
+ defer ticker.Stop()
+ for {
+ select {
+ case <-muer.finished:
+ return
+ case <-ticker.C:
+ readed := muer.workers.Readed()
+ muer.onUploadStatusEvent(&UploadStatus{
+ totalSize: muer.file.Len(),
+ uploaded: readed,
+ speedsPerSecond: muer.speedsStat.GetSpeeds(),
+ timeElapsed: time.Since(muer.executeTime) / 1e8 * 1e8,
+ }, muer.updateInstanceStateChan)
+ }
+ }
+ }()
+}
diff --git a/internal/file/uploader/uploader.go b/internal/file/uploader/uploader.go
new file mode 100644
index 0000000..cb7fce9
--- /dev/null
+++ b/internal/file/uploader/uploader.go
@@ -0,0 +1,136 @@
+// 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 uploader
+
+import (
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/tickstep/aliyunpan/internal/utils"
+ "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"
+ "net/http"
+ "time"
+)
+
+const (
+ // BufioReadSize bufio 缓冲区大小, 用于上传时读取文件
+ BufioReadSize = int(64 * converter.KB) // 64KB
+)
+
+type (
+ //CheckFunc 上传完成的检测函数
+ CheckFunc func(resp *http.Response, uploadErr error)
+
+ // Uploader 上传
+ Uploader struct {
+ url string // 上传地址
+ readed64 Readed64 // 要上传的对象
+ contentType string
+
+ client *requester.HTTPClient
+
+ executeTime time.Time
+ executed bool
+ finished chan struct{}
+
+ checkFunc CheckFunc
+ onExecute func()
+ onFinish func()
+ }
+)
+
+var (
+ uploaderVerbose = logger.New("UPLOADER", config.EnvVerbose)
+)
+
+// NewUploader 返回 uploader 对象, url: 上传地址, readerlen64: 实现 rio.ReaderLen64 接口的对象, 例如文件
+func NewUploader(url string, readerlen64 rio.ReaderLen64) (uploader *Uploader) {
+ uploader = &Uploader{
+ url: url,
+ readed64: NewReaded64(readerlen64),
+ }
+
+ return
+}
+
+func (u *Uploader) lazyInit() {
+ if u.finished == nil {
+ u.finished = make(chan struct{})
+ }
+ if u.client == nil {
+ u.client = requester.NewHTTPClient()
+ }
+ u.client.SetTimeout(0)
+ u.client.SetResponseHeaderTimeout(0)
+}
+
+// SetClient 设置http客户端
+func (u *Uploader) SetClient(c *requester.HTTPClient) {
+ u.client = c
+}
+
+//SetContentType 设置Content-Type
+func (u *Uploader) SetContentType(contentType string) {
+ u.contentType = contentType
+}
+
+//SetCheckFunc 设置上传完成的检测函数
+func (u *Uploader) SetCheckFunc(checkFunc CheckFunc) {
+ u.checkFunc = checkFunc
+}
+
+// Execute 执行上传, 收到返回值信号则为上传结束
+func (u *Uploader) Execute() {
+ utils.Trigger(u.onExecute)
+
+ // 开始上传
+ u.executeTime = time.Now()
+ u.executed = true
+ resp, _, err := u.execute()
+
+ // 上传结束
+ close(u.finished)
+
+ if u.checkFunc != nil {
+ u.checkFunc(resp, err)
+ }
+
+ utils.Trigger(u.onFinish) // 触发上传结束的事件
+}
+
+func (u *Uploader) execute() (resp *http.Response, code int, err error) {
+ u.lazyInit()
+ header := map[string]string{}
+ if u.contentType != "" {
+ header["Content-Type"] = u.contentType
+ }
+
+ resp, err = u.client.Req(http.MethodPost, u.url, u.readed64, header)
+ if err != nil {
+ return nil, 2, err
+ }
+
+ return resp, 0, nil
+}
+
+// OnExecute 任务开始时触发的事件
+func (u *Uploader) OnExecute(fn func()) {
+ u.onExecute = fn
+}
+
+// OnFinish 任务完成时触发的事件
+func (u *Uploader) OnFinish(fn func()) {
+ u.onFinish = fn
+}
diff --git a/internal/functions/common.go b/internal/functions/common.go
new file mode 100644
index 0000000..b555241
--- /dev/null
+++ b/internal/functions/common.go
@@ -0,0 +1,24 @@
+// 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 functions
+
+import "time"
+
+// RetryWait 失败重试等待事件
+func RetryWait(retry int) time.Duration {
+ if retry < 3 {
+ return 2 * time.Duration(retry) * time.Second
+ }
+ return 6 * time.Second
+}
diff --git a/internal/functions/pandownload/download_statistic.go b/internal/functions/pandownload/download_statistic.go
new file mode 100644
index 0000000..abb1101
--- /dev/null
+++ b/internal/functions/pandownload/download_statistic.go
@@ -0,0 +1,24 @@
+// 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 pandownload
+
+import (
+ "github.com/tickstep/aliyunpan/internal/functions"
+)
+
+type (
+ DownloadStatistic struct {
+ functions.Statistic
+ }
+)
diff --git a/internal/functions/pandownload/download_task_unit.go b/internal/functions/pandownload/download_task_unit.go
new file mode 100644
index 0000000..18a14b8
--- /dev/null
+++ b/internal/functions/pandownload/download_task_unit.go
@@ -0,0 +1,424 @@
+// 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 pandownload
+
+import (
+ "errors"
+ "fmt"
+ "github.com/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
+ "github.com/tickstep/aliyunpan/cmder/cmdtable"
+ "github.com/tickstep/aliyunpan/internal/file/downloader"
+ "github.com/tickstep/aliyunpan/internal/functions"
+ "github.com/tickstep/aliyunpan/internal/taskframework"
+ "github.com/tickstep/library-go/converter"
+ "github.com/tickstep/library-go/logger"
+ "github.com/tickstep/library-go/requester"
+ "github.com/tickstep/aliyunpan/library/requester/transfer"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type (
+ // DownloadTaskUnit 下载的任务单元
+ DownloadTaskUnit struct {
+ taskInfo *taskframework.TaskInfo // 任务信息
+
+ Cfg *downloader.Config
+ PanClient *aliyunpan.PanClient
+ ParentTaskExecutor *taskframework.TaskExecutor
+
+ DownloadStatistic *DownloadStatistic // 下载统计
+
+ // 可选项
+ VerbosePrinter *logger.CmdVerbose
+ PrintFormat string
+ IsPrintStatus bool // 是否输出各个下载线程的详细信息
+ IsExecutedPermission bool // 下载成功后是否加上执行权限
+ IsOverwrite bool // 是否覆盖已存在的文件
+ NoCheck bool // 不校验文件
+
+ FilePanPath string // 要下载的网盘文件路径
+ SavePath string // 文件保存在本地的路径
+ OriginSaveRootPath string // 文件保存在本地的根目录路径
+ DriveId string
+
+ fileInfo *aliyunpan.FileEntity // 文件或目录详情
+ }
+)
+
+const (
+ // DefaultPrintFormat 默认的下载进度输出格式
+ DefaultPrintFormat = "\r[%s] ↓ %s/%s %s/s in %s, left %s ............"
+ //DownloadSuffix 文件下载后缀
+ DownloadSuffix = ".cloudpan189-downloading"
+ //StrDownloadInitError 初始化下载发生错误
+ StrDownloadInitError = "初始化下载发生错误"
+ // StrDownloadFailed 下载文件失败
+ StrDownloadFailed = "下载文件失败"
+ // StrDownloadGetDlinkFailed 获取下载链接失败
+ StrDownloadGetDlinkFailed = "获取下载链接失败"
+ // StrDownloadChecksumFailed 检测文件有效性失败
+ StrDownloadChecksumFailed = "检测文件有效性失败"
+ // DefaultDownloadMaxRetry 默认下载失败最大重试次数
+ DefaultDownloadMaxRetry = 3
+)
+
+func (dtu *DownloadTaskUnit) SetTaskInfo(info *taskframework.TaskInfo) {
+ dtu.taskInfo = info
+}
+
+func (dtu *DownloadTaskUnit) verboseInfof(format string, a ...interface{}) {
+ if dtu.VerbosePrinter != nil {
+ dtu.VerbosePrinter.Infof(format, a...)
+ }
+}
+
+// download 执行下载
+func (dtu *DownloadTaskUnit) download() (err error) {
+ var (
+ writer downloader.Writer
+ file *os.File
+ )
+
+ dtu.Cfg.InstanceStatePath = dtu.SavePath + DownloadSuffix
+
+ // 创建下载的目录
+ // 获取SavePath所在的目录
+ dir := filepath.Dir(dtu.SavePath)
+ fileInfo, err := os.Stat(dir)
+ if err != nil {
+ // 目录不存在, 创建
+ err = os.MkdirAll(dir, 0777)
+ if err != nil {
+ return err
+ }
+ } else if !fileInfo.IsDir() {
+ // SavePath所在的目录不是目录
+ return fmt.Errorf("%s, path %s: not a directory", StrDownloadInitError, dir)
+ }
+
+ // 打开文件
+ writer, file, err = downloader.NewDownloaderWriterByFilename(dtu.SavePath, os.O_CREATE|os.O_WRONLY, 0666)
+ if err != nil {
+ return fmt.Errorf("%s, %s", StrDownloadInitError, err)
+ }
+ defer file.Close()
+
+ der := downloader.NewDownloader(writer, dtu.Cfg, dtu.PanClient)
+ der.SetFileInfo(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)) {
+ // 这里可能会下载结束了, 还会输出内容
+ builder := &strings.Builder{}
+ if dtu.IsPrintStatus {
+ // 输出所有的worker状态
+ var (
+ tb = cmdtable.NewTable(builder)
+ )
+ tb.SetHeader([]string{"#", "status", "range", "left", "speeds", "error"})
+ workersCallback(func(key int, worker *downloader.Worker) bool {
+ wrange := worker.GetRange()
+ tb.Append([]string{fmt.Sprint(worker.ID()), worker.GetStatus().StatusText(), wrange.ShowDetails(), strconv.FormatInt(wrange.Len(), 10), strconv.FormatInt(worker.GetSpeedsPerSecond(), 10), fmt.Sprint(worker.Err())})
+ return true
+ })
+
+ // 先空两行
+ builder.WriteString("\n\n")
+ tb.Render()
+ }
+
+ // 如果下载速度为0, 剩余下载时间未知, 则用 - 代替
+ var leftStr string
+ left := status.TimeLeft()
+ if left < 0 {
+ leftStr = "-"
+ } else {
+ leftStr = left.String()
+ }
+
+ if dtu.Cfg.ShowProgress {
+ fmt.Fprintf(builder, dtu.PrintFormat, dtu.taskInfo.Id(),
+ converter.ConvertFileSize(status.Downloaded(), 2),
+ converter.ConvertFileSize(status.TotalSize(), 2),
+ converter.ConvertFileSize(status.SpeedsPerSecond(), 2),
+ status.TimeElapsed()/1e7*1e7, leftStr,
+ )
+ }
+
+ if !isComplete {
+ // 如果未完成下载, 就输出
+ fmt.Print(builder.String())
+ }
+ })
+
+ der.OnExecute(func() {
+ fmt.Printf("[%s] 下载开始\n\n", dtu.taskInfo.Id())
+ })
+
+ err = der.Execute()
+ isComplete = true
+ fmt.Print("\n")
+
+ if err != nil {
+ // check zero size file
+ if err == downloader.ErrNoWokers && dtu.fileInfo.FileSize == 0 {
+ // success for 0 size file
+ dtu.verboseInfof("download success for zero size file")
+ } else {
+ // 下载发生错误
+ // 下载失败, 删去空文件
+ if info, infoErr := file.Stat(); infoErr == nil {
+ if info.Size() == 0 {
+ // 空文件, 应该删除
+ dtu.verboseInfof("[%s] remove empty file: %s\n", dtu.taskInfo.Id(), dtu.SavePath)
+ removeErr := os.Remove(dtu.SavePath)
+ if removeErr != nil {
+ dtu.verboseInfof("[%s] remove file error: %s\n", dtu.taskInfo.Id(), removeErr)
+ }
+ }
+ }
+ return err
+ }
+ }
+
+ // 下载成功
+ if dtu.IsExecutedPermission {
+ err = file.Chmod(0766)
+ if err != nil {
+ fmt.Printf("[%s] 警告, 加执行权限错误: %s\n", dtu.taskInfo.Id(), err)
+ }
+ }
+ fmt.Printf("[%s] 下载完成, 保存位置: %s\n", dtu.taskInfo.Id(), dtu.SavePath)
+
+ return nil
+}
+
+//panHTTPClient 获取包含特定User-Agent的HTTPClient
+func (dtu *DownloadTaskUnit) panHTTPClient() (client *requester.HTTPClient) {
+ client = requester.NewHTTPClient()
+ client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 10 {
+ return errors.New("stopped after 10 redirects")
+ }
+ return nil
+ }
+ client.SetTimeout(20 * time.Minute)
+ client.SetKeepAlive(true)
+ return client
+}
+
+func (dtu *DownloadTaskUnit) handleError(result *taskframework.TaskUnitRunResult) {
+ switch value := result.Err.(type) {
+ case *apierror.ApiError:
+ switch value.ErrCode() {
+ case apierror.ApiCodeFileNotFoundCode:
+ result.NeedRetry = false
+ break
+ default:
+ result.NeedRetry = true
+ }
+ case *os.PathError:
+ // 系统级别的错误, 可能是权限问题
+ result.NeedRetry = false
+ default:
+ // 其他错误, 需要重试
+ result.NeedRetry = true
+ }
+}
+
+//checkFileValid 检测文件有效性
+func (dtu *DownloadTaskUnit) checkFileValid(result *taskframework.TaskUnitRunResult) (ok bool) {
+ if dtu.NoCheck {
+ // 不检测文件有效性
+ return
+ }
+
+ if dtu.fileInfo.FileSize >= 128*converter.MB {
+ // 大文件, 输出一句提示消息
+ fmt.Printf("[%s] 开始检验文件有效性, 请稍候...\n", dtu.taskInfo.Id())
+ }
+
+ // 就在这里处理校验出错
+ err := CheckFileValid(dtu.SavePath, dtu.fileInfo)
+ if err != nil {
+ result.ResultMessage = StrDownloadChecksumFailed
+ result.Err = err
+ switch err {
+ case ErrDownloadNotSupportChecksum:
+ // 文件不支持校验
+ result.ResultMessage = "检验文件有效性"
+ result.Err = err
+ fmt.Printf("[%s] 检验文件有效性: %s\n", dtu.taskInfo.Id(), err)
+ return true
+ case ErrDownloadFileBanned:
+ // 违规文件
+ result.NeedRetry = false
+ return
+ case ErrDownloadChecksumFailed:
+ // 校验失败, 需要重新下载
+ result.NeedRetry = true
+ // 设置允许覆盖
+ dtu.IsOverwrite = true
+ return
+ default:
+ result.NeedRetry = false
+ return
+ }
+ }
+
+ fmt.Printf("[%s] 检验文件有效性成功: %s\n", dtu.taskInfo.Id(), dtu.SavePath)
+ return true
+}
+
+func (dtu *DownloadTaskUnit) OnRetry(lastRunResult *taskframework.TaskUnitRunResult) {
+ // 输出错误信息
+ if lastRunResult.Err == nil {
+ // result中不包含Err, 忽略输出
+ fmt.Printf("[%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())
+}
+
+func (dtu *DownloadTaskUnit) OnSuccess(lastRunResult *taskframework.TaskUnitRunResult) {
+}
+
+func (dtu *DownloadTaskUnit) OnFailed(lastRunResult *taskframework.TaskUnitRunResult) {
+ // 失败
+ if lastRunResult.Err == nil {
+ // result中不包含Err, 忽略输出
+ fmt.Printf("[%s] %s\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage)
+ return
+ }
+ fmt.Printf("[%s] %s, %s\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage, lastRunResult.Err)
+}
+
+func (dtu *DownloadTaskUnit) OnComplete(lastRunResult *taskframework.TaskUnitRunResult) {
+}
+
+func (dtu *DownloadTaskUnit) RetryWait() time.Duration {
+ return functions.RetryWait(dtu.taskInfo.Retry())
+}
+
+func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) {
+ result = &taskframework.TaskUnitRunResult{}
+ // 获取文件信息
+ var apierr *apierror.ApiError
+ if dtu.fileInfo == nil || dtu.taskInfo.Retry() > 0 {
+ // 没有获取文件信息
+ // 如果是动态添加的下载任务, 是会写入文件信息的
+ // 如果该任务重试过, 则应该再获取一次文件信息
+ dtu.fileInfo, apierr = dtu.PanClient.FileInfoByPath(dtu.DriveId, dtu.FilePanPath)
+ if apierr != nil {
+ // 如果不是未登录或文件不存在, 则不重试
+ result.ResultMessage = "获取下载路径信息错误"
+ result.Err = apierr
+ dtu.handleError(result)
+ return
+ }
+ }
+
+ // 输出文件信息
+ fmt.Print("\n")
+ fmt.Printf("[%s] ----\n%s\n", dtu.taskInfo.Id(), dtu.fileInfo.String())
+
+ // 如果是一个目录, 将子文件和子目录加入队列
+ if dtu.fileInfo.IsFolder() {
+ _, err := os.Stat(dtu.SavePath)
+ if err != nil && !os.IsExist(err) {
+ os.MkdirAll(dtu.SavePath, 0777) // 首先在本地创建目录, 保证空目录也能被保存
+ }
+
+ // 获取该目录下的文件列表
+ fileList := dtu.PanClient.FilesDirectoriesRecurseList(dtu.DriveId, dtu.FilePanPath, nil)
+ if fileList == nil {
+ result.ResultMessage = "获取目录信息错误"
+ result.Err = err
+ result.NeedRetry = true
+ return
+ }
+
+ for k := range fileList {
+ if fileList[k].IsFolder() {
+ continue
+ }
+ // 添加子任务
+ subUnit := *dtu
+ newCfg := *dtu.Cfg
+ subUnit.Cfg = &newCfg
+ subUnit.fileInfo = fileList[k] // 保存文件信息
+ 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)
+ }
+
+ result.Succeed = true // 执行成功
+ return
+ }
+
+ fmt.Printf("[%s] 准备下载: %s\n", dtu.taskInfo.Id(), dtu.FilePanPath)
+
+ if !dtu.IsOverwrite && FileExist(dtu.SavePath) {
+ fmt.Printf("[%s] 文件已经存在: %s, 跳过...\n", dtu.taskInfo.Id(), dtu.SavePath)
+ result.Succeed = true // 执行成功
+ return
+ }
+
+ fmt.Printf("[%s] 将会下载到路径: %s\n\n", dtu.taskInfo.Id(), dtu.SavePath)
+
+ var ok bool
+ er := dtu.download()
+
+ if er != nil {
+ // 以上执行不成功, 返回
+ result.ResultMessage = StrDownloadFailed
+ result.Err = er
+ dtu.handleError(result)
+ return result
+ }
+
+ // 检测文件有效性
+ ok = dtu.checkFileValid(result)
+ if !ok {
+ // 校验不成功, 返回结果
+ return result
+ }
+
+ // 统计下载
+ dtu.DownloadStatistic.AddTotalSize(dtu.fileInfo.FileSize)
+ // 下载成功
+ result.Succeed = true
+ return
+}
diff --git a/internal/functions/pandownload/errors.go b/internal/functions/pandownload/errors.go
new file mode 100644
index 0000000..851026d
--- /dev/null
+++ b/internal/functions/pandownload/errors.go
@@ -0,0 +1,29 @@
+// 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 pandownload
+
+import "errors"
+
+var (
+ // ErrDownloadNotSupportChecksum 文件不支持校验
+ ErrDownloadNotSupportChecksum = errors.New("该文件不支持校验")
+ // ErrDownloadChecksumFailed 文件校验失败
+ ErrDownloadChecksumFailed = errors.New("该文件校验失败, 文件md5值与服务器记录的不匹配")
+ // ErrDownloadFileBanned 违规文件
+ ErrDownloadFileBanned = errors.New("该文件可能是违规文件, 不支持校验")
+ // ErrDlinkNotFound 未取得下载链接
+ ErrDlinkNotFound = errors.New("未取得下载链接")
+ // ErrShareInfoNotFound 未在已分享列表中找到分享信息
+ ErrShareInfoNotFound = errors.New("未在已分享列表中找到分享信息")
+)
diff --git a/internal/functions/pandownload/utils.go b/internal/functions/pandownload/utils.go
new file mode 100644
index 0000000..19be120
--- /dev/null
+++ b/internal/functions/pandownload/utils.go
@@ -0,0 +1,42 @@
+// 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 pandownload
+
+import (
+ "github.com/tickstep/aliyunpan-api/aliyunpan"
+ "os"
+)
+
+// CheckFileValid 检测文件有效性
+func CheckFileValid(filePath string, fileInfo *aliyunpan.FileEntity) error {
+ // 检查MD5
+ // 检查文件大小
+ // 检查digest签名
+ return nil
+}
+
+// FileExist 检查文件是否存在,
+// 只有当文件存在, 文件大小不为0或断点续传文件不存在时, 才判断为存在
+func FileExist(path string) bool {
+ if info, err := os.Stat(path); err == nil {
+ if info.Size() == 0 {
+ return false
+ }
+ if _, err = os.Stat(path + DownloadSuffix); err != nil {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/internal/functions/panupload/sync_database.go b/internal/functions/panupload/sync_database.go
new file mode 100644
index 0000000..93ac6a0
--- /dev/null
+++ b/internal/functions/panupload/sync_database.go
@@ -0,0 +1,46 @@
+// Copyright (c) 2020 tickstep & chenall
+//
+// 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 panupload
+
+type SyncDb interface {
+ //读取记录,返回值不会是nil
+ Get(key string) (ufm *UploadedFileMeta)
+ //删除单条记录
+ Del(key string) error
+ //根据前辍删除数据库记录,比如删除一个目录时可以连同子目录一起删除
+ DelWithPrefix(prefix string) error
+ Put(key string, value *UploadedFileMeta) error
+ Close() error
+ //读取数据库指定路径前辍的第一条记录(也作为循环获取的初始化,配置Next函数使用)
+ First(prefix string) (*UploadedFileMeta, error)
+ //获取指定路径前辍的的下一条记录
+ Next(prefix string) (*UploadedFileMeta, error)
+ //是否进行自动数据库清理
+ //注: 清理规则,所有以 prefix 前辍开头并且未更新的记录都将被清理,只有在必要的时候才开启这个功能。
+ AutoClean(prefix string, cleanFlag bool)
+}
+
+type autoCleanInfo struct {
+ PreFix string
+ SyncTime int64
+}
+
+func OpenSyncDb(file string, bucket string) (SyncDb, error) {
+ return openBoltDb(file, bucket)
+}
+
+type dbTableField struct {
+ Path string
+ Data []byte
+}
diff --git a/internal/functions/panupload/sync_database_bolt.go b/internal/functions/panupload/sync_database_bolt.go
new file mode 100644
index 0000000..b7a69e0
--- /dev/null
+++ b/internal/functions/panupload/sync_database_bolt.go
@@ -0,0 +1,185 @@
+// Copyright (c) 2020 tickstep & chenall
+//
+// 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 panupload
+
+import (
+ "bytes"
+ "fmt"
+ jsoniter "github.com/json-iterator/go"
+ "github.com/tickstep/bolt"
+ "github.com/tickstep/library-go/logger"
+ "time"
+)
+
+type boltDB struct {
+ db *bolt.DB
+ bucket string
+ next map[string]*boltDBScan
+ cleanInfo *autoCleanInfo
+}
+
+type boltDBScan struct {
+ entries []*boltKV
+ off int
+ size int
+}
+
+type boltKV struct {
+ k []byte
+ v []byte
+}
+
+func openBoltDb(file string, bucket string) (SyncDb, error) {
+ db, err := bolt.Open(file + "_bolt.db", 0600, &bolt.Options{Timeout: 5 * time.Second})
+
+ if err != nil {
+ return nil, err
+ }
+ logger.Verboseln("open boltDB ok")
+ return &boltDB{db: db, bucket: bucket, next: make(map[string]*boltDBScan)}, nil
+}
+
+func (db *boltDB) Get(key string) (data *UploadedFileMeta) {
+ data = &UploadedFileMeta{Path: key}
+ db.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte(db.bucket))
+ if b == nil {
+ return nil
+ }
+ v := b.Get([]byte(key))
+ return jsoniter.Unmarshal(v, data)
+ })
+
+ return data
+}
+
+func (db *boltDB) Del(key string) error {
+ return db.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte(db.bucket))
+ if b == nil {
+ return nil
+ }
+ return b.Delete([]byte(key))
+ })
+}
+
+func (db *boltDB) AutoClean(prefix string, cleanFlag bool) {
+ if !cleanFlag {
+ db.cleanInfo = nil
+ } else if db.cleanInfo == nil {
+ db.cleanInfo = &autoCleanInfo{
+ PreFix: prefix,
+ SyncTime: time.Now().Unix(),
+ }
+ }
+}
+
+func (db *boltDB) clean() (count uint) {
+ for ufm, err := db.First(db.cleanInfo.PreFix); err == nil; ufm, err = db.Next(db.cleanInfo.PreFix) {
+ if ufm.LastSyncTime != db.cleanInfo.SyncTime {
+ db.DelWithPrefix(ufm.Path)
+ }
+ }
+ return
+}
+
+func (db *boltDB) DelWithPrefix(prefix string) error {
+ return db.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte(db.bucket))
+ if b == nil {
+ return nil
+ }
+ c := b.Cursor()
+ for k, _ := c.Seek([]byte(prefix)); k != nil && bytes.HasPrefix(k, []byte(prefix)); k, _ = c.Next() {
+ b.Delete(k)
+ }
+ return nil
+ })
+}
+
+func (db *boltDB) First(prefix string) (*UploadedFileMeta, error) {
+ db.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte(db.bucket))
+ if b == nil {
+ return nil
+ }
+ c := b.Cursor()
+ db.next[prefix] = &boltDBScan{
+ entries: []*boltKV{},
+ off: 0,
+ size: 0,
+ }
+ for k, v := c.Seek([]byte(prefix)); k != nil && bytes.HasPrefix(k, []byte(prefix)); k, v = c.Next() {
+ //fmt.Printf("key=%s, value=%s\n", k, v)
+ if len(k) > 0 {
+ db.next[prefix].entries = append(db.next[prefix].entries, &boltKV{
+ k: k,
+ v: v,
+ })
+ }
+ }
+ db.next[prefix].off = 0
+ db.next[prefix].size = len(db.next[prefix].entries)
+ return nil
+ })
+ return db.Next(prefix)
+}
+
+func (db *boltDB) Next(prefix string) (*UploadedFileMeta, error) {
+ data := &UploadedFileMeta{}
+ if _,ok := db.next[prefix]; ok {
+ if db.next[prefix].off >= db.next[prefix].size {
+ return nil, fmt.Errorf("no any more record")
+ }
+ kv := db.next[prefix].entries[db.next[prefix].off]
+ db.next[prefix].off++
+ if kv != nil {
+ jsoniter.Unmarshal(kv.v, &data)
+ data.Path = string(kv.k)
+ return data, nil
+ }
+ }
+ return nil, fmt.Errorf("no any more record")
+}
+
+func (db *boltDB) Put(key string, value *UploadedFileMeta) error {
+ if db.cleanInfo != nil {
+ value.LastSyncTime = db.cleanInfo.SyncTime
+ }
+
+ return db.db.Update(func(tx *bolt.Tx) error {
+ data, err := jsoniter.Marshal(value)
+ if err != nil {
+ return err
+ }
+ b := tx.Bucket([]byte(db.bucket))
+ if b == nil {
+ b,err = tx.CreateBucket([]byte(db.bucket))
+ if err != nil {
+ return err
+ }
+ }
+ return b.Put([]byte(key), data)
+ })
+}
+
+func (db *boltDB) Close() error {
+ if db.cleanInfo != nil {
+ db.clean()
+ }
+ if db.db != nil {
+ return db.db.Close()
+ }
+ return nil
+}
\ No newline at end of file
diff --git a/internal/functions/panupload/upload.go b/internal/functions/panupload/upload.go
new file mode 100644
index 0000000..2791f6d
--- /dev/null
+++ b/internal/functions/panupload/upload.go
@@ -0,0 +1,210 @@
+// 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 panupload
+
+import (
+ "context"
+ "encoding/xml"
+ "fmt"
+ "github.com/tickstep/library-go/logger"
+ "io"
+ "net/http"
+ "strconv"
+
+ "github.com/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
+ "github.com/tickstep/aliyunpan/internal/file/uploader"
+ "github.com/tickstep/library-go/requester"
+ "github.com/tickstep/library-go/requester/rio"
+)
+
+type (
+ PanUpload struct {
+ panClient *aliyunpan.PanClient
+ targetPath string
+ driveId string
+
+ // 网盘上传参数
+ uploadOpEntity *aliyunpan.CreateFileUploadResult
+ }
+
+ UploadedFileMeta struct {
+ IsFolder bool `json:"isFolder,omitempty"` // 是否目录
+ Path string `json:"-"` // 本地路径,不记录到数据库
+ SHA1 string `json:"sha1,omitempty"` // 文件的 SHA1
+ FileId string `json:"id,omitempty"` //文件、目录ID
+ ParentId string `json:"parentId,omitempty"` //父文件夹ID
+ Size int64 `json:"length,omitempty"` // 文件大小
+ ModTime int64 `json:"modtime,omitempty"` // 修改日期
+ LastSyncTime int64 `json:"synctime,omitempty"` //最后同步时间
+ }
+
+ EmptyReaderLen64 struct {
+ }
+)
+
+var (
+ uploadUrlExpired = fmt.Errorf("UrlExpired")
+ uploadPartNotSeq = fmt.Errorf("PartNotSequential")
+ uploadTerminate = fmt.Errorf("UploadErrorTerminate")
+ uploadPartAlreadyExist = fmt.Errorf("PartAlreadyExist")
+)
+
+func (e EmptyReaderLen64) Read(p []byte) (n int, err error) {
+ return 0, io.EOF
+}
+
+func (e EmptyReaderLen64) Len() int64 {
+ return 0
+}
+
+func NewPanUpload(panClient *aliyunpan.PanClient, targetPath, driveId string, uploadOpEntity *aliyunpan.CreateFileUploadResult) uploader.MultiUpload {
+ return &PanUpload{
+ panClient: panClient,
+ targetPath: targetPath,
+ driveId: driveId,
+ uploadOpEntity: uploadOpEntity,
+ }
+}
+
+func (pu *PanUpload) lazyInit() {
+ if pu.panClient == nil {
+ pu.panClient = &aliyunpan.PanClient{}
+ }
+}
+
+func (pu *PanUpload) Precreate() (err error) {
+ return nil
+}
+
+func (pu *PanUpload) UploadFile(ctx context.Context, partseq int, partOffset int64, partEnd int64, r rio.ReaderLen64) (uploadDone bool, uperr error) {
+ pu.lazyInit()
+
+ var respErr *uploader.MultiError
+ uploadFunc := func(httpMethod, fullUrl string, headers map[string]string) (*http.Response, error) {
+ var resp *http.Response
+ var respError error = nil
+
+ // do http upload request
+ client := requester.NewHTTPClient()
+ client.SetTimeout(0)
+ resp, _ = client.Req(httpMethod, fullUrl, r, headers)
+
+ if resp != nil {
+ if blen, e := strconv.Atoi(resp.Header.Get("content-length")); e == nil {
+ if blen > 0 {
+ buf := make([]byte, blen)
+ resp.Body.Read(buf)
+ logger.Verbosef("分片上传出错: 分片%d => %s\n", partseq, string(buf))
+
+ errResp := &apierror.ErrorXmlResp{}
+ if err := xml.Unmarshal(buf, errResp); err == nil {
+ if errResp.Code != "" {
+ if "PartNotSequential" == errResp.Code {
+ respError = uploadPartNotSeq
+ respErr = &uploader.MultiError{
+ Err: uploadPartNotSeq,
+ Terminated: false,
+ NeedStartOver: true,
+ }
+ return resp, respError
+ } else if "AccessDenied" == errResp.Code && "Request has expired." == errResp.Message {
+ respError = uploadUrlExpired
+ respErr = &uploader.MultiError{
+ Err: uploadUrlExpired,
+ Terminated: false,
+ }
+ return resp, respError
+ } else if "PartAlreadyExist" == errResp.Code {
+ respError = uploadPartAlreadyExist
+ respErr = &uploader.MultiError{
+ Err: uploadPartAlreadyExist,
+ Terminated: false,
+ }
+ return resp, respError
+ }
+ }
+ }
+ }
+ } else {
+ logger.Verbosef("分片上传出错: %d分片 => 原因未知\n", partseq)
+ }
+
+ // 不可恢复的错误
+ switch resp.StatusCode {
+ case 400, 401, 403, 413, 600:
+ respError = uploadTerminate
+ respErr = &uploader.MultiError{
+ Terminated: true,
+ }
+ }
+ }
+ return resp, respError
+ }
+
+ // 上传一个分片数据
+ apiError := pu.panClient.UploadFileData(pu.uploadOpEntity.PartInfoList[partseq].UploadURL, uploadFunc)
+
+ if respErr != nil {
+ if respErr.Err == uploadUrlExpired {
+ // URL过期,获取新的URL
+ guur, er := pu.panClient.GetUploadUrl(&aliyunpan.GetUploadUrlParam{
+ DriveId: pu.driveId,
+ FileId: pu.uploadOpEntity.FileId,
+ UploadId: pu.uploadOpEntity.UploadId,
+ PartInfoList: []aliyunpan.FileUploadPartInfoParam{{PartNumber:(partseq+1)}}, // 阿里云盘partNum从1开始计数,partSeq从0开始
+ })
+ if er != nil {
+ return false, &uploader.MultiError{
+ Terminated: false,
+ }
+ }
+
+ // 获取新的上传URL重试一次
+ pu.uploadOpEntity.PartInfoList[partseq] = guur.PartInfoList[0]
+ apiError = pu.panClient.UploadFileData(pu.uploadOpEntity.PartInfoList[partseq].UploadURL, uploadFunc)
+ } else if respErr.Err == uploadPartAlreadyExist {
+ // already upload
+ // success
+ return true, nil
+ } else if respErr.Err == uploadPartNotSeq {
+ // 上传分片乱序了,需要重新从0分片开始上传
+ // 先直接返回,后续再优化
+ return false, respErr
+ } else {
+ return false, respErr
+ }
+ }
+
+ if apiError != nil {
+ return false, apiError
+ }
+
+ return true, nil
+}
+
+func (pu *PanUpload) CommitFile() (cerr error) {
+ pu.lazyInit()
+ var er *apierror.ApiError
+
+ _, er = pu.panClient.CompleteUploadFile(&aliyunpan.CompleteUploadFileParam{
+ DriveId: pu.driveId,
+ FileId: pu.uploadOpEntity.FileId,
+ UploadId: pu.uploadOpEntity.UploadId,
+ })
+ if er != nil {
+ return er
+ }
+ return nil
+}
diff --git a/internal/functions/panupload/upload_database.go b/internal/functions/panupload/upload_database.go
new file mode 100644
index 0000000..ba2ecec
--- /dev/null
+++ b/internal/functions/panupload/upload_database.go
@@ -0,0 +1,213 @@
+// Copyright (c) 2020 tickstep & chenall
+//
+// 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 panupload
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/tickstep/aliyunpan/internal/file/uploader"
+ "github.com/tickstep/aliyunpan/internal/localfile"
+ "github.com/tickstep/library-go/converter"
+ "github.com/tickstep/library-go/jsonhelper"
+)
+
+type (
+ // Uploading 未完成上传的信息
+ Uploading struct {
+ *localfile.LocalFileMeta
+ State *uploader.InstanceState `json:"state"`
+ }
+
+ // UploadingDatabase 未完成上传的数据库
+ UploadingDatabase struct {
+ UploadingList []*Uploading `json:"upload_state"`
+ Timestamp int64 `json:"timestamp"`
+
+ dataFile *os.File
+ }
+)
+
+// NewUploadingDatabase 初始化未完成上传的数据库, 从库中读取内容
+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
+ }
+
+ ud = &UploadingDatabase{
+ dataFile: file,
+ }
+ info, err := file.Stat()
+ if err != nil {
+ return nil, err
+ }
+
+ if info.Size() <= 0 {
+ return ud, nil
+ }
+
+ err = jsonhelper.UnmarshalData(file, ud)
+ if err != nil {
+ return nil, err
+ }
+
+ return ud, nil
+}
+
+// Save 保存内容
+func (ud *UploadingDatabase) Save() error {
+ if ud.dataFile == nil {
+ return errors.New("dataFile is nil")
+ }
+
+ ud.Timestamp = time.Now().Unix()
+
+ var (
+ builder = &strings.Builder{}
+ err = jsonhelper.MarshalData(builder, ud)
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ 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 {
+ return err
+ }
+
+ return nil
+}
+
+// UpdateUploading 更新正在上传
+func (ud *UploadingDatabase) UpdateUploading(meta *localfile.LocalFileMeta, state *uploader.InstanceState) {
+ if meta == nil {
+ return
+ }
+
+ meta.CompleteAbsPath()
+ for k, uploading := range ud.UploadingList {
+ if uploading.LocalFileMeta == nil {
+ continue
+ }
+ if uploading.LocalFileMeta.EqualLengthMD5(meta) || uploading.LocalFileMeta.Path == meta.Path {
+ ud.UploadingList[k].State = state
+ return
+ }
+ }
+
+ ud.UploadingList = append(ud.UploadingList, &Uploading{
+ LocalFileMeta: meta,
+ State: state,
+ })
+}
+
+func (ud *UploadingDatabase) deleteIndex(k int) {
+ ud.UploadingList = append(ud.UploadingList[:k], ud.UploadingList[k+1:]...)
+}
+
+// Delete 删除
+func (ud *UploadingDatabase) Delete(meta *localfile.LocalFileMeta) bool {
+ if meta == nil {
+ return false
+ }
+
+ meta.CompleteAbsPath()
+ for k, uploading := range ud.UploadingList {
+ if uploading.LocalFileMeta == nil {
+ continue
+ }
+ if uploading.LocalFileMeta.EqualLengthMD5(meta) || uploading.LocalFileMeta.Path == meta.Path {
+ ud.deleteIndex(k)
+ return true
+ }
+ }
+ return false
+}
+
+// Search 搜索
+func (ud *UploadingDatabase) Search(meta *localfile.LocalFileMeta) *uploader.InstanceState {
+ if meta == nil {
+ return nil
+ }
+
+ meta.CompleteAbsPath()
+ ud.clearModTimeChange()
+ for _, uploading := range ud.UploadingList {
+ if uploading.LocalFileMeta == nil {
+ continue
+ }
+ if uploading.LocalFileMeta.EqualLengthSHA1(meta) {
+ return uploading.State
+ }
+ if uploading.LocalFileMeta.Path == meta.Path {
+ // 移除旧的信息
+ // 目前只是比较了文件大小
+ if meta.Length != uploading.LocalFileMeta.Length {
+ ud.Delete(meta)
+ return nil
+ }
+
+ // 覆盖数据
+ meta.SHA1 = uploading.LocalFileMeta.SHA1
+ meta.ParentFolderId = uploading.LocalFileMeta.ParentFolderId
+ meta.UploadOpEntity = uploading.LocalFileMeta.UploadOpEntity
+ return uploading.State
+ }
+ }
+ return nil
+}
+
+func (ud *UploadingDatabase) clearModTimeChange() {
+ for i := 0; i < len(ud.UploadingList); i++ {
+ uploading := ud.UploadingList[i]
+ if uploading.LocalFileMeta == nil {
+ continue
+ }
+
+ if uploading.ModTime == -1 { // 忽略
+ continue
+ }
+
+ info, err := os.Stat(uploading.LocalFileMeta.Path)
+ if err != nil {
+ ud.deleteIndex(i)
+ i--
+ cmdUploadVerbose.Warnf("clear invalid file path: %s, err: %s\n", uploading.LocalFileMeta.Path, err)
+ continue
+ }
+
+ if uploading.LocalFileMeta.ModTime != info.ModTime().Unix() {
+ ud.deleteIndex(i)
+ i--
+ cmdUploadVerbose.Infof("clear modified file path: %s\n", uploading.LocalFileMeta.Path)
+ continue
+ }
+ }
+}
+
+// Close 关闭数据库
+func (ud *UploadingDatabase) Close() error {
+ return ud.dataFile.Close()
+}
diff --git a/internal/functions/panupload/upload_statistic.go b/internal/functions/panupload/upload_statistic.go
new file mode 100644
index 0000000..b14d1e7
--- /dev/null
+++ b/internal/functions/panupload/upload_statistic.go
@@ -0,0 +1,24 @@
+// 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 panupload
+
+import (
+ "github.com/tickstep/aliyunpan/internal/functions"
+)
+
+type (
+ UploadStatistic struct {
+ functions.Statistic
+ }
+)
diff --git a/internal/functions/panupload/upload_task_unit.go b/internal/functions/panupload/upload_task_unit.go
new file mode 100644
index 0000000..bd6d6b1
--- /dev/null
+++ b/internal/functions/panupload/upload_task_unit.go
@@ -0,0 +1,415 @@
+// 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 panupload
+
+import (
+ "fmt"
+ "path"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/tickstep/library-go/logger"
+
+ "github.com/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan-api/aliyunpan/apierror"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/tickstep/aliyunpan/internal/file/uploader"
+ "github.com/tickstep/aliyunpan/internal/functions"
+ "github.com/tickstep/aliyunpan/internal/localfile"
+ "github.com/tickstep/aliyunpan/internal/taskframework"
+ "github.com/tickstep/library-go/converter"
+ "github.com/tickstep/library-go/requester/rio"
+)
+
+type (
+ // StepUpload 上传步骤
+ StepUpload int
+
+ // UploadTaskUnit 上传的任务单元
+ UploadTaskUnit struct {
+ LocalFileChecksum *localfile.LocalFileEntity // 要上传的本地文件详情
+ Step StepUpload
+ SavePath string // 保存路径
+ DriveId string // 网盘ID,例如:文件网盘,相册网盘
+ FolderCreateMutex *sync.Mutex
+ FolderSyncDb SyncDb //文件备份状态数据库
+
+ PanClient *aliyunpan.PanClient
+ UploadingDatabase *UploadingDatabase // 数据库
+ Parallel int
+ NoRapidUpload bool // 禁用秒传
+ BlockSize int64 // 分片大小
+
+ UploadStatistic *UploadStatistic
+
+ taskInfo *taskframework.TaskInfo
+ panDir string
+ panFile string
+ state *uploader.InstanceState
+
+ ShowProgress bool
+ IsOverwrite bool // 覆盖已存在的文件,如果同名文件已存在则移到回收站里
+ }
+)
+
+const (
+ // StepUploadInit 初始化步骤
+ StepUploadInit StepUpload = iota
+ // 上传前准备,创建上传任务
+ StepUploadPrepareUpload
+ // StepUploadRapidUpload 秒传步骤
+ StepUploadRapidUpload
+ // StepUploadUpload 正常上传步骤
+ StepUploadUpload
+)
+
+const (
+ StrUploadFailed = "上传文件失败"
+)
+
+func (utu *UploadTaskUnit) SetTaskInfo(taskInfo *taskframework.TaskInfo) {
+ utu.taskInfo = taskInfo
+}
+
+// prepareFile 解析文件阶段
+func (utu *UploadTaskUnit) prepareFile() {
+ // 解析文件保存路径
+ var (
+ panDir, panFile = path.Split(utu.SavePath)
+ )
+ utu.panDir = path.Clean(panDir)
+ utu.panFile = panFile
+
+ // 检测断点续传
+ utu.state = utu.UploadingDatabase.Search(&utu.LocalFileChecksum.LocalFileMeta)
+ if utu.state != nil || utu.LocalFileChecksum.LocalFileMeta.UploadOpEntity != nil { // 读取到了上一次上传task请求的fileId
+ utu.Step = StepUploadUpload
+ }
+
+ if utu.LocalFileChecksum.UploadOpEntity == nil {
+ utu.Step = StepUploadPrepareUpload
+ return
+ }
+
+ if utu.NoRapidUpload {
+ utu.Step = StepUploadUpload
+ return
+ }
+
+ if utu.LocalFileChecksum.Length > MaxRapidUploadSize {
+ fmt.Printf("[%s] 文件超过20GB, 无法使用秒传功能, 跳过秒传...\n", utu.taskInfo.Id())
+ utu.Step = StepUploadUpload
+ return
+ }
+ // 下一步: 秒传
+ utu.Step = StepUploadRapidUpload
+}
+
+// rapidUpload 执行秒传
+func (utu *UploadTaskUnit) rapidUpload() (isContinue bool, result *taskframework.TaskUnitRunResult) {
+ utu.Step = StepUploadRapidUpload
+
+ // 是否可以秒传
+ result = &taskframework.TaskUnitRunResult{}
+ fmt.Printf("[%s] 检测秒传中, 请稍候...\n", utu.taskInfo.Id())
+ if utu.LocalFileChecksum.UploadOpEntity.RapidUpload {
+ fmt.Printf("[%s] 秒传成功, 保存到网盘路径: %s\n\n", utu.taskInfo.Id(), utu.SavePath)
+ result.Succeed = true
+ return false, result
+ } else {
+ fmt.Printf("[%s] 秒传失败,开始正常上传文件\n", utu.taskInfo.Id())
+ result.Succeed = false
+ result.ResultMessage = "文件未曾上传,无法秒传"
+ return true, result
+ }
+}
+
+// upload 上传文件
+func (utu *UploadTaskUnit) upload() (result *taskframework.TaskUnitRunResult) {
+ utu.Step = StepUploadUpload
+
+ // 创建分片上传器
+ // 阿里云盘默认就是分片上传,每一个分片对应一个part_info
+ // 但是不支持分片同时上传,必须单线程,并且按照顺序从1开始一个一个上传
+ muer := uploader.NewMultiUploader(
+ NewPanUpload(utu.PanClient, utu.SavePath, utu.DriveId, utu.LocalFileChecksum.UploadOpEntity),
+ rio.NewFileReaderAtLen64(utu.LocalFileChecksum.GetFile()), &uploader.MultiUploaderConfig{
+ Parallel: utu.Parallel,
+ BlockSize: utu.BlockSize,
+ MaxRate: config.Config.MaxUploadRate,
+ }, utu.LocalFileChecksum.UploadOpEntity)
+
+ // 设置断点续传
+ if utu.state != nil {
+ muer.SetInstanceState(utu.state)
+ }
+
+ muer.OnUploadStatusEvent(func(status uploader.Status, updateChan <-chan struct{}) {
+ select {
+ case <-updateChan:
+ utu.UploadingDatabase.UpdateUploading(&utu.LocalFileChecksum.LocalFileMeta, muer.InstanceState())
+ utu.UploadingDatabase.Save()
+ default:
+ }
+
+ if utu.ShowProgress {
+ fmt.Printf("\r[%s] ↑ %s/%s %s/s in %s ............", utu.taskInfo.Id(),
+ converter.ConvertFileSize(status.Uploaded(), 2),
+ converter.ConvertFileSize(status.TotalSize(), 2),
+ converter.ConvertFileSize(status.SpeedsPerSecond(), 2),
+ status.TimeElapsed(),
+ )
+ }
+ })
+
+ // result
+ result = &taskframework.TaskUnitRunResult{}
+ muer.OnSuccess(func() {
+ fmt.Printf("\n")
+ fmt.Printf("[%s] 上传文件成功, 保存到网盘路径: %s\n", utu.taskInfo.Id(), utu.SavePath)
+ // 统计
+ utu.UploadStatistic.AddTotalSize(utu.LocalFileChecksum.Length)
+ utu.UploadingDatabase.Delete(&utu.LocalFileChecksum.LocalFileMeta) // 删除
+ utu.UploadingDatabase.Save()
+ result.Succeed = true
+ })
+ muer.OnError(func(err error) {
+ apiError, ok := err.(*apierror.ApiError)
+ if !ok {
+ // 未知错误类型 (非预期的)
+ // 不重试
+ result.ResultMessage = "上传文件错误"
+ result.Err = err
+ return
+ }
+
+ // 默认需要重试
+ result.NeedRetry = true
+
+ switch apiError.ErrCode() {
+ default:
+ result.ResultMessage = StrUploadFailed
+ result.NeedRetry = false
+ result.Err = apiError
+ }
+ return
+ })
+ muer.Execute()
+
+ return
+}
+
+func (utu *UploadTaskUnit) OnRetry(lastRunResult *taskframework.TaskUnitRunResult) {
+ // 输出错误信息
+ if lastRunResult.Err == nil {
+ // result中不包含Err, 忽略输出
+ fmt.Printf("[%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())
+}
+
+func (utu *UploadTaskUnit) OnSuccess(lastRunResult *taskframework.TaskUnitRunResult) {
+ //文件上传成功
+ if utu.FolderSyncDb == nil || lastRunResult == ResultLocalFileNotUpdated { //不需要更新数据库
+ return
+ }
+ ufm := &UploadedFileMeta{
+ IsFolder: false,
+ SHA1: utu.LocalFileChecksum.SHA1,
+ ModTime: utu.LocalFileChecksum.ModTime,
+ Size: utu.LocalFileChecksum.Length,
+ }
+
+ if utu.LocalFileChecksum.UploadOpEntity != nil {
+ ufm.FileId = utu.LocalFileChecksum.UploadOpEntity.FileId
+ ufm.ParentId = utu.LocalFileChecksum.UploadOpEntity.ParentFileId
+ } else {
+ efi, _ := utu.PanClient.FileInfoByPath(utu.DriveId, utu.SavePath)
+ if efi != nil {
+ ufm.FileId = efi.FileId
+ ufm.ParentId = efi.ParentFileId
+ }
+ }
+ utu.FolderSyncDb.Put(utu.SavePath, ufm)
+}
+
+func (utu *UploadTaskUnit) OnFailed(lastRunResult *taskframework.TaskUnitRunResult) {
+ // 失败
+}
+
+var ResultLocalFileNotUpdated = &taskframework.TaskUnitRunResult{ResultCode: 1, Succeed: true, ResultMessage: "本地文件未更新,无需上传!"}
+var ResultUpdateLocalDatabase = &taskframework.TaskUnitRunResult{ResultCode: 2, Succeed: true, ResultMessage: "本地文件和云端文件MD5一致,无需上传!"}
+
+func (utu *UploadTaskUnit) OnComplete(lastRunResult *taskframework.TaskUnitRunResult) {
+
+}
+
+func (utu *UploadTaskUnit) RetryWait() time.Duration {
+ return functions.RetryWait(utu.taskInfo.Retry())
+}
+
+func (utu *UploadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) {
+ err := utu.LocalFileChecksum.OpenPath()
+ if err != nil {
+ fmt.Printf("[%s] 文件不可读, 错误信息: %s, 跳过...\n", utu.taskInfo.Id(), err)
+ return
+ }
+ defer utu.LocalFileChecksum.Close() // 关闭文件
+
+ timeStart := time.Now()
+ result = &taskframework.TaskUnitRunResult{}
+
+ fmt.Printf("[%s] 准备上传: %s=>%s\n", utu.taskInfo.Id(), utu.LocalFileChecksum.Path, utu.SavePath)
+
+ defer func() {
+ var msg string
+ if result.Err != nil {
+ msg = "失败!" + result.ResultMessage + "," + result.Err.Error()
+ } else if result.Succeed {
+ msg = "成功!" + result.ResultMessage
+ } else {
+ msg = result.ResultMessage
+ }
+ fmt.Printf("%s [%s] 文件上传结果:%s 耗时 %s\n", time.Now().Format("2006-01-02 15:04:06"), utu.taskInfo.Id(), msg, time.Now().Sub(timeStart))
+ }()
+ // 准备文件
+ utu.prepareFile()
+
+ var apierr *apierror.ApiError
+ var rs *aliyunpan.MkdirResult
+ var appCreateUploadFileParam *aliyunpan.CreateFileUploadParam
+ var sha1Str string
+ var saveFilePath string
+ var testFileMeta = &UploadedFileMeta{}
+ var uploadOpEntity *aliyunpan.CreateFileUploadResult
+
+ switch utu.Step {
+ case StepUploadPrepareUpload:
+ goto StepUploadPrepareUpload
+ case StepUploadRapidUpload:
+ goto stepUploadRapidUpload
+ case StepUploadUpload:
+ goto stepUploadUpload
+ }
+
+StepUploadPrepareUpload:
+
+ if utu.FolderSyncDb != nil {
+ //启用了备份功能,强制使用覆盖同名文件功能
+ utu.IsOverwrite = true
+ testFileMeta = utu.FolderSyncDb.Get(utu.SavePath)
+ }
+ // 创建上传任务
+ utu.LocalFileChecksum.Sum(localfile.CHECKSUM_SHA1)
+
+ if testFileMeta.SHA1 == utu.LocalFileChecksum.SHA1 {
+ return ResultUpdateLocalDatabase
+ }
+
+ utu.FolderCreateMutex.Lock()
+ saveFilePath = path.Dir(utu.SavePath)
+ if saveFilePath != "/" {
+ //同步功能先尝试从数据库获取
+ if utu.FolderSyncDb != nil {
+ if test := utu.FolderSyncDb.Get(saveFilePath); test.FileId != "" && test.IsFolder {
+ rs = &aliyunpan.MkdirResult{FileId: test.FileId}
+ }
+ }
+ if rs == nil {
+ rs, apierr = utu.PanClient.MkdirRecursive(utu.DriveId, "", "", 0, strings.Split(path.Clean(saveFilePath), "/"))
+ if apierr != nil || rs.FileId == "" {
+ result.Err = apierr
+ result.ResultMessage = "创建云盘文件夹失败"
+ return
+ }
+ }
+ } else {
+ rs = &aliyunpan.MkdirResult{}
+ rs.FileId = ""
+ }
+ time.Sleep(time.Duration(2) * time.Second)
+ utu.FolderCreateMutex.Unlock()
+
+ if utu.IsOverwrite {
+ // 标记覆盖旧同名文件
+ // 检查同名文件是否存在
+ efi, apierr := utu.PanClient.FileInfoByPath(utu.DriveId, utu.SavePath)
+ if apierr != nil && apierr.Code != apierror.ApiCodeFileNotFoundCode {
+ result.Err = apierr
+ result.ResultMessage = "检测同名文件失败"
+ return
+ }
+ if efi != nil && efi.FileId != "" {
+ if efi.ContentHash == strings.ToUpper(utu.LocalFileChecksum.SHA1) {
+ result.Succeed = true
+ result.Extra = efi
+ return
+ }
+ // existed, delete it
+ var fileDeleteResult []*aliyunpan.FileBatchActionResult
+ var err *apierror.ApiError
+ fileDeleteResult, err = utu.PanClient.FileDelete([]*aliyunpan.FileBatchActionParam{{DriveId:efi.DriveId, FileId:efi.FileId}})
+ if err != nil || len(fileDeleteResult) == 0 {
+ result.Err = err
+ result.ResultMessage = "无法删除文件,请稍后重试"
+ return
+ }
+ time.Sleep(time.Duration(500) * time.Millisecond)
+ logger.Verbosef("[%s] 检测到同名文件,已移动到回收站: %s", utu.taskInfo.Id(), utu.SavePath)
+ }
+ }
+
+ sha1Str = utu.LocalFileChecksum.SHA1
+ if utu.LocalFileChecksum.Length == 0 {
+ sha1Str = aliyunpan.DefaultZeroSizeFileContentHash
+ }
+
+ appCreateUploadFileParam = &aliyunpan.CreateFileUploadParam{
+ DriveId: utu.DriveId,
+ Name: filepath.Base(utu.LocalFileChecksum.Path),
+ Size: utu.LocalFileChecksum.Length,
+ ContentHash: sha1Str,
+ ParentFileId: rs.FileId,
+ BlockSize: utu.BlockSize,
+ }
+
+ uploadOpEntity, apierr = utu.PanClient.CreateUploadFile(appCreateUploadFileParam)
+ if apierr != nil {
+ result.Err = apierr
+ result.ResultMessage = "创建上传任务失败:" + apierr.Error()
+ return
+ }
+
+ utu.LocalFileChecksum.UploadOpEntity = uploadOpEntity
+ utu.LocalFileChecksum.ParentFolderId = rs.FileId
+
+stepUploadRapidUpload:
+ // 秒传
+ if !utu.NoRapidUpload {
+ isContinue, rapidUploadResult := utu.rapidUpload()
+ if !isContinue {
+ // 秒传成功, 返回秒传的结果
+ return rapidUploadResult
+ }
+ }
+
+stepUploadUpload:
+ // 正常上传流程
+ uploadResult := utu.upload()
+
+ return uploadResult
+}
diff --git a/internal/functions/panupload/utils.go b/internal/functions/panupload/utils.go
new file mode 100644
index 0000000..2659a7b
--- /dev/null
+++ b/internal/functions/panupload/utils.go
@@ -0,0 +1,43 @@
+// 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 panupload
+
+import (
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/tickstep/library-go/converter"
+ "github.com/tickstep/library-go/logger"
+)
+
+const (
+ // MaxUploadBlockSize 最大上传的文件分片大小
+ MaxUploadBlockSize = 2 * converter.GB
+ // MinUploadBlockSize 最小的上传的文件分片大小
+ MinUploadBlockSize = 4 * converter.MB
+ // MaxRapidUploadSize 秒传文件支持的最大文件大小
+ MaxRapidUploadSize = 20 * converter.GB
+
+ UploadingFileName = "aliyunpan_uploading.json"
+)
+
+var (
+ cmdUploadVerbose = logger.New("FILE_UPLOAD", config.EnvVerbose)
+)
+
+func getBlockSize(fileSize int64) int64 {
+ blockNum := fileSize / MinUploadBlockSize
+ if blockNum > 999 {
+ return fileSize/999 + 1
+ }
+ return MinUploadBlockSize
+}
diff --git a/internal/functions/statistic.go b/internal/functions/statistic.go
new file mode 100644
index 0000000..c50a951
--- /dev/null
+++ b/internal/functions/statistic.go
@@ -0,0 +1,44 @@
+// 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 functions
+
+import (
+ "github.com/tickstep/library-go/expires"
+ "sync/atomic"
+ "time"
+)
+
+type (
+ Statistic struct {
+ totalSize int64
+ startTime time.Time
+ }
+)
+
+func (s *Statistic) AddTotalSize(size int64) int64 {
+ return atomic.AddInt64(&s.totalSize, size)
+}
+
+func (s *Statistic) TotalSize() int64 {
+ return s.totalSize
+}
+
+func (s *Statistic) StartTimer() {
+ s.startTime = time.Now()
+ expires.StripMono(&s.startTime)
+}
+
+func (s *Statistic) Elapsed() time.Duration {
+ return time.Now().Sub(s.startTime)
+}
\ No newline at end of file
diff --git a/internal/localfile/checksum_write.go b/internal/localfile/checksum_write.go
new file mode 100644
index 0000000..ac6a08b
--- /dev/null
+++ b/internal/localfile/checksum_write.go
@@ -0,0 +1,169 @@
+// 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 localfile
+
+import (
+ "hash"
+ "io"
+)
+
+type (
+ ChecksumWriter interface {
+ io.Writer
+ Sum() interface{}
+ }
+
+ ChecksumWriteUnit struct {
+ SliceEnd int64
+ End int64
+ SliceSum interface{}
+ Sum interface{}
+ OnlySliceSum bool
+ ChecksumWriter ChecksumWriter
+
+ ptr int64
+ }
+
+ hashChecksumWriter struct {
+ h hash.Hash
+ }
+
+ hash32ChecksumWriter struct {
+ h hash.Hash32
+ }
+)
+
+func (wi *ChecksumWriteUnit) handleEnd() error {
+ if wi.ptr >= wi.End {
+ // 已写完
+ if !wi.OnlySliceSum {
+ wi.Sum = wi.ChecksumWriter.Sum()
+ }
+ return ErrChecksumWriteStop
+ }
+ return nil
+}
+
+func (wi *ChecksumWriteUnit) write(p []byte) (n int, err error) {
+ if wi.End <= 0 {
+ // do nothing
+ err = ErrChecksumWriteStop
+ return
+ }
+ err = wi.handleEnd()
+ if err != nil {
+ return
+ }
+
+ var (
+ i int
+ left = wi.End - wi.ptr
+ lenP = len(p)
+ )
+ if left < int64(lenP) {
+ // 读取即将完毕
+ i = int(left)
+ } else {
+ i = lenP
+ }
+ n, err = wi.ChecksumWriter.Write(p[:i])
+ if err != nil {
+ return
+ }
+ wi.ptr += int64(n)
+ if left < int64(lenP) {
+ err = wi.handleEnd()
+ return
+ }
+ return
+}
+
+func (wi *ChecksumWriteUnit) Write(p []byte) (n int, err error) {
+ if wi.SliceEnd <= 0 { // 忽略Slice
+ // 读取全部
+ n, err = wi.write(p)
+ return
+ }
+
+ // 要计算Slice的情况
+ // 调整slice
+ if wi.SliceEnd > wi.End {
+ wi.SliceEnd = wi.End
+ }
+
+ // 计算剩余Slice
+ var (
+ sliceLeft = wi.SliceEnd - wi.ptr
+ )
+ if sliceLeft <= 0 {
+ // 已处理完Slice
+ if wi.OnlySliceSum {
+ err = ErrChecksumWriteStop
+ return
+ }
+
+ // 继续处理
+ n, err = wi.write(p)
+ return
+ }
+
+ var (
+ lenP = len(p)
+ )
+ if sliceLeft <= int64(lenP) {
+ var n1, n2 int
+ n1, err = wi.write(p[:sliceLeft])
+ n += n1
+ if err != nil {
+ return
+ }
+ wi.SliceSum = wi.ChecksumWriter.Sum().([]byte)
+ n2, err = wi.write(p[sliceLeft:])
+ n += n2
+ if err != nil {
+ return
+ }
+ return
+ }
+ n, err = wi.write(p)
+ return
+}
+
+func NewHashChecksumWriter(h hash.Hash) ChecksumWriter {
+ return &hashChecksumWriter{
+ h: h,
+ }
+}
+
+func (hc *hashChecksumWriter) Write(p []byte) (n int, err error) {
+ return hc.h.Write(p)
+}
+
+func (hc *hashChecksumWriter) Sum() interface{} {
+ return hc.h.Sum(nil)
+}
+
+func NewHash32ChecksumWriter(h32 hash.Hash32) ChecksumWriter {
+ return &hash32ChecksumWriter{
+ h: h32,
+ }
+}
+
+func (hc *hash32ChecksumWriter) Write(p []byte) (n int, err error) {
+ return hc.h.Write(p)
+}
+
+func (hc *hash32ChecksumWriter) Sum() interface{} {
+ return hc.h.Sum32()
+}
diff --git a/internal/localfile/errors.go b/internal/localfile/errors.go
new file mode 100644
index 0000000..235997b
--- /dev/null
+++ b/internal/localfile/errors.go
@@ -0,0 +1,24 @@
+// 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 localfile
+
+import (
+ "errors"
+)
+
+var (
+ ErrFileIsNil = errors.New("file is nil")
+ ErrChecksumWriteStop = errors.New("checksum write stop")
+ ErrChecksumWriteAllStop = errors.New("checksum write all stop")
+)
diff --git a/internal/localfile/file.go b/internal/localfile/file.go
new file mode 100644
index 0000000..fad11ff
--- /dev/null
+++ b/internal/localfile/file.go
@@ -0,0 +1,76 @@
+// 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 localfile
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// EqualLengthMD5 检测md5和大小是否相同
+func (lfm *LocalFileMeta) EqualLengthMD5(m *LocalFileMeta) bool {
+ if lfm.Length != m.Length {
+ return false
+ }
+ if lfm.MD5 != m.MD5 {
+ return false
+ }
+ return true
+}
+
+// EqualLengthSHA1 检测sha1和大小是否相同
+func (lfm *LocalFileMeta) EqualLengthSHA1(m *LocalFileMeta) bool {
+ if lfm.Length != m.Length {
+ return false
+ }
+ if lfm.SHA1 != m.SHA1 {
+ return false
+ }
+ return true
+}
+
+// CompleteAbsPath 补齐绝对路径
+func (lfm *LocalFileMeta) CompleteAbsPath() {
+ if filepath.IsAbs(lfm.Path) {
+ return
+ }
+
+ absPath, err := filepath.Abs(lfm.Path)
+ if err != nil {
+ return
+ }
+ // windows
+ if os.PathSeparator == '\\' {
+ absPath = strings.ReplaceAll(absPath, "\\", "/")
+ }
+ lfm.Path = absPath
+}
+
+// GetFileSum 获取文件的大小, md5, crc32
+func GetFileSum(localPath string, flag int) (lfc *LocalFileEntity, err error) {
+ lfc = NewLocalFileEntity(localPath)
+ defer lfc.Close()
+
+ err = lfc.OpenPath()
+ if err != nil {
+ return nil, err
+ }
+
+ err = lfc.Sum(flag)
+ if err != nil {
+ return nil, err
+ }
+ return lfc, nil
+}
diff --git a/internal/localfile/localfile.go b/internal/localfile/localfile.go
new file mode 100644
index 0000000..bed32a6
--- /dev/null
+++ b/internal/localfile/localfile.go
@@ -0,0 +1,267 @@
+// 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 localfile
+
+import (
+ "crypto/md5"
+ "crypto/sha1"
+ "encoding/hex"
+ "github.com/tickstep/aliyunpan-api/aliyunpan"
+ "hash/crc32"
+ "io"
+ "os"
+ "strings"
+
+ "github.com/tickstep/library-go/cachepool"
+ "github.com/tickstep/library-go/converter"
+)
+
+const (
+ // DefaultBufSize 默认的bufSize
+ DefaultBufSize = int(256 * converter.KB)
+)
+
+const (
+ // CHECKSUM_MD5 获取文件的 md5 值
+ CHECKSUM_MD5 int = 1 << iota
+
+ // CHECKSUM_CRC32 获取文件的 crc32 值
+ CHECKSUM_CRC32
+
+ // CHECKSUM_SHA1 获取文件的 sha1 值
+ CHECKSUM_SHA1
+)
+
+type (
+ // LocalFileMeta 本地文件元信息
+ LocalFileMeta struct {
+ Path string `json:"path,omitempty"` // 本地路径
+ Length int64 `json:"length,omitempty"` // 文件大小
+ MD5 string `json:"md5,omitempty"` // 文件的 md5
+ CRC32 uint32 `json:"crc32,omitempty"` // 文件的 crc32
+ SHA1 string `json:"sha1,omitempty"` // 文件的 sha1
+ ModTime int64 `json:"modtime"` // 修改日期
+
+ // 网盘上传参数
+ UploadOpEntity *aliyunpan.CreateFileUploadResult `json:"uploadOpEntity"`
+
+ // ParentFolderId 存储云盘的目录ID
+ ParentFolderId string `json:"parent_folder_id,omitempty"`
+ }
+
+ // LocalFileEntity 校验本地文件
+ LocalFileEntity struct {
+ LocalFileMeta
+ bufSize int
+ buf []byte
+ file *os.File // 文件
+ }
+)
+
+func NewLocalFileEntity(localPath string) *LocalFileEntity {
+ return NewLocalFileEntityWithBufSize(localPath, DefaultBufSize)
+}
+
+func NewLocalFileEntityWithBufSize(localPath string, bufSize int) *LocalFileEntity {
+ return &LocalFileEntity{
+ LocalFileMeta: LocalFileMeta{
+ Path: localPath,
+ },
+ bufSize: bufSize,
+ }
+}
+
+// OpenPath 检查文件状态并获取文件的大小 (Length)
+func (lfc *LocalFileEntity) OpenPath() error {
+ if lfc.file != nil {
+ lfc.file.Close()
+ }
+
+ var err error
+ lfc.file, err = os.Open(lfc.Path)
+ if err != nil {
+ return err
+ }
+
+ info, err := lfc.file.Stat()
+ if err != nil {
+ return err
+ }
+
+ lfc.Length = info.Size()
+ lfc.ModTime = info.ModTime().Unix()
+ return nil
+}
+
+// GetFile 获取文件
+func (lfc *LocalFileEntity) GetFile() *os.File {
+ return lfc.file
+}
+
+// Close 关闭文件
+func (lfc *LocalFileEntity) Close() error {
+ if lfc.file == nil {
+ return ErrFileIsNil
+ }
+
+ return lfc.file.Close()
+}
+
+func (lfc *LocalFileEntity) initBuf() {
+ if lfc.buf == nil {
+ lfc.buf = cachepool.RawMallocByteSlice(lfc.bufSize)
+ }
+}
+
+func (lfc *LocalFileEntity) writeChecksum(data []byte, wus ...*ChecksumWriteUnit) (err error) {
+ doneCount := 0
+ for _, wu := range wus {
+ _, err := wu.Write(data)
+ switch err {
+ case ErrChecksumWriteStop:
+ doneCount++
+ continue
+ case nil:
+ default:
+ return err
+ }
+ }
+ if doneCount == len(wus) {
+ return ErrChecksumWriteAllStop
+ }
+ return nil
+}
+
+func (lfc *LocalFileEntity) repeatRead(wus ...*ChecksumWriteUnit) (err error) {
+ if lfc.file == nil {
+ return ErrFileIsNil
+ }
+
+ lfc.initBuf()
+
+ defer func() {
+ _, err = lfc.file.Seek(0, os.SEEK_SET) // 恢复文件指针
+ if err != nil {
+ return
+ }
+ }()
+
+ // 读文件
+ var (
+ n int
+ )
+read:
+ for {
+ n, err = lfc.file.Read(lfc.buf)
+ switch err {
+ case io.EOF:
+ err = lfc.writeChecksum(lfc.buf[:n], wus...)
+ break read
+ case nil:
+ err = lfc.writeChecksum(lfc.buf[:n], wus...)
+ default:
+ return
+ }
+ }
+ switch err {
+ case ErrChecksumWriteAllStop: // 全部结束
+ err = nil
+ }
+ return
+}
+
+func (lfc *LocalFileEntity) createChecksumWriteUnit(cw ChecksumWriter, isAll bool, getSumFunc func(sum interface{})) (wu *ChecksumWriteUnit, deferFunc func(err error)) {
+ wu = &ChecksumWriteUnit{
+ ChecksumWriter: cw,
+ End: lfc.LocalFileMeta.Length,
+ OnlySliceSum: !isAll,
+ }
+
+ return wu, func(err error) {
+ if err != nil {
+ return
+ }
+ getSumFunc(wu.Sum)
+ }
+}
+
+// Sum 计算文件摘要值
+func (lfc *LocalFileEntity) Sum(checkSumFlag int) (err error) {
+ lfc.fix()
+ wus := make([]*ChecksumWriteUnit, 0, 2)
+ if (checkSumFlag & (CHECKSUM_MD5)) != 0 {
+ md5w := md5.New()
+ wu, d := lfc.createChecksumWriteUnit(
+ NewHashChecksumWriter(md5w),
+ (checkSumFlag&CHECKSUM_MD5) != 0,
+ func(sum interface{}) {
+ if sum != nil {
+ lfc.MD5 = hex.EncodeToString(sum.([]byte))
+ }
+
+ // zero size file
+ if lfc.Length == 0 {
+ lfc.MD5 = aliyunpan.DefaultZeroSizeFileContentHash
+ }
+ },
+ )
+
+ wus = append(wus, wu)
+ defer d(err)
+ }
+ if (checkSumFlag & CHECKSUM_CRC32) != 0 {
+ crc32w := crc32.NewIEEE()
+ wu, d := lfc.createChecksumWriteUnit(
+ NewHash32ChecksumWriter(crc32w),
+ true,
+ func(sum interface{}) {
+ if sum != nil {
+ lfc.CRC32 = sum.(uint32)
+ }
+ },
+ )
+
+ wus = append(wus, wu)
+ defer d(err)
+ }
+ if (checkSumFlag & (CHECKSUM_SHA1)) != 0 {
+ sha1w := sha1.New()
+ wu, d := lfc.createChecksumWriteUnit(
+ NewHashChecksumWriter(sha1w),
+ (checkSumFlag&CHECKSUM_SHA1) != 0,
+ func(sum interface{}) {
+ if sum != nil {
+ lfc.SHA1 = strings.ToUpper(hex.EncodeToString(sum.([]byte)))
+ }
+
+ // zero size file
+ if lfc.Length == 0 {
+ lfc.SHA1 = aliyunpan.DefaultZeroSizeFileContentHash
+ }
+ },
+ )
+
+ wus = append(wus, wu)
+ defer d(err)
+ }
+
+ err = lfc.repeatRead(wus...)
+ return
+}
+
+func (lfc *LocalFileEntity) fix() {
+ if lfc.bufSize < DefaultBufSize {
+ lfc.bufSize = DefaultBufSize
+ }
+}
diff --git a/internal/panupdate/github.go b/internal/panupdate/github.go
new file mode 100644
index 0000000..bf5d215
--- /dev/null
+++ b/internal/panupdate/github.go
@@ -0,0 +1,31 @@
+// 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 panupdate
+
+type (
+ // AssetInfo asset 信息
+ AssetInfo struct {
+ Name string `json:"name"`
+ ContentType string `json:"content_type"`
+ State string `json:"state"`
+ Size int64 `json:"size"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ }
+
+ // ReleaseInfo 发布信息
+ ReleaseInfo struct {
+ TagName string `json:"tag_name"`
+ Assets []*AssetInfo `json:"assets"`
+ }
+)
diff --git a/internal/panupdate/panupdate.go b/internal/panupdate/panupdate.go
new file mode 100644
index 0000000..3fd812c
--- /dev/null
+++ b/internal/panupdate/panupdate.go
@@ -0,0 +1,398 @@
+// 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 panupdate
+
+import (
+ "archive/zip"
+ "bytes"
+ "fmt"
+ "github.com/tickstep/aliyunpan/cmder/cmdliner"
+ "github.com/tickstep/aliyunpan/cmder/cmdutil"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/tickstep/aliyunpan/internal/utils"
+ "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"
+ "github.com/tickstep/aliyunpan/library/requester/transfer"
+ "net/http"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ ReleaseName = "aliyunpan"
+)
+
+type info struct {
+ filename string
+ size int64
+ downloadURL string
+}
+
+type tsResp struct {
+ Code int `json:"code"`
+ Data interface{} `json:"data"`
+ Msg string `json:"msg"`
+}
+
+func getReleaseFromTicstep(client *requester.HTTPClient, showPrompt bool) *ReleaseInfo {
+ tsReleaseInfo := &ReleaseInfo{}
+ tsResp := &tsResp{Data: tsReleaseInfo}
+ fullUrl := strings.Builder{}
+ ipAddr, err := getip.IPInfoFromTechainBaidu()
+ 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, config.AppVersion)
+ resp, err := client.Req(http.MethodGet, fullUrl.String(), nil, nil)
+ if resp != nil {
+ defer resp.Body.Close()
+ }
+ if err != nil {
+ if showPrompt {
+ logger.Verbosef("获取数据错误: %s\n", err)
+ }
+ return nil
+ }
+ err = jsonhelper.UnmarshalData(resp.Body, tsResp)
+ if err != nil {
+ if showPrompt {
+ fmt.Printf("json数据解析失败: %s\n", err)
+ }
+ return nil
+ }
+ if tsResp.Code == 0 {
+ return tsReleaseInfo
+ }
+ return nil
+}
+
+func getReleaseFromGithub(client *requester.HTTPClient, showPrompt bool) *ReleaseInfo {
+ resp, err := client.Req(http.MethodGet, "https://api.github.com/repos/tickstep/aliyunpan/releases/latest", nil, nil)
+ if resp != nil {
+ defer resp.Body.Close()
+ }
+ if err != nil {
+ if showPrompt {
+ logger.Verbosef("获取数据错误: %s\n", err)
+ }
+ return nil
+ }
+
+ releaseInfo := ReleaseInfo{}
+ err = jsonhelper.UnmarshalData(resp.Body, &releaseInfo)
+ if err != nil {
+ if showPrompt {
+ fmt.Printf("json数据解析失败: %s\n", err)
+ }
+ return nil
+ }
+ return &releaseInfo
+}
+
+func GetLatestReleaseInfo(showPrompt bool) *ReleaseInfo {
+ client := config.Config.HTTPClient("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36")
+ 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)
+ }
+
+ // github
+ var ghReleaseInfo *ReleaseInfo = nil
+ for idx := 0; idx < 3; idx++ {
+ ghReleaseInfo = getReleaseFromGithub(client, showPrompt)
+ if ghReleaseInfo != nil {
+ break
+ }
+ time.Sleep(time.Duration(5) * time.Second)
+ }
+
+ var releaseInfo *ReleaseInfo = nil
+ if config.Config.UpdateCheckInfo.PreferUpdateSrv == "tickstep" {
+ // theoretically, tickstep server will be more faster at mainland
+ releaseInfo = tsReleaseInfo
+ } else {
+ releaseInfo = ghReleaseInfo
+ if ghReleaseInfo == nil || ghReleaseInfo.TagName == "" {
+ releaseInfo = tsReleaseInfo
+ }
+ }
+ return releaseInfo
+}
+
+// CheckUpdate 检测更新
+func CheckUpdate(version string, yes bool) {
+ if !checkaccess.AccessRDWR(cmdutil.ExecutablePath()) {
+ fmt.Printf("程序目录不可写, 无法更新.\n")
+ return
+ }
+ fmt.Println("检测更新中, 稍候...")
+ client := config.Config.HTTPClient("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36")
+ client.SetTimeout(time.Duration(0) * time.Second)
+ client.SetKeepAlive(true)
+
+ releaseInfo := GetLatestReleaseInfo(true)
+ if releaseInfo == nil {
+ fmt.Printf("获取版本信息失败!\n")
+ return
+ }
+
+ // 没有更新, 或忽略 Beta 版本, 和版本前缀不符的
+ if strings.Contains(releaseInfo.TagName, "Beta") || !strings.HasPrefix(releaseInfo.TagName, "v") || utils.ParseVersionNum(version) >= utils.ParseVersionNum(releaseInfo.TagName) {
+ fmt.Printf("未检测到更新!\n")
+ return
+ }
+
+ fmt.Printf("检测到新版本: %s\n", releaseInfo.TagName)
+
+ line := cmdliner.NewLiner()
+ defer line.Close()
+
+ if !yes {
+ y, err := line.State.Prompt("是否进行更新 (y/n): ")
+ if err != nil {
+ fmt.Printf("输入错误: %s\n", err)
+ return
+ }
+
+ if y != "y" && y != "Y" {
+ fmt.Printf("更新取消.\n")
+ return
+ }
+ }
+
+ 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)
+ }
+ }
+ builder.WriteString("\\.zip")
+
+ exp := regexp.MustCompile(builder.String())
+
+ var targetList []*info
+ for _, asset := range releaseInfo.Assets {
+ if asset == nil || asset.State != "uploaded" {
+ continue
+ }
+
+ if exp.MatchString(asset.Name) {
+ targetList = append(targetList, &info{
+ filename: asset.Name,
+ size: asset.Size,
+ downloadURL: asset.BrowserDownloadURL,
+ })
+ }
+ }
+
+ var target info
+ switch len(targetList) {
+ case 0:
+ fmt.Printf("未匹配到当前系统的程序更新文件, GOOS: %s, GOARCH: %s\n", runtime.GOOS, runtime.GOARCH)
+ return
+ case 1:
+ target = *targetList[0]
+ default:
+ fmt.Println()
+ for k := range targetList {
+ fmt.Printf("%d: %s\n", k, targetList[k].filename)
+ }
+
+ fmt.Println()
+ t, err := line.State.Prompt("输入序号以下载更新: ")
+ if err != nil {
+ fmt.Printf("%s\n", err)
+ return
+ }
+
+ i, err := strconv.Atoi(t)
+ if err != nil {
+ fmt.Printf("输入错误: %s\n", err)
+ return
+ }
+
+ if i < 0 || i >= len(targetList) {
+ fmt.Printf("输入错误: 序号不在范围内\n")
+ return
+ }
+
+ target = *targetList[i]
+ }
+
+ if target.size > 0x7fffffff {
+ fmt.Printf("file size too large: %d\n", target.size)
+ return
+ }
+
+ fmt.Printf("准备下载更新: %s\n", target.filename)
+
+ // 开始下载
+ buf := cachepool.RawMallocByteSlice(int(target.size))
+ resp, err := client.Req("GET", target.downloadURL, nil, nil)
+ if err != nil {
+ fmt.Printf("下载更新文件发生错误: %s\n", err)
+ return
+ }
+ total, _ := strconv.Atoi(resp.Header.Get("Content-Length"))
+ if total > 0 {
+ if int64(total) != target.size {
+ fmt.Printf("下载更新文件发生错误: %s\n", err)
+ return
+ }
+ }
+
+ // 初始化数据
+ var readErr error
+ downloadSize := 0
+ nn := 0
+ nn64 := int64(0)
+ downloadStatus := transfer.NewDownloadStatus()
+ downloadStatus.AddTotalSize(target.size)
+
+ statusIndicator := func(status *transfer.DownloadStatus) {
+ status.UpdateSpeeds() // 更新速度
+ var leftStr string
+ left := status.TimeLeft()
+ if left < 0 {
+ leftStr = "-"
+ } else {
+ leftStr = left.String()
+ }
+
+ fmt.Printf("\r ↓ %s/%s %s/s in %s, left %s ............",
+ converter.ConvertFileSize(status.Downloaded(), 2),
+ converter.ConvertFileSize(status.TotalSize(), 2),
+ converter.ConvertFileSize(status.SpeedsPerSecond(), 2),
+ status.TimeElapsed()/1e7*1e7, leftStr,
+ )
+ }
+
+ // 读取数据
+ wg := sync.WaitGroup{}
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for downloadSize < len(buf) && readErr == nil {
+ nn, readErr = resp.Body.Read(buf[downloadSize:])
+ nn64 = int64(nn)
+
+ // 更新速度统计
+ downloadStatus.AddSpeedsDownloaded(nn64)
+ downloadStatus.AddDownloaded(nn64)
+ downloadSize += nn
+
+ if statusIndicator != nil {
+ statusIndicator(downloadStatus)
+ }
+ }
+ }()
+ wg.Wait()
+
+ if int64(downloadSize) == target.size {
+ // 下载完成
+ fmt.Printf("\n下载完毕\n")
+ } else {
+ fmt.Printf("\n下载更新文件失败\n")
+ return
+ }
+
+ // 读取文件
+ reader, err := zip.NewReader(bytes.NewReader(buf), target.size)
+ if err != nil {
+ fmt.Printf("读取更新文件发生错误: %s\n", err)
+ return
+ }
+
+ execPath := cmdutil.ExecutablePath()
+
+ var fileNum, errTimes int
+ for _, zipFile := range reader.File {
+ if zipFile == nil {
+ continue
+ }
+
+ info := zipFile.FileInfo()
+
+ if info.IsDir() {
+ continue
+ }
+
+ rc, err := zipFile.Open()
+ if err != nil {
+ fmt.Printf("解析 zip 文件错误: %s\n", err)
+ continue
+ }
+
+ fileNum++
+
+ name := zipFile.Name[strings.Index(zipFile.Name, "/")+1:]
+ if name == ReleaseName {
+ err = update(cmdutil.Executable(), rc)
+ } else {
+ err = update(filepath.Join(execPath, name), rc)
+ }
+
+ if err != nil {
+ errTimes++
+ fmt.Printf("发生错误, zip 路径: %s, 错误: %s\n", zipFile.Name, err)
+ continue
+ }
+ }
+
+ if errTimes == fileNum {
+ fmt.Printf("更新失败\n")
+ return
+ }
+
+ fmt.Printf("更新完毕, 请重启程序\n")
+}
diff --git a/internal/panupdate/updatefile.go b/internal/panupdate/updatefile.go
new file mode 100644
index 0000000..ffa88b1
--- /dev/null
+++ b/internal/panupdate/updatefile.go
@@ -0,0 +1,59 @@
+// 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 panupdate
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+func update(targetPath string, src io.Reader) error {
+ info, err := os.Stat(targetPath)
+ if err != nil {
+ fmt.Printf("Warning: %s\n", err)
+ return nil
+ }
+
+ privMode := info.Mode()
+
+ oldPath := filepath.Join(filepath.Dir(targetPath), "old-"+filepath.Base(targetPath))
+
+ err = os.Rename(targetPath, oldPath)
+ if err != nil {
+ return err
+ }
+
+ newFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY, privMode)
+ if err != nil {
+ return err
+ }
+
+ _, err = io.Copy(newFile, src)
+ if err != nil {
+ return err
+ }
+
+ err = newFile.Close()
+ if err != nil {
+ fmt.Printf("Warning: 关闭文件发生错误: %s\n", err)
+ }
+
+ err = os.Remove(oldPath)
+ if err != nil {
+ fmt.Printf("Warning: 移除旧文件发生错误: %s\n", err)
+ }
+ return nil
+}
diff --git a/internal/taskframework/executor.go b/internal/taskframework/executor.go
new file mode 100644
index 0000000..b83e2df
--- /dev/null
+++ b/internal/taskframework/executor.go
@@ -0,0 +1,184 @@
+// 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 taskframework
+
+import (
+ "github.com/GeertJohan/go.incremental"
+ "github.com/oleiade/lane"
+ "github.com/tickstep/aliyunpan/internal/waitgroup"
+ "strconv"
+ "time"
+)
+
+type (
+ TaskExecutor struct {
+ incr *incremental.Int // 任务id生成
+ deque *lane.Deque // 队列
+ parallel int // 任务的最大并发量
+
+ // 是否统计失败队列
+ IsFailedDeque bool
+ failedDeque *lane.Deque
+ }
+)
+
+func NewTaskExecutor() *TaskExecutor {
+ return &TaskExecutor{}
+}
+
+func (te *TaskExecutor) lazyInit() {
+ if te.deque == nil {
+ te.deque = lane.NewDeque()
+ }
+ if te.incr == nil {
+ te.incr = &incremental.Int{}
+ }
+ if te.parallel < 1 {
+ te.parallel = 1
+ }
+ if te.IsFailedDeque {
+ te.failedDeque = lane.NewDeque()
+ }
+}
+
+// 设置任务的最大并发量
+func (te *TaskExecutor) SetParallel(parallel int) {
+ te.parallel = parallel
+}
+
+//Append 将任务加到任务队列末尾
+func (te *TaskExecutor) Append(unit TaskUnit, maxRetry int) *TaskInfo {
+ te.lazyInit()
+ taskInfo := &TaskInfo{
+ id: strconv.Itoa(te.incr.Next()),
+ maxRetry: maxRetry,
+ }
+ unit.SetTaskInfo(taskInfo)
+ te.deque.Append(&TaskInfoItem{
+ Info: taskInfo,
+ Unit: unit,
+ })
+ return taskInfo
+}
+
+//AppendNoRetry 将任务加到任务队列末尾, 不重试
+func (te *TaskExecutor) AppendNoRetry(unit TaskUnit) {
+ te.Append(unit, 0)
+}
+
+//Count 返回任务数量
+func (te *TaskExecutor) Count() int {
+ if te.deque == nil {
+ return 0
+ }
+ return te.deque.Size()
+}
+
+// Execute 执行任务
+// 一个任务对应一个文件上传
+func (te *TaskExecutor) Execute() {
+ te.lazyInit()
+
+ for {
+ wg := waitgroup.NewWaitGroup(te.parallel)
+ for {
+ e := te.deque.Shift()
+ if e == nil { // 任务为空
+ break
+ }
+
+ // 获取任务
+ task, ok := e.(*TaskInfoItem)
+ if !ok {
+ // type cast failed
+ }
+ wg.AddDelta()
+
+ go func(task *TaskInfoItem) {
+ defer wg.Done()
+
+ result := task.Unit.Run()
+
+ // 返回结果为空
+ if result == nil {
+ task.Unit.OnComplete(result)
+ return
+ }
+
+ if result.Succeed {
+ task.Unit.OnSuccess(result)
+ task.Unit.OnComplete(result)
+ return
+ }
+
+ // 需要进行重试
+ if result.NeedRetry {
+ // 重试次数超出限制
+ // 执行失败
+ if task.Info.IsExceedRetry() {
+ task.Unit.OnFailed(result)
+ if te.IsFailedDeque {
+ // 加入失败队列
+ te.failedDeque.Append(task)
+ }
+ task.Unit.OnComplete(result)
+ return
+ }
+
+ task.Info.retry++ // 增加重试次数
+ task.Unit.OnRetry(result) // 调用重试
+ task.Unit.OnComplete(result)
+
+ time.Sleep(task.Unit.RetryWait()) // 等待
+ te.deque.Append(task) // 重新加入队列末尾
+ return
+ }
+
+ // 执行失败
+ task.Unit.OnFailed(result)
+ if te.IsFailedDeque {
+ // 加入失败队列
+ te.failedDeque.Append(task)
+ }
+ task.Unit.OnComplete(result)
+ }(task)
+ }
+
+ wg.Wait()
+
+ // 没有任务了
+ if te.deque.Size() == 0 {
+ break
+ }
+ }
+}
+
+//FailedDeque 获取失败队列
+func (te *TaskExecutor) FailedDeque() *lane.Deque {
+ return te.failedDeque
+}
+
+//Stop 停止执行
+func (te *TaskExecutor) Stop() {
+
+}
+
+//Pause 暂停执行
+func (te *TaskExecutor) Pause() {
+
+}
+
+//Resume 恢复执行
+func (te *TaskExecutor) Resume() {
+}
diff --git a/internal/taskframework/task_unit.go b/internal/taskframework/task_unit.go
new file mode 100644
index 0000000..0e68f69
--- /dev/null
+++ b/internal/taskframework/task_unit.go
@@ -0,0 +1,52 @@
+// 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 taskframework
+
+import "time"
+
+type (
+ TaskUnit interface {
+ SetTaskInfo(info *TaskInfo)
+ // 执行任务
+ Run() (result *TaskUnitRunResult)
+ // 重试任务执行的方法
+ // 当达到最大重试次数, 执行失败
+ OnRetry(lastRunResult *TaskUnitRunResult)
+ // 每次执行成功执行的方法
+ OnSuccess(lastRunResult *TaskUnitRunResult)
+ // 每次执行失败执行的方法
+ OnFailed(lastRunResult *TaskUnitRunResult)
+ // 每次执行结束执行的方法, 不管成功失败
+ OnComplete(lastRunResult *TaskUnitRunResult)
+ // 重试等待的时间
+ RetryWait() time.Duration
+ }
+
+ // 任务单元执行结果
+ TaskUnitRunResult struct {
+ Succeed bool // 是否执行成功
+ NeedRetry bool // 是否需要重试
+
+ // 以下是额外的信息
+ Err error // 错误信息
+ ResultCode int // 结果代码
+ ResultMessage string // 结果描述
+ Extra interface{} // 额外的信息
+ }
+)
+
+var (
+ // TaskUnitRunResultSuccess 任务执行成功
+ TaskUnitRunResultSuccess = &TaskUnitRunResult{}
+)
diff --git a/internal/taskframework/taskframework_test.go b/internal/taskframework/taskframework_test.go
new file mode 100644
index 0000000..a32ca80
--- /dev/null
+++ b/internal/taskframework/taskframework_test.go
@@ -0,0 +1,72 @@
+// 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 taskframework_test
+
+import (
+ "fmt"
+ "github.com/tickstep/aliyunpan/internal/taskframework"
+ "testing"
+ "time"
+)
+
+type (
+ TestUnit struct {
+ retry bool
+ taskInfo *taskframework.TaskInfo
+ }
+)
+
+func (tu *TestUnit) SetTaskInfo(taskInfo *taskframework.TaskInfo) {
+ tu.taskInfo = taskInfo
+}
+
+func (tu *TestUnit) OnFailed(lastRunResult *taskframework.TaskUnitRunResult) {
+ fmt.Printf("[%s] error: %s, failed\n", tu.taskInfo.Id(), lastRunResult.Err)
+}
+
+func (tu *TestUnit) OnSuccess(lastRunResult *taskframework.TaskUnitRunResult) {
+ fmt.Printf("[%s] success\n", tu.taskInfo.Id())
+}
+
+func (tu *TestUnit) OnComplete(lastRunResult *taskframework.TaskUnitRunResult) {
+ fmt.Printf("[%s] complete\n", tu.taskInfo.Id())
+}
+
+func (tu *TestUnit) Run() (result *taskframework.TaskUnitRunResult) {
+ fmt.Printf("[%s] running...\n", tu.taskInfo.Id())
+ return &taskframework.TaskUnitRunResult{
+ //Succeed: true,
+ NeedRetry: true,
+ }
+}
+
+func (tu *TestUnit) OnRetry(lastRunResult *taskframework.TaskUnitRunResult) {
+ fmt.Printf("[%s] prepare retry, times [%d/%d]...\n", tu.taskInfo.Id(), tu.taskInfo.Retry(), tu.taskInfo.MaxRetry())
+}
+
+func (tu *TestUnit) RetryWait() time.Duration {
+ return 1 * time.Second
+}
+
+func TestTaskExecutor(t *testing.T) {
+ te := taskframework.NewTaskExecutor()
+ te.SetParallel(2)
+ for i := 0; i < 3; i++ {
+ tu := TestUnit{
+ retry: false,
+ }
+ te.Append(&tu, 2)
+ }
+ te.Execute()
+}
diff --git a/internal/taskframework/taskinfo.go b/internal/taskframework/taskinfo.go
new file mode 100644
index 0000000..acd889b
--- /dev/null
+++ b/internal/taskframework/taskinfo.go
@@ -0,0 +1,48 @@
+// 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 taskframework
+
+type (
+ TaskInfo struct {
+ id string
+ maxRetry int
+ retry int
+ }
+
+ TaskInfoItem struct {
+ Info *TaskInfo
+ Unit TaskUnit
+ }
+)
+
+// IsExceedRetry 重试次数达到限制
+func (t *TaskInfo) IsExceedRetry() bool {
+ return t.retry >= t.maxRetry
+}
+
+func (t *TaskInfo) Id() string {
+ return t.id
+}
+
+func (t *TaskInfo) MaxRetry() int {
+ return t.maxRetry
+}
+
+func (t *TaskInfo) SetMaxRetry(maxRetry int) {
+ t.maxRetry = maxRetry
+}
+
+func (t *TaskInfo) Retry() int {
+ return t.retry
+}
diff --git a/internal/utils/utils.go b/internal/utils/utils.go
new file mode 100644
index 0000000..6dcd926
--- /dev/null
+++ b/internal/utils/utils.go
@@ -0,0 +1,114 @@
+// 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 utils
+
+import (
+ "compress/gzip"
+ "flag"
+ "io"
+ "io/ioutil"
+ "net/http/cookiejar"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+// TrimPathPrefix 去除目录的前缀
+func TrimPathPrefix(path, prefixPath string) string {
+ if prefixPath == "/" {
+ return path
+ }
+ return strings.TrimPrefix(path, prefixPath)
+}
+
+// ContainsString 检测字符串是否在字符串数组里
+func ContainsString(ss []string, s string) bool {
+ for k := range ss {
+ if ss[k] == s {
+ return true
+ }
+ }
+ return false
+}
+
+// GetURLCookieString 返回cookie字串
+func GetURLCookieString(urlString string, jar *cookiejar.Jar) string {
+ u, _ := url.Parse(urlString)
+ cookies := jar.Cookies(u)
+ cookieString := ""
+ for _, v := range cookies {
+ cookieString += v.String() + "; "
+ }
+ cookieString = strings.TrimRight(cookieString, "; ")
+ return cookieString
+}
+
+// DecompressGZIP 对 io.Reader 数据, 进行 gzip 解压
+func DecompressGZIP(r io.Reader) ([]byte, error) {
+ gzipReader, err := gzip.NewReader(r)
+ if err != nil {
+ return nil, err
+ }
+ gzipReader.Close()
+ return ioutil.ReadAll(gzipReader)
+}
+
+// FlagProvided 检测命令行是否提供名为 name 的 flag, 支持多个name(names)
+func FlagProvided(names ...string) bool {
+ if len(names) == 0 {
+ return false
+ }
+ var targetFlag *flag.Flag
+ for _, name := range names {
+ targetFlag = flag.Lookup(name)
+ if targetFlag == nil {
+ return false
+ }
+ if targetFlag.DefValue == targetFlag.Value.String() {
+ return false
+ }
+ }
+ return true
+}
+
+// Trigger 用于触发事件
+func Trigger(f func()) {
+ if f == nil {
+ return
+ }
+ go f()
+}
+
+// TriggerOnSync 用于触发事件, 同步触发
+func TriggerOnSync(f func()) {
+ if f == nil {
+ return
+ }
+ f()
+}
+
+func ParseVersionNum(versionStr string) int {
+ versionStr = strings.ReplaceAll(versionStr, "-dev", "")
+ versionStr = strings.ReplaceAll(versionStr, "v", "")
+ versionParts := strings.Split(versionStr, ".")
+ verNum := parseInt(versionParts[0]) * 1e4 + parseInt(versionParts[1]) * 1e2 + parseInt(versionParts[2])
+ return verNum
+}
+func parseInt(numStr string) int {
+ num,e := strconv.Atoi(numStr)
+ if e != nil {
+ return 0
+ }
+ return num
+}
\ No newline at end of file
diff --git a/internal/waitgroup/wait_group.go b/internal/waitgroup/wait_group.go
new file mode 100644
index 0000000..2c7113c
--- /dev/null
+++ b/internal/waitgroup/wait_group.go
@@ -0,0 +1,70 @@
+// 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 waitgroup
+
+import "sync"
+
+// WaitGroup 在 sync.WaitGroup 的基础上, 新增线程控制功能
+type WaitGroup struct {
+ wg sync.WaitGroup
+ p chan struct{}
+
+ sync.RWMutex
+}
+
+// NewWaitGroup returns a pointer to a new `WaitGroup` object.
+// parallel 为最大并发数, 0 代表无限制
+func NewWaitGroup(parallel int) (w *WaitGroup) {
+ w = &WaitGroup{
+ wg: sync.WaitGroup{},
+ }
+
+ if parallel <= 0 {
+ return
+ }
+
+ w.p = make(chan struct{}, parallel)
+ return
+}
+
+// AddDelta sync.WaitGroup.Add(1)
+func (w *WaitGroup) AddDelta() {
+ if w.p != nil {
+ w.p <- struct{}{}
+ }
+
+ w.wg.Add(1)
+}
+
+// Done sync.WaitGroup.Done()
+func (w *WaitGroup) Done() {
+ w.wg.Done()
+
+ if w.p != nil {
+ <-w.p
+ }
+}
+
+// Wait 参照 sync.WaitGroup 的 Wait 方法
+func (w *WaitGroup) Wait() {
+ w.wg.Wait()
+ if w.p != nil {
+ close(w.p)
+ }
+}
+
+// Parallel 返回当前正在进行的任务数量
+func (w *WaitGroup) Parallel() int {
+ return len(w.p)
+}
diff --git a/internal/waitgroup/wait_group_test.go b/internal/waitgroup/wait_group_test.go
new file mode 100644
index 0000000..7da2207
--- /dev/null
+++ b/internal/waitgroup/wait_group_test.go
@@ -0,0 +1,33 @@
+// 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 waitgroup
+
+import (
+ "fmt"
+ "testing"
+ "time"
+)
+
+func TestWg(t *testing.T) {
+ wg := NewWaitGroup(2)
+ for i := 0; i < 60; i++ {
+ wg.AddDelta()
+ go func(i int) {
+ fmt.Println(i, wg.Parallel())
+ time.Sleep(1e9)
+ wg.Done()
+ }(i)
+ }
+ wg.Wait()
+}
diff --git a/library/crypto/crypto.go b/library/crypto/crypto.go
new file mode 100644
index 0000000..65c16be
--- /dev/null
+++ b/library/crypto/crypto.go
@@ -0,0 +1,183 @@
+// 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 crypto
+
+import (
+ "fmt"
+ "github.com/tickstep/library-go/archive"
+ "github.com/tickstep/library-go/crypto"
+ "io"
+ "os"
+ "strings"
+)
+
+// CryptoMethodSupport 检测是否支持加密解密方法
+func CryptoMethodSupport(method string) bool {
+ switch method {
+ case "aes-128-ctr", "aes-192-ctr", "aes-256-ctr", "aes-128-cfb", "aes-192-cfb", "aes-256-cfb", "aes-128-ofb", "aes-192-ofb", "aes-256-ofb":
+ return true
+ }
+
+ return false
+}
+
+// EncryptFile 加密本地文件
+func EncryptFile(method string, key []byte, filePath string, isGzip bool) (encryptedFilePath string, err error) {
+ if !CryptoMethodSupport(method) {
+ return "", fmt.Errorf("unknown encrypt method: %s", method)
+ }
+
+ if isGzip {
+ err = archive.GZIPCompressFile(filePath)
+ if err != nil {
+ return
+ }
+ }
+
+ plainFile, err := os.OpenFile(filePath, os.O_RDONLY, 0)
+ if err != nil {
+ return
+ }
+
+ defer plainFile.Close()
+
+ var cipherReader io.Reader
+ switch method {
+ case "aes-128-ctr":
+ cipherReader, err = crypto.Aes128CTREncrypt(crypto.Convert16bytes(key), plainFile)
+ case "aes-192-ctr":
+ cipherReader, err = crypto.Aes192CTREncrypt(crypto.Convert24bytes(key), plainFile)
+ case "aes-256-ctr":
+ cipherReader, err = crypto.Aes256CTREncrypt(crypto.Convert32bytes(key), plainFile)
+ case "aes-128-cfb":
+ cipherReader, err = crypto.Aes128CFBEncrypt(crypto.Convert16bytes(key), plainFile)
+ case "aes-192-cfb":
+ cipherReader, err = crypto.Aes192CFBEncrypt(crypto.Convert24bytes(key), plainFile)
+ case "aes-256-cfb":
+ cipherReader, err = crypto.Aes256CFBEncrypt(crypto.Convert32bytes(key), plainFile)
+ case "aes-128-ofb":
+ cipherReader, err = crypto.Aes128OFBEncrypt(crypto.Convert16bytes(key), plainFile)
+ case "aes-192-ofb":
+ cipherReader, err = crypto.Aes192OFBEncrypt(crypto.Convert24bytes(key), plainFile)
+ case "aes-256-ofb":
+ cipherReader, err = crypto.Aes256OFBEncrypt(crypto.Convert32bytes(key), plainFile)
+ default:
+ return "", fmt.Errorf("unknown encrypt method: %s", method)
+ }
+
+ if err != nil {
+ return
+ }
+
+ plainFileInfo, err := plainFile.Stat()
+ if err != nil {
+ return
+ }
+
+ encryptedFilePath = filePath + ".encrypt"
+ encryptedFile, err := os.OpenFile(encryptedFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, plainFileInfo.Mode())
+ if err != nil {
+ return
+ }
+
+ defer encryptedFile.Close()
+
+ _, err = io.Copy(encryptedFile, cipherReader)
+ if err != nil {
+ return
+ }
+
+ os.Remove(filePath)
+
+ return encryptedFilePath, nil
+}
+
+// DecryptFile 加密本地文件
+func DecryptFile(method string, key []byte, filePath string, isGzip bool) (decryptedFilePath string, err error) {
+ if !CryptoMethodSupport(method) {
+ return "", fmt.Errorf("unknown decrypt method: %s", method)
+ }
+
+ cipherFile, err := os.OpenFile(filePath, os.O_RDONLY, 0644)
+ if err != nil {
+ return
+ }
+
+ var plainReader io.Reader
+ switch method {
+ case "aes-128-ctr":
+ plainReader, err = crypto.Aes128CTRDecrypt(crypto.Convert16bytes(key), cipherFile)
+ case "aes-192-ctr":
+ plainReader, err = crypto.Aes192CTRDecrypt(crypto.Convert24bytes(key), cipherFile)
+ case "aes-256-ctr":
+ plainReader, err = crypto.Aes256CTRDecrypt(crypto.Convert32bytes(key), cipherFile)
+ case "aes-128-cfb":
+ plainReader, err = crypto.Aes128CFBDecrypt(crypto.Convert16bytes(key), cipherFile)
+ case "aes-192-cfb":
+ plainReader, err = crypto.Aes192CFBDecrypt(crypto.Convert24bytes(key), cipherFile)
+ case "aes-256-cfb":
+ plainReader, err = crypto.Aes256CFBDecrypt(crypto.Convert32bytes(key), cipherFile)
+ case "aes-128-ofb":
+ plainReader, err = crypto.Aes128OFBDecrypt(crypto.Convert16bytes(key), cipherFile)
+ case "aes-192-ofb":
+ plainReader, err = crypto.Aes192OFBDecrypt(crypto.Convert24bytes(key), cipherFile)
+ case "aes-256-ofb":
+ plainReader, err = crypto.Aes256OFBDecrypt(crypto.Convert32bytes(key), cipherFile)
+ default:
+ return "", fmt.Errorf("unknown decrypt method: %s", method)
+ }
+
+ if err != nil {
+ return
+ }
+
+ cipherFileInfo, err := cipherFile.Stat()
+ if err != nil {
+ return
+ }
+
+ decryptedFilePath = strings.TrimSuffix(filePath, ".encrypt")
+ decryptedTmpFilePath := decryptedFilePath + ".decrypted"
+ decryptedTmpFile, err := os.OpenFile(decryptedTmpFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, cipherFileInfo.Mode())
+ if err != nil {
+ return
+ }
+
+ _, err = io.Copy(decryptedTmpFile, plainReader)
+ if err != nil {
+ return
+ }
+
+ decryptedTmpFile.Close()
+ cipherFile.Close()
+
+ if isGzip {
+ err = archive.GZIPUnompressFile(decryptedTmpFilePath)
+ if err != nil {
+ os.Remove(decryptedTmpFilePath)
+ return
+ }
+
+ // 删除已加密的文件
+ os.Remove(filePath)
+ }
+
+ if filePath != decryptedFilePath {
+ os.Rename(decryptedTmpFilePath, decryptedFilePath)
+ } else {
+ decryptedFilePath = decryptedTmpFilePath
+ }
+
+ return decryptedFilePath, nil
+}
diff --git a/library/requester/transfer/download_instanceinfo.go b/library/requester/transfer/download_instanceinfo.go
new file mode 100644
index 0000000..c174d75
--- /dev/null
+++ b/library/requester/transfer/download_instanceinfo.go
@@ -0,0 +1,82 @@
+// 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 transfer
+
+import (
+ "time"
+)
+
+type (
+ //DownloadInstanceInfo 状态详细信息, 用于导出状态文件
+ DownloadInstanceInfo struct {
+ DownloadStatus *DownloadStatus
+ Ranges RangeList
+ }
+
+ // DownloadInstanceInfoExport 断点续传
+ DownloadInstanceInfoExport struct {
+ RangeGenMode RangeGenMode `json:"rangeGenMode,omitempty"`
+ TotalSize int64 `json:"totalSize,omitempty"`
+ GenBegin int64 `json:"genBegin,omitempty"`
+ BlockSize int64 `json:"blockSize,omitempty"`
+ Ranges []*Range `json:"ranges,omitempty"`
+ }
+)
+
+// GetInstanceInfo 从断点信息获取下载状态
+func (m *DownloadInstanceInfoExport) GetInstanceInfo() (eii *DownloadInstanceInfo) {
+ eii = &DownloadInstanceInfo{
+ Ranges: m.Ranges,
+ }
+
+ var downloaded int64
+ switch m.RangeGenMode {
+ case RangeGenMode_BlockSize:
+ downloaded = m.GenBegin - eii.Ranges.Len()
+ default:
+ downloaded = m.TotalSize - eii.Ranges.Len()
+ }
+ eii.DownloadStatus = &DownloadStatus{
+ startTime: time.Now(),
+ totalSize: m.TotalSize,
+ downloaded: downloaded,
+ gen: NewRangeListGenBlockSize(m.TotalSize, m.GenBegin, m.BlockSize),
+ }
+ switch m.RangeGenMode {
+ case RangeGenMode_BlockSize:
+ eii.DownloadStatus.gen = NewRangeListGenBlockSize(m.TotalSize, m.GenBegin, m.BlockSize)
+ default:
+ eii.DownloadStatus.gen = NewRangeListGenDefault(m.TotalSize, m.TotalSize, len(m.Ranges), len(m.Ranges))
+ }
+ return eii
+}
+
+// SetInstanceInfo 从下载状态导出断点信息
+func (m *DownloadInstanceInfoExport) SetInstanceInfo(eii *DownloadInstanceInfo) {
+ if eii == nil {
+ return
+ }
+
+ if eii.DownloadStatus != nil {
+ m.TotalSize = eii.DownloadStatus.TotalSize()
+ if eii.DownloadStatus.gen != nil {
+ m.GenBegin = eii.DownloadStatus.gen.LoadBegin()
+ m.BlockSize = eii.DownloadStatus.gen.LoadBlockSize()
+ m.RangeGenMode = eii.DownloadStatus.gen.RangeGenMode()
+ } else {
+ m.RangeGenMode = RangeGenMode_Default
+ }
+ }
+ m.Ranges = eii.Ranges
+}
diff --git a/library/requester/transfer/download_status.go b/library/requester/transfer/download_status.go
new file mode 100644
index 0000000..5317fbf
--- /dev/null
+++ b/library/requester/transfer/download_status.go
@@ -0,0 +1,147 @@
+// 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 transfer
+
+import (
+ "github.com/tickstep/library-go/requester/rio/speeds"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+type (
+ //DownloadStatuser 下载状态接口
+ DownloadStatuser interface {
+ TotalSize() int64
+ Downloaded() int64
+ SpeedsPerSecond() int64
+ TimeElapsed() time.Duration // 已开始时间
+ TimeLeft() time.Duration // 预计剩余时间, 负数代表未知
+ }
+
+ //DownloadStatus 下载状态及统计信息
+ DownloadStatus struct {
+ totalSize int64 // 总大小
+ downloaded int64 // 已下载的数据量
+ speedsDownloaded int64 // 用于统计速度的downloaded
+ maxSpeeds int64 // 最大下载速度
+ tmpSpeeds int64 // 缓存的速度
+ speedsStat speeds.Speeds // 速度统计 (注意对齐)
+
+ startTime time.Time // 开始下载的时间
+
+ rateLimit *speeds.RateLimit // 限速控制
+
+ gen *RangeListGen // Range生成状态
+ mu sync.Mutex
+ }
+)
+
+//NewDownloadStatus 初始化DownloadStatus
+func NewDownloadStatus() *DownloadStatus {
+ return &DownloadStatus{
+ startTime: time.Now(),
+ }
+}
+
+// SetRateLimit 设置限速
+func (ds *DownloadStatus) SetRateLimit(rl *speeds.RateLimit) {
+ ds.rateLimit = rl
+}
+
+//SetTotalSize 返回总大小
+func (ds *DownloadStatus) SetTotalSize(size int64) {
+ ds.totalSize = size
+}
+
+//AddDownloaded 增加已下载数据量
+func (ds *DownloadStatus) AddDownloaded(d int64) {
+ atomic.AddInt64(&ds.downloaded, d)
+}
+
+//AddTotalSize 增加总大小 (不支持多线程)
+func (ds *DownloadStatus) AddTotalSize(size int64) {
+ ds.totalSize += size
+}
+
+//AddSpeedsDownloaded 增加已下载数据量, 用于统计速度
+func (ds *DownloadStatus) AddSpeedsDownloaded(d int64) {
+ if ds.rateLimit != nil {
+ ds.rateLimit.Add(d)
+ }
+ ds.speedsStat.Add(d)
+}
+
+//SetMaxSpeeds 设置最大速度, 原子操作
+func (ds *DownloadStatus) SetMaxSpeeds(speeds int64) {
+ if speeds > atomic.LoadInt64(&ds.maxSpeeds) {
+ atomic.StoreInt64(&ds.maxSpeeds, speeds)
+ }
+}
+
+//ClearMaxSpeeds 清空统计最大速度, 原子操作
+func (ds *DownloadStatus) ClearMaxSpeeds() {
+ atomic.StoreInt64(&ds.maxSpeeds, 0)
+}
+
+//TotalSize 返回总大小
+func (ds *DownloadStatus) TotalSize() int64 {
+ return ds.totalSize
+}
+
+//Downloaded 返回已下载数据量
+func (ds *DownloadStatus) Downloaded() int64 {
+ return atomic.LoadInt64(&ds.downloaded)
+}
+
+// UpdateSpeeds 更新speeds
+func (ds *DownloadStatus) UpdateSpeeds() {
+ atomic.StoreInt64(&ds.tmpSpeeds, ds.speedsStat.GetSpeeds())
+}
+
+//SpeedsPerSecond 返回每秒速度
+func (ds *DownloadStatus) SpeedsPerSecond() int64 {
+ return atomic.LoadInt64(&ds.tmpSpeeds)
+}
+
+//MaxSpeeds 返回最大速度
+func (ds *DownloadStatus) MaxSpeeds() int64 {
+ return atomic.LoadInt64(&ds.maxSpeeds)
+}
+
+//TimeElapsed 返回花费的时间
+func (ds *DownloadStatus) TimeElapsed() (elapsed time.Duration) {
+ return time.Since(ds.startTime)
+}
+
+//TimeLeft 返回预计剩余时间
+func (ds *DownloadStatus) TimeLeft() (left time.Duration) {
+ speeds := atomic.LoadInt64(&ds.tmpSpeeds)
+ if speeds <= 0 {
+ left = -1
+ } else {
+ left = time.Duration((ds.totalSize-ds.downloaded)/(speeds)) * time.Second
+ }
+ return
+}
+
+// RangeListGen 返回RangeListGen
+func (ds *DownloadStatus) RangeListGen() *RangeListGen {
+ return ds.gen
+}
+
+// SetRangeListGen 设置RangeListGen
+func (ds *DownloadStatus) SetRangeListGen(gen *RangeListGen) {
+ ds.gen = gen
+}
diff --git a/library/requester/transfer/rangelist.go b/library/requester/transfer/rangelist.go
new file mode 100644
index 0000000..5ca67a9
--- /dev/null
+++ b/library/requester/transfer/rangelist.go
@@ -0,0 +1,234 @@
+// 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 transfer
+
+import (
+ "errors"
+ "fmt"
+ "github.com/tickstep/library-go/converter"
+ "sync"
+ "sync/atomic"
+)
+type (
+ Range struct {
+ Begin int64 `json:"begin,omitempty"`
+ End int64 `json:"end,omitempty"`
+ }
+
+ // RangeGenMode 线程分配方式
+ RangeGenMode int32
+
+ //RangeList 请求范围列表
+ RangeList []*Range
+
+ //RangeListGen Range 生成器
+ RangeListGen struct {
+ total int64
+ begin int64
+ blockSize int64
+ parallel int
+ count int // 已生成次数
+ rangeGenMode RangeGenMode
+ mu sync.Mutex
+ }
+)
+
+const (
+ // DefaultBlockSize 默认的BlockSize
+ DefaultBlockSize = 256 * converter.KB
+
+ // RangeGenMode_Default 根据parallel平均生成
+ RangeGenMode_Default RangeGenMode = 0
+ // RangeGenMode_BlockSize 根据blockSize生成
+ RangeGenMode_BlockSize RangeGenMode = 1
+)
+
+var (
+ // ErrUnknownRangeGenMode RangeGenMode 非法
+ ErrUnknownRangeGenMode = errors.New("Unknown RangeGenMode")
+)
+
+//Len 长度
+func (r *Range) Len() int64 {
+ return r.LoadEnd() - r.LoadBegin()
+}
+
+//LoadBegin 读取Begin, 原子操作
+func (r *Range) LoadBegin() int64 {
+ return atomic.LoadInt64(&r.Begin)
+}
+
+//AddBegin 增加Begin, 原子操作
+func (r *Range) AddBegin(i int64) (newi int64) {
+ return atomic.AddInt64(&r.Begin, i)
+}
+
+//LoadEnd 读取End, 原子操作
+func (r *Range) LoadEnd() int64 {
+ return atomic.LoadInt64(&r.End)
+}
+
+//StoreBegin 储存End, 原子操作
+func (r *Range) StoreBegin(end int64) {
+ atomic.StoreInt64(&r.Begin, end)
+}
+
+//StoreEnd 储存End, 原子操作
+func (r *Range) StoreEnd(end int64) {
+ atomic.StoreInt64(&r.End, end)
+}
+
+// ShowDetails 显示Range细节
+func (r *Range) ShowDetails() string {
+ return fmt.Sprintf("{%d-%d}", r.LoadBegin(), r.LoadEnd())
+}
+
+//Len 获取所有的Range的剩余长度
+func (rl *RangeList) Len() int64 {
+ var l int64
+ for _, wrange := range *rl {
+ if wrange == nil {
+ continue
+ }
+ l += wrange.Len()
+ }
+ return l
+}
+
+// NewRangeListGenDefault 初始化默认Range生成器, 根据parallel平均生成
+func NewRangeListGenDefault(totalSize, begin int64, count, parallel int) *RangeListGen {
+ return &RangeListGen{
+ total: totalSize,
+ begin: begin,
+ parallel: parallel,
+ count: count,
+ rangeGenMode: RangeGenMode_Default,
+ }
+}
+
+// NewRangeListGenBlockSize 初始化Range生成器, 根据blockSize生成
+func NewRangeListGenBlockSize(totalSize, begin, blockSize int64) *RangeListGen {
+ return &RangeListGen{
+ total: totalSize,
+ begin: begin,
+ blockSize: blockSize,
+ rangeGenMode: RangeGenMode_BlockSize,
+ }
+}
+
+// RangeGenMode 返回Range生成方式
+func (gen *RangeListGen) RangeGenMode() RangeGenMode {
+ return gen.rangeGenMode
+}
+
+// RangeCount 返回预计生成的Range数量
+func (gen *RangeListGen) RangeCount() (rangeCount int) {
+ switch gen.rangeGenMode {
+ case RangeGenMode_Default:
+ rangeCount = gen.parallel - gen.count
+ case RangeGenMode_BlockSize:
+ rangeCount = int((gen.total - gen.begin) / gen.blockSize)
+ if gen.total%gen.blockSize != 0 {
+ rangeCount++
+ }
+ }
+ return
+}
+
+// LoadBegin 返回begin
+func (gen *RangeListGen) LoadBegin() (begin int64) {
+ gen.mu.Lock()
+ begin = gen.begin
+ gen.mu.Unlock()
+ return
+}
+
+// LoadBlockSize 返回blockSize
+func (gen *RangeListGen) LoadBlockSize() (blockSize int64) {
+ switch gen.rangeGenMode {
+ case RangeGenMode_Default:
+ if gen.blockSize <= 0 {
+ gen.blockSize = (gen.total - gen.begin) / int64(gen.parallel)
+ }
+ blockSize = gen.blockSize
+ case RangeGenMode_BlockSize:
+ blockSize = gen.blockSize
+ }
+ return
+}
+
+// IsDone 是否已分配完成
+func (gen *RangeListGen) IsDone() bool {
+ return gen.begin >= gen.total
+}
+
+// GenRange 生成 Range
+func (gen *RangeListGen) GenRange() (index int, r *Range) {
+ var (
+ end int64
+ )
+ if gen.parallel < 1 {
+ gen.parallel = 1
+ }
+ switch gen.rangeGenMode {
+ case RangeGenMode_Default:
+ gen.LoadBlockSize()
+ gen.mu.Lock()
+ defer gen.mu.Unlock()
+
+ if gen.IsDone() {
+ return gen.count, nil
+ }
+
+ gen.count++
+ if gen.count >= gen.parallel {
+ end = gen.total
+ } else {
+ end = gen.begin + gen.blockSize
+ }
+ r = &Range{
+ Begin: gen.begin,
+ End: end,
+ }
+
+ gen.begin = end
+ index = gen.count - 1
+ return
+ case RangeGenMode_BlockSize:
+ if gen.blockSize <= 0 {
+ gen.blockSize = DefaultBlockSize
+ }
+ gen.mu.Lock()
+ defer gen.mu.Unlock()
+
+ if gen.IsDone() {
+ return gen.count, nil
+ }
+
+ gen.count++
+ end = gen.begin + gen.blockSize
+ if end >= gen.total {
+ end = gen.total
+ }
+ r = &Range{
+ Begin: gen.begin,
+ End: end,
+ }
+ gen.begin = end
+ index = gen.count - 1
+ return
+ }
+
+ return 0, nil
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..08ec7fb
--- /dev/null
+++ b/main.go
@@ -0,0 +1,507 @@
+// 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 main
+
+import (
+ "fmt"
+ "github.com/tickstep/aliyunpan-api/aliyunpan"
+ "github.com/tickstep/aliyunpan/cmder"
+ "os"
+ "path"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strings"
+ "time"
+ "unicode"
+
+ "github.com/peterh/liner"
+ "github.com/tickstep/aliyunpan/cmder/cmdliner"
+ "github.com/tickstep/aliyunpan/cmder/cmdliner/args"
+ "github.com/tickstep/aliyunpan/cmder/cmdutil"
+ "github.com/tickstep/aliyunpan/cmder/cmdutil/escaper"
+ "github.com/tickstep/aliyunpan/internal/command"
+ "github.com/tickstep/aliyunpan/internal/config"
+ "github.com/tickstep/aliyunpan/internal/panupdate"
+ "github.com/tickstep/aliyunpan/internal/utils"
+ "github.com/tickstep/library-go/converter"
+ "github.com/tickstep/library-go/logger"
+ "github.com/urfave/cli"
+)
+
+const (
+ // NameShortDisplayNum 文件名缩略显示长度
+ NameShortDisplayNum = 16
+)
+
+var (
+ // Version 版本号
+ Version = "v0.0.2"
+
+ historyFilePath = filepath.Join(config.GetConfigDir(), "aliyunpan_command_history.txt")
+
+ isCli bool
+)
+
+func init() {
+ config.AppVersion = Version
+ cmdutil.ChWorkDir()
+
+ err := config.Config.Init()
+ switch err {
+ case nil:
+ case config.ErrConfigFileNoPermission, config.ErrConfigContentsParseError:
+ fmt.Fprintf(os.Stderr, "FATAL ERROR: config file error: %s\n", err)
+ os.Exit(1)
+ default:
+ fmt.Printf("WARNING: config init error: %s\n", err)
+ }
+}
+
+func checkLoginExpiredAndRelogin() {
+ cmder.ReloadConfigFunc(nil)
+ activeUser := config.Config.ActiveUser()
+ if activeUser == nil || activeUser.UserId == "" {
+ // maybe expired, try to login
+ cmder.TryLogin()
+ } else {
+ // refresh expired token
+ if activeUser.PanClient() != nil {
+ if len(activeUser.WebToken.RefreshToken) > 0 {
+ cz := time.FixedZone("CST", 8*3600) // 东8区
+ expiredTime, _ := time.ParseInLocation("2006-01-02 15:04:05", activeUser.WebToken.ExpireTime, cz)
+ now := time.Now()
+ if (expiredTime.Unix() - now.Unix()) <= (10 * 60) {
+ // need refresh token
+ logger.Verboseln("access token expired, get new from refresh token")
+ if wt, er := aliyunpan.GetAccessTokenFromRefreshToken(activeUser.RefreshToken); er == nil {
+ activeUser.WebToken = *wt
+ activeUser.PanClient().UpdateToken(*wt)
+ logger.Verboseln("get new access token success")
+ }
+ }
+ }
+ }
+ }
+ cmder.SaveConfigFunc(nil)
+}
+
+func main() {
+ defer config.Config.Close()
+
+ // check & relogin
+ checkLoginExpiredAndRelogin()
+
+ // check token expired task
+ go func() {
+ for {
+ time.Sleep(time.Duration(5) * time.Minute)
+ //time.Sleep(time.Duration(5) * time.Second)
+ checkLoginExpiredAndRelogin()
+ }
+ }()
+
+ app := cli.NewApp()
+ cmder.SetApp(app)
+
+ app.Name = "aliyunpan"
+ app.Version = Version
+ app.Author = "tickstep/aliyunpan: https://github.com/tickstep/aliyunpan"
+ app.Copyright = "(c) 2021 tickstep."
+ app.Usage = "阿里云盘客户端 for " + runtime.GOOS + "/" + runtime.GOARCH
+ app.Description = `aliyunpan 使用Go语言编写的阿里云盘命令行客户端, 为操作阿里云盘, 提供实用功能.
+ 具体功能, 参见 COMMANDS 列表
+
+ ------------------------------------------------------------------------------
+ 前往 https://github.com/tickstep/aliyunpan 以获取更多帮助信息!
+ 前往 https://github.com/tickstep/aliyunpan/releases 以获取程序更新信息!
+ ------------------------------------------------------------------------------
+
+ 交流反馈:
+ 提交Issue: https://github.com/tickstep/aliyunpan/issues
+ 联系邮箱: tickstep@outlook.com`
+
+ // 全局options
+ app.Flags = []cli.Flag{
+ cli.BoolFlag{
+ Name: "verbose",
+ Usage: "启用调试",
+ EnvVar: config.EnvVerbose,
+ Destination: &logger.IsVerbose,
+ },
+ }
+
+ // 进入交互CLI命令行界面
+ app.Action = func(c *cli.Context) {
+ if c.NArg() != 0 {
+ fmt.Printf("未找到命令: %s\n运行命令 %s help 获取帮助\n", c.Args().Get(0), app.Name)
+ return
+ }
+
+ os.Setenv(config.EnvVerbose, c.String("verbose"))
+ isCli = true
+ logger.Verbosef("提示: 你已经开启VERBOSE调试日志\n\n")
+
+ var (
+ line = cmdliner.NewLiner()
+ err error
+ )
+
+ line.History, err = cmdliner.NewLineHistory(historyFilePath)
+ if err != nil {
+ fmt.Printf("警告: 读取历史命令文件错误, %s\n", err)
+ }
+
+ line.ReadHistory()
+ defer func() {
+ line.DoWriteHistory()
+ line.Close()
+ }()
+
+ // 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", "upload", "login", "loglist", "logout",
+ "clear", "quit", "exit", "quota", "who", "sign", "update", "who", "su", "config",
+ "drive", "export", "import", "backup",
+ }
+ closed = strings.LastIndex(line, " ") == len(line)-1
+ )
+
+ for _, cmd := range app.Commands {
+ for _, name := range cmd.Names() {
+ if !strings.HasPrefix(name, line) {
+ continue
+ }
+
+ s = append(s, name+" ")
+ }
+ }
+
+ switch numArgs {
+ case 0:
+ return
+ case 1:
+ if !closed {
+ return
+ }
+ }
+
+ 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)
+ //}
+ targetPath string
+ )
+
+ 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 {
+ targetDir = path.Join(activeUser.Workdir, targetPath)
+ if !isDir {
+ targetDir = path.Dir(targetDir)
+ }
+ }
+
+ return
+ })
+
+ fmt.Printf("提示: 方向键上下可切换历史命令.\n")
+ fmt.Printf("提示: Ctrl + A / E 跳转命令 首 / 尾.\n")
+ fmt.Printf("提示: 输入 help 获取帮助.\n")
+
+ // check update
+ cmder.ReloadConfigFunc(c)
+ if config.Config.UpdateCheckInfo.LatestVer != "" {
+ if utils.ParseVersionNum(config.Config.UpdateCheckInfo.LatestVer) > utils.ParseVersionNum(config.AppVersion) {
+ fmt.Printf("\n当前的软件版本为:%s, 现在有新版本 %s 可供更新,强烈推荐进行更新!(可以输入 update 命令进行更新)\n\n",
+ config.AppVersion, config.Config.UpdateCheckInfo.LatestVer)
+ }
+ }
+ go func() {
+ latestCheckTime := config.Config.UpdateCheckInfo.CheckTime
+ nowTime := time.Now().Unix()
+ secsOf12Hour := int64(43200)
+ if (nowTime - latestCheckTime) > secsOf12Hour {
+ releaseInfo := panupdate.GetLatestReleaseInfo(false)
+ if releaseInfo == nil {
+ logger.Verboseln("获取版本信息失败!")
+ return
+ }
+ config.Config.UpdateCheckInfo.LatestVer = releaseInfo.TagName
+ config.Config.UpdateCheckInfo.CheckTime = nowTime
+
+ // save
+ cmder.SaveConfigFunc(c)
+ }
+ }()
+
+ for {
+ var (
+ prompt string
+ activeUser = config.Config.ActiveUser()
+ )
+
+ if activeUser == nil {
+ activeUser = cmder.TryLogin()
+ }
+
+ if activeUser != nil && activeUser.Nickname != "" {
+ // 格式: aliyunpan:<工作目录> $
+ // 工作目录太长时, 会自动缩略
+ wd := "/"
+ if activeUser.IsFileDriveActive() {
+ wd = activeUser.Workdir
+ prompt = app.Name + ":" + converter.ShortDisplay(path.Base(wd), NameShortDisplayNum) + " " + activeUser.Nickname + "$ "
+ } else if activeUser.IsAlbumDriveActive() {
+ wd = activeUser.AlbumWorkdir
+ prompt = app.Name + ":" + converter.ShortDisplay(path.Base(wd), NameShortDisplayNum) + " " + activeUser.Nickname + "(相册)$ "
+ }
+
+ } else {
+ // aliyunpan >
+ prompt = app.Name + " > "
+ }
+
+ commandLine, err := line.State.Prompt(prompt)
+ switch err {
+ case liner.ErrPromptAborted:
+ return
+ case nil:
+ // continue
+ default:
+ fmt.Println(err)
+ return
+ }
+
+ line.State.AppendHistory(commandLine)
+
+ cmdArgs := args.Parse(commandLine)
+ if len(cmdArgs) == 0 {
+ continue
+ }
+
+ s := []string{os.Args[0]}
+ s = append(s, cmdArgs...)
+
+ // 恢复原始终端状态
+ // 防止运行命令时程序被结束, 终端出现异常
+ line.Pause()
+ c.App.Run(s)
+ line.Resume()
+ }
+ }
+
+ // 命令配置和对应的处理func
+ app.Commands = []cli.Command{
+ // 登录账号 login
+ command.CmdLogin(),
+
+ // 退出登录帐号 logout
+ command.CmdLogout(),
+
+ // 列出帐号列表 loglist
+ command.CmdLoglist(),
+
+ // 切换网盘 drive
+ command.CmdDrive(),
+
+ // 切换阿里账号 su
+ command.CmdSu(),
+
+ // 获取当前帐号 who
+ command.CmdWho(),
+
+ // 获取当前帐号空间配额 quota
+ command.CmdQuota(),
+
+ // 切换工作目录 cd
+ command.CmdCd(),
+
+ // 输出工作目录 pwd
+ command.CmdPwd(),
+
+ // 列出目录 ls
+ command.CmdLs(),
+
+ // 创建目录 mkdir
+ command.CmdMkdir(),
+
+ // 删除文件/目录 rm
+ command.CmdRm(),
+
+ //// 拷贝文件/目录 cp
+ //command.CmdCp(),
+ //
+ //// 拷贝文件/目录到个人云/家庭云 xcp
+ //command.CmdXcp(),
+
+ // 移动文件/目录 mv
+ command.CmdMv(),
+
+ // 重命名文件 rename
+ command.CmdRename(),
+
+ // 分享文件/目录 share
+ command.CmdShare(),
+
+ // 备份 backup
+ command.CmdBackup(),
+
+ // 上传文件/目录 upload
+ command.CmdUpload(),
+
+ // 手动秒传
+ command.CmdRapidUpload(),
+
+ // 下载文件/目录 download
+ command.CmdDownload(),
+
+ // 导出文件/目录元数据 export
+ command.CmdExport(),
+
+ // 导入文件 import
+ command.CmdImport(),
+
+ // 回收站
+ command.CmdRecycle(),
+
+ // 显示和修改程序配置项 config
+ command.CmdConfig(),
+
+ // 工具箱 tool
+ command.CmdTool(),
+
+ // 清空控制台 clear
+ {
+ Name: "clear",
+ Aliases: []string{"cls"},
+ Usage: "清空控制台",
+ UsageText: app.Name + " clear",
+ Description: "清空控制台屏幕",
+ Category: "其他",
+ Action: func(c *cli.Context) error {
+ cmdliner.ClearScreen()
+ return nil
+ },
+ },
+
+ // 检测程序更新 update
+ {
+ Name: "update",
+ Usage: "检测程序更新",
+ Category: "其他",
+ Action: func(c *cli.Context) error {
+ if c.IsSet("y") {
+ if !c.Bool("y") {
+ return nil
+ }
+ }
+ panupdate.CheckUpdate(app.Version, c.Bool("y"))
+ return nil
+ },
+ Flags: []cli.Flag{
+ cli.BoolFlag{
+ Name: "y",
+ Usage: "确认更新",
+ },
+ },
+ },
+
+ // 退出程序 quit
+ {
+ Name: "quit",
+ Aliases: []string{"exit"},
+ Usage: "退出程序",
+ Description: "退出程序",
+ Category: "其他",
+ Action: func(c *cli.Context) error {
+ return cli.NewExitError("", 0)
+ },
+ Hidden: true,
+ HideHelp: true,
+ },
+
+ // 调试用 debug
+ //{
+ // Name: "debug",
+ // Aliases: []string{"dg"},
+ // Usage: "开发调试用",
+ // Description: "",
+ // Category: "debug",
+ // Before: cmder.ReloadConfigFunc,
+ // Action: func(c *cli.Context) error {
+ // os.Setenv(config.EnvVerbose, c.String("verbose"))
+ // fmt.Println("显示调试日志", logger.IsVerbose)
+ // return nil
+ // },
+ // Flags: []cli.Flag{
+ // cli.StringFlag{
+ // Name: "param",
+ // Usage: "参数",
+ // },
+ // cli.BoolFlag{
+ // Name: "verbose",
+ // Destination: &logger.IsVerbose,
+ // EnvVar: config.EnvVerbose,
+ // Usage: "显示调试信息",
+ // },
+ // },
+ //},
+ }
+
+ sort.Sort(cli.FlagsByName(app.Flags))
+ sort.Sort(cli.CommandsByName(app.Commands))
+ app.Run(os.Args)
+}
diff --git a/resource_windows_386.syso b/resource_windows_386.syso
new file mode 100644
index 0000000..fb5b2b8
Binary files /dev/null and b/resource_windows_386.syso differ
diff --git a/resource_windows_amd64.syso b/resource_windows_amd64.syso
new file mode 100644
index 0000000..3e7f85c
Binary files /dev/null and b/resource_windows_amd64.syso differ
diff --git a/versioninfo.json b/versioninfo.json
new file mode 100644
index 0000000..f37f9b6
--- /dev/null
+++ b/versioninfo.json
@@ -0,0 +1,43 @@
+{
+ "FixedFileInfo": {
+ "FileVersion": {
+ "Major": 0,
+ "Minor": 0,
+ "Patch": 2,
+ "Build": 0
+ },
+ "ProductVersion": {
+ "Major": 0,
+ "Minor": 0,
+ "Patch": 2,
+ "Build": 0
+ },
+ "FileFlagsMask": "3f",
+ "FileFlags ": "00",
+ "FileOS": "040004",
+ "FileType": "01",
+ "FileSubType": "00"
+ },
+ "StringFileInfo": {
+ "Comments": "",
+ "CompanyName": "tickstep",
+ "FileDescription": "阿里云盘客户端",
+ "FileVersion": "v0.0.2",
+ "InternalName": "",
+ "LegalCopyright": "© 2021 tickstep.",
+ "LegalTrademarks": "",
+ "OriginalFilename": "",
+ "PrivateBuild": "",
+ "ProductName": "aliyunpan",
+ "ProductVersion": "v0.0.2",
+ "SpecialBuild": ""
+ },
+ "VarFileInfo": {
+ "Translation": {
+ "LangID": "0409",
+ "CharsetID": "04B0"
+ }
+ },
+ "IconPath": "assets/aliyunpan.ico",
+ "ManifestPath": "aliyunpan.exe.manifest"
+}
\ No newline at end of file
diff --git a/win_build.bat b/win_build.bat
new file mode 100644
index 0000000..520993e
--- /dev/null
+++ b/win_build.bat
@@ -0,0 +1,48 @@
+@echo off
+
+REM ============= build script for windows ================
+REM how to use
+REM win_build.bat v0.0.1
+REM =======================================================
+
+REM ============= variable definitions ================
+set currentDir=%CD%
+set output=out
+set name=aliyunpan
+set version=%1
+
+REM ============= build action ================
+call :build_task %name%-%version%-windows-x86 windows 386
+call :build_task %name%-%version%-windows-x64 windows amd64
+call :build_task %name%-%version%-linux-386 linux 386
+call :build_task %name%-%version%-linux-amd64 linux amd64
+call :build_task %name%-%version%-darwin-macos-amd64 darwin amd64
+
+goto:EOF
+
+REM ============= build function ================
+:build_task
+setlocal
+
+set targetName=%1
+set GOOS=%2
+set GOARCH=%3
+set goarm=%4
+set GO386=sse2
+set CGO_ENABLED=0
+set GOARM=%goarm%
+
+echo "Building %targetName% ..."
+if %GOOS% == windows (
+ goversioninfo -o=resource_windows_386.syso
+ goversioninfo -64 -o=resource_windows_amd64.syso
+ go build -ldflags "-linkmode internal -X main.Version=%version% -s -w" -o "%output%/%1/%name%.exe"
+) ^
+else (
+ go build -ldflags "-X main.Version=%version% -s -w" -o "%output%/%1/%name%"
+)
+
+copy README.md %output%\%1
+
+endlocal
+