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 +