init project

This commit is contained in:
tickstep 2021-10-10 10:48:53 +08:00
parent 66742754aa
commit 60f3e1a0a4
103 changed files with 13773 additions and 0 deletions

201
LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [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.

14
aliyunpan.exe.manifest Normal file
View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity version="0.0.2.0" processorArchitecture="*" name="com.tickstep.aliyunpan" type="win32"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
<dpiAware>true</dpiAware>
</asmv3:windowsSettings>
</asmv3:application>
</assembly>

BIN
assets/aliyunpan.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

BIN
assets/aliyunpan.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

139
build.sh Executable file
View File

@ -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

92
cmder/cmder_helper.go Normal file
View File

@ -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
}

View File

@ -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
}

17
cmder/cmdliner/clear.go Normal file
View File

@ -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")
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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}
}

45
cmder/cmdutil/addr.go Normal file
View File

@ -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
}

98
cmder/cmdutil/cmdutil.go Normal file
View File

@ -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()
}

View File

@ -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)
}
}

125
cmder/cmdutil/file.go Normal file
View File

@ -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)
}

View File

@ -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)
}

15
docs/complie_project.md Normal file
View File

@ -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图标

16
entitlements.xml Normal file
View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>application-identifier</key>
<string>com.tickstep.aliyunpan</string>
<key>get-task-allow</key>
<true/>
<key>platform-application</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>com.tickstep.aliyunpan</string>
</array>
</dict>
</plist>

22
go.mod Normal file
View File

@ -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

99
go.sum Normal file
View File

@ -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=

303
internal/command/backup.go Normal file
View File

@ -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
}

113
internal/command/cd.go Normal file
View File

@ -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)
}

396
internal/command/command.go Normal file
View File

@ -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 = `
可用的方法 <method>:
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.
密钥 <key>:
aes-128 对应key长度为16, aes-192 对应key长度为24, aes-256 对应key长度为32,
如果key长度不符合, 则自动修剪key, 舍弃超出长度的部分, 长度不足的部分用'\0'填充.
GZIP <disable-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=<method> -key=<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=<method> -key=<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",
},
},
},
},
}
}

View File

@ -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)
}

View File

@ -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 <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()
}
}

View File

@ -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 <driveId>
`,
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()
}

View File

@ -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)
}

View File

@ -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
}

118
internal/command/login.go Normal file
View File

@ -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)
}

View File

@ -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")
}

74
internal/command/mkdir.go Normal file
View File

@ -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)
}
}

131
internal/command/mv.go Normal file
View File

@ -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
}

64
internal/command/quota.go Normal file
View File

@ -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
}

256
internal/command/recycle.go Normal file
View File

@ -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 <file_id 1> <file_id 2> <file_id 3> ...",
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] <file_id 1> <file_id 2> <file_id 3> ...",
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")
}

105
internal/command/rename.go Normal file
View File

@ -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))
}

125
internal/command/rm.go Normal file
View File

@ -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
}
}

320
internal/command/share.go Normal file
View File

@ -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 <shareid_1> <shareid_2> ...",
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)
}

605
internal/command/upload.go Normal file
View File

@ -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
}

View File

@ -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 <uid or name>
`,
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()
}

95
internal/command/utils.go Normal file
View File

@ -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()
}

31
internal/config/errors.go Normal file
View File

@ -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")
)

View File

@ -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
}

View File

@ -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()
}

205
internal/config/pan_user.go Normal file
View File

@ -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"
}

107
internal/config/utils.go Normal file
View File

@ -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)
}

View File

@ -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"))
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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
}
}
}
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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())
}
}

View File

@ -0,0 +1,28 @@
// Copyright (c) 2020 tickstep.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package uploader
type (
// MultiError 多线程上传的错误
MultiError struct {
Err error
// IsRetry 是否重试,
Terminated bool
NeedStartOver bool // 是否从头开始上传
}
)
func (me *MultiError) Error() string {
return me.Err.Error()
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}
}
}()
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}
)

View File

@ -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
}

View File

@ -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("未在已分享列表中找到分享信息")
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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
}
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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")
)

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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"`
}
)

View File

@ -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")
}

View File

@ -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
}

View File

@ -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() {
}

View File

@ -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{}
)

View File

@ -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()
}

View File

@ -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
}

114
internal/utils/utils.go Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -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()
}

183
library/crypto/crypto.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

507
main.go Normal file
View File

@ -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:<工作目录> <UserName>$
// 工作目录太长时, 会自动缩略
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)
}

BIN
resource_windows_386.syso Normal file

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More