// Copyright (c) 2020 tickstep. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package command import ( "fmt" "github.com/olekukonko/tablewriter" "github.com/tickstep/aliyunpan-api/aliyunpan" "github.com/tickstep/aliyunpan-api/aliyunpan/apierror" "github.com/tickstep/aliyunpan-api/aliyunpan_web" "github.com/tickstep/aliyunpan/cmder" "github.com/tickstep/aliyunpan/cmder/cmdtable" "github.com/tickstep/aliyunpan/internal/config" "github.com/tickstep/aliyunpan/internal/file/downloader" "github.com/tickstep/aliyunpan/internal/functions/pandownload" "github.com/tickstep/aliyunpan/internal/taskframework" "github.com/tickstep/aliyunpan/internal/utils" "github.com/tickstep/aliyunpan/library/requester/transfer" "github.com/tickstep/library-go/converter" "github.com/tickstep/library-go/logger" "github.com/tickstep/library-go/requester/rio/speeds" "github.com/urfave/cli" "os" "path/filepath" "strconv" "sync/atomic" "time" ) type ( AlbumFileCategoryOption string ) var ( ImageOnlyOption AlbumFileCategoryOption = "image" VideoOnlyOption AlbumFileCategoryOption = "video" ImageVideoOnlyOption AlbumFileCategoryOption = "image_video" AllFileOption AlbumFileCategoryOption = "none" ) func CmdAlbum() cli.Command { return cli.Command{ Name: "album", Aliases: []string{"abm"}, Usage: "个人相册(Beta)", UsageText: cmder.App().Name + " album", Category: "阿里云盘", Before: ReloadConfigFunc, Action: func(c *cli.Context) error { cli.ShowCommandHelp(c, c.Command.Name) return nil }, Subcommands: []cli.Command{ { Name: "list", Aliases: []string{"ls"}, Usage: "展示相簿列表", UsageText: cmder.App().Name + " album list", Description: ` 示例: 展示相簿列表 aliyunpan album ls `, Action: func(c *cli.Context) error { if config.Config.ActiveUser() == nil { fmt.Println("未登录账号") return nil } if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil { fmt.Println("WEB客户端未登录,请登录后再使用该命令") return nil } RunAlbumList() return nil }, Flags: []cli.Flag{}, }, { Name: "new", Aliases: []string{""}, Usage: "创建相簿", UsageText: cmder.App().Name + " album new", Description: ` 示例: 新建相簿,名称为:我的相簿2022 aliyunpan album new "我的相簿2022" 新建相簿,名称为:我的相簿2022,描述为:存放2022所有文件 aliyunpan album new "我的相簿2022" "存放2022所有文件" `, Action: func(c *cli.Context) error { if config.Config.ActiveUser() == nil { fmt.Println("未登录账号") return nil } if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil { fmt.Println("WEB客户端未登录,请登录后再使用该命令") return nil } RunAlbumCreate(c.Args().Get(0), c.Args().Get(1)) return nil }, Flags: []cli.Flag{}, }, { Name: "rm", Aliases: []string{""}, Usage: "删除相簿", UsageText: cmder.App().Name + " album rm", Description: ` 删除相簿,同名的相簿只会删除第一个符合条件的 示例: 删除名称为"我的相簿2022"的相簿 aliyunpan album rm "我的相簿2022" 删除名称为"我的相簿2022-1" 和 "我的相簿2022-2"的相簿 aliyunpan album rm "我的相簿2022-1" "我的相簿2022-2" `, Action: func(c *cli.Context) error { if config.Config.ActiveUser() == nil { fmt.Println("未登录账号") return nil } if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil { fmt.Println("WEB客户端未登录,请登录后再使用该命令") return nil } RunAlbumDelete(c.Args()) return nil }, Flags: []cli.Flag{}, }, { Name: "rename", Aliases: []string{""}, Usage: "重命名相簿", UsageText: cmder.App().Name + " album rename", Description: ` 重命名相簿,同名的相簿只会修改第一个符合条件的 示例: 重命名相簿"我的相簿2022"为新的名称"我的相簿2022-new" aliyunpan album rename "我的相簿2022" "我的相簿2022-new" `, Action: func(c *cli.Context) error { if config.Config.ActiveUser() == nil { fmt.Println("未登录账号") return nil } if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil { fmt.Println("WEB客户端未登录,请登录后再使用该命令") return nil } RunAlbumRename(c.Args().Get(0), c.Args().Get(1)) return nil }, Flags: []cli.Flag{}, }, { Name: "list-file", Aliases: []string{"lf"}, Usage: "展示相簿中的文件", UsageText: cmder.App().Name + " album list-file", Description: ` 展示相簿中文件,同名的相簿只会展示第一个符合条件的 示例: 展示相簿中文件"我的相簿2022" aliyunpan album list-file "我的相簿2022" `, Action: func(c *cli.Context) error { if config.Config.ActiveUser() == nil { fmt.Println("未登录账号") return nil } if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil { fmt.Println("WEB客户端未登录,请登录后再使用该命令") return nil } RunAlbumListFile(c.Args().Get(0)) return nil }, Flags: []cli.Flag{}, }, { Name: "rm-file", Aliases: []string{"rf"}, Usage: "移除相簿中的文件", UsageText: cmder.App().Name + " album rm-file", Description: ` 移除相簿中的文件,同名的相簿只会移除第一个符合条件的 示例: 移除相簿 "我的相簿2022" 中的文件 1.png 2.png aliyunpan album rm-file 我的相簿2022 1.png 2.png `, Action: func(c *cli.Context) error { if config.Config.ActiveUser() == nil { fmt.Println("未登录账号") return nil } if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil { fmt.Println("WEB客户端未登录,请登录后再使用该命令") return nil } subArgs := c.Args() if len(subArgs) < 2 { fmt.Println("请指定移除的文件") return nil } RunAlbumRmFile(subArgs[0], subArgs[1:]) return nil }, Flags: []cli.Flag{}, }, { Name: "add-file", Aliases: []string{"af"}, Usage: "增加(文件/相册)网盘文件到相簿中", UsageText: cmder.App().Name + " album add-file", Description: ` 增加文件到相簿中 示例: 增加当前目录下的 1.png 2.png 文件到相簿 "我的相簿2022" 中 aliyunpan album add-file 我的相簿2022 1.png 2.png 增加当前目录下的 myFolder 文件夹下所有文件到相簿 "我的相簿2022" 中 aliyunpan album add-file 我的相簿2022 myFolder `, Action: func(c *cli.Context) error { if config.Config.ActiveUser() == nil { fmt.Println("未登录账号") return nil } if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil { fmt.Println("WEB客户端未登录,请登录后再使用该命令") return nil } subArgs := c.Args() if len(subArgs) < 2 { fmt.Println("请指定增加的文件") return nil } RunAlbumAddFile(subArgs[0], subArgs[1:], ImageVideoOnlyOption) return nil }, Flags: []cli.Flag{}, }, { Name: "download-file", Aliases: []string{"df"}, Usage: "下载相簿中的所有文件到本地", UsageText: cmder.App().Name + " album download-file", Description: ` 下载相簿中的所有文件 示例: 下载相簿 "我的相簿2022" 里面的所有文件 aliyunpan album download-file 我的相簿2022 `, Action: func(c *cli.Context) error { if config.Config.ActiveUser() == nil { fmt.Println("未登录账号") return nil } if config.Config.ActiveUser().PanClient().WebapiPanClient() == nil { fmt.Println("WEB客户端未登录,请登录后再使用该命令") return nil } subArgs := c.Args() if len(subArgs) == 0 { fmt.Println("请指定下载的相簿名称") return nil } // 处理saveTo var ( saveTo string ) if c.String("saveto") != "" { saveTo = filepath.Clean(c.String("saveto")) } do := &DownloadOptions{ IsPrintStatus: false, IsExecutedPermission: false, IsOverwrite: c.Bool("ow"), SaveTo: saveTo, Parallel: 0, Load: 0, MaxRetry: pandownload.DefaultDownloadMaxRetry, NoCheck: false, ShowProgress: !c.Bool("np"), DriveId: parseDriveId(c), ExcludeNames: []string{}, } RunAlbumDownloadFile(c.Args(), do) return nil }, Flags: []cli.Flag{ cli.BoolFlag{ Name: "ow", Usage: "overwrite, 覆盖已存在的文件", }, cli.StringFlag{ Name: "saveto", Usage: "将下载的文件直接保存到指定的目录", }, cli.BoolFlag{ Name: "np", Usage: "no progress 不展示下载进度条", }, }, }, }, } } func RunAlbumList() { activeUser := GetActiveUser() records, err := activeUser.PanClient().WebapiPanClient().AlbumListGetAll(&aliyunpan_web.AlbumListParam{}) if err != nil { fmt.Printf("获取相簿列表失败: %s\n", err) return } tb := cmdtable.NewTable(os.Stdout) tb.SetHeader([]string{"#", "ALBUM_ID", "名称", "文件数量", "创建日期", "修改日期"}) tb.SetColumnAlignment([]int{tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_CENTER, tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_DEFAULT}) for k, record := range records { tb.Append([]string{strconv.Itoa(k + 1), record.AlbumId, record.Name, strconv.Itoa(record.FileCount), record.CreatedAtStr(), record.UpdatedAtStr()}) } tb.Render() } func RunAlbumCreate(name, description string) { if name == "" { fmt.Printf("相簿名称不能为空\n") return } activeUser := GetActiveUser() _, err := activeUser.PanClient().WebapiPanClient().AlbumCreate(&aliyunpan_web.AlbumCreateParam{ Name: name, Description: description, }) if err != nil { fmt.Printf("创建相簿失败: %s\n", err) return } fmt.Printf("创建相簿成功: %s\n", name) } func RunAlbumDelete(nameList []string) { if len(nameList) == 0 { fmt.Printf("相簿名称不能为空\n") return } activeUser := GetActiveUser() records, err := activeUser.PanClient().WebapiPanClient().AlbumListGetAll(&aliyunpan_web.AlbumListParam{}) if err != nil { fmt.Printf("获取相簿列表失败: %s\n", err) return } for _, record := range records { for i, name := range nameList { if name == record.Name { nameList = append(nameList[:i], nameList[i+1:]...) _, err := activeUser.PanClient().WebapiPanClient().AlbumDelete(&aliyunpan_web.AlbumDeleteParam{ AlbumId: record.AlbumId, }) if err != nil { fmt.Printf("删除相簿失败: %s\n", name) return } else { fmt.Printf("删除相簿成功: %s\n", name) } break } } } } func getAlbumFromName(activeUser *config.PanUser, name string) *aliyunpan_web.AlbumEntity { records, err := activeUser.PanClient().WebapiPanClient().AlbumListGetAll(&aliyunpan_web.AlbumListParam{}) if err != nil { fmt.Printf("获取相簿列表失败: %s\n", err) return nil } for _, record := range records { if name == record.Name { return record } } return nil } func RunAlbumRename(name, newName string) { if len(name) == 0 { fmt.Printf("相簿名称不能为空\n") return } if len(newName) == 0 { fmt.Printf("相簿名称不能为空\n") return } activeUser := GetActiveUser() record := getAlbumFromName(activeUser, name) if record == nil { return } _, err := activeUser.PanClient().WebapiPanClient().AlbumEdit(&aliyunpan_web.AlbumEditParam{ AlbumId: record.AlbumId, Description: record.Description, Name: newName, }) if err != nil { fmt.Printf("重命名相簿失败: %s\n", name) return } else { fmt.Printf("重命名相簿成功: %s -> %s\n", name, newName) } } func RunAlbumListFile(name string) { if len(name) == 0 { fmt.Printf("相簿名称不能为空\n") return } activeUser := GetActiveUser() record := getAlbumFromName(activeUser, name) if record == nil { return } fileList, er := activeUser.PanClient().WebapiPanClient().AlbumListFileGetAll(&aliyunpan_web.AlbumListFileParam{ AlbumId: record.AlbumId, }) if er != nil { fmt.Printf("获取相簿文件列表失败:%s\n", er) return } renderTable(opLs, false, "", fileList) } func RunAlbumRmFile(name string, nameList []string) { if len(name) == 0 { fmt.Printf("相簿名称不能为空\n") return } if len(nameList) == 0 { fmt.Printf("指定文件不能为空\n") return } activeUser := GetActiveUser() album := getAlbumFromName(activeUser, name) if album == nil { return } fileList, er := activeUser.PanClient().WebapiPanClient().AlbumListFileGetAll(&aliyunpan_web.AlbumListFileParam{ AlbumId: album.AlbumId, }) if er != nil { fmt.Printf("获取相簿文件列表失败:%s\n", er) return } param := &aliyunpan_web.AlbumDeleteFileParam{ AlbumId: album.AlbumId, DriveFileList: []aliyunpan.FileBatchActionParam{}, } for _, file := range fileList { if len(nameList) == 0 { break } for i, name := range nameList { if name == file.FileName { nameList = append(nameList[:i], nameList[i+1:]...) param.AddFileItem(file.DriveId, file.FileId) break } } } // 1-500 范围 if len(param.DriveFileList) == 0 { fmt.Printf("没有符合的文件\n") return } // delete file _, e := activeUser.PanClient().WebapiPanClient().AlbumDeleteFile(param) if e != nil { fmt.Printf("删除相簿文件失败:%s\n", e) return } fmt.Printf("删除相簿文件成功:%s\n", name) } // RunAlbumAddFile 增加网盘文件到相簿 func RunAlbumAddFile(albumName string, filePathList []string, filterOption AlbumFileCategoryOption) { activeUser := GetActiveUser() if albumName == "" { fmt.Printf("必须指定相簿名称\n") return } album := getAlbumFromName(activeUser, albumName) if album == nil { fmt.Printf("相簿不存在\n") return } paths, err := makePathAbsolute(activeUser.ActiveDriveId, filePathList...) if err != nil { fmt.Println(err) return } if len(paths) == 0 { fmt.Printf("没有有效的文件\n") return } fmt.Printf("正在获取增加的文件信息,该操作可能会非常耗费时间,请耐心等待...\n") param := &aliyunpan_web.AlbumAddFileParam{ AlbumId: album.AlbumId, DriveFileList: []aliyunpan.FileBatchActionParam{}, } for k := range paths { filePath := paths[k] fileInfo, apierr := activeUser.PanClient().WebapiPanClient().FileInfoByPath(activeUser.ActiveDriveId, filePath) if apierr != nil { fmt.Printf("获取文件信息失败: %s\n", filePath) continue } if fileInfo.IsFile() { // file if isFileMatchCondition(fileInfo, filterOption) { param.AddFileItem(fileInfo.DriveId, fileInfo.FileId) } } else { // folder activeUser.PanClient().WebapiPanClient().FilesDirectoriesRecurseList(activeUser.ActiveDriveId, fileInfo.Path, func(depth int, _ string, fd *aliyunpan.FileEntity, apiError *apierror.ApiError) bool { if apiError != nil { logger.Verbosef("%s\n", apiError) return true } if !fd.IsFolder() { if isFileMatchCondition(fd, filterOption) { param.AddFileItem(fd.DriveId, fd.FileId) } } time.Sleep(2 * time.Second) return true }) } time.Sleep(2 * time.Second) } if len(param.DriveFileList) == 0 { fmt.Printf("没有符合的文件\n") return } // add file _, e := activeUser.PanClient().WebapiPanClient().AlbumAddFile(param) if e != nil { fmt.Printf("增加相簿文件失败:%s\n", e) return } fmt.Printf("增加相簿文件成功:%s\n", albumName) } func isFileMatchCondition(fileInfo *aliyunpan.FileEntity, filterOption AlbumFileCategoryOption) bool { if fileInfo == nil { return false } if filterOption == ImageOnlyOption { return fileInfo.Category == "image" } else if filterOption == VideoOnlyOption { return fileInfo.Category == "video" } else if filterOption == ImageVideoOnlyOption { return fileInfo.Category == "image" || fileInfo.Category == "video" } else if filterOption == AllFileOption { return true } return false } func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) { if len(albumNames) == 0 { fmt.Printf("相簿名称不能为空\n") return } activeUser := GetActiveUser() activeUser.PanClient().WebapiPanClient().EnableCache() activeUser.PanClient().WebapiPanClient().ClearCache() defer activeUser.PanClient().WebapiPanClient().DisableCache() // pan token expired checker continueFlag := int32(0) atomic.StoreInt32(&continueFlag, 0) defer func() { atomic.StoreInt32(&continueFlag, 1) }() go func(flag *int32) { for atomic.LoadInt32(flag) == 0 { time.Sleep(time.Duration(1) * time.Minute) if RefreshWebTokenInNeed(activeUser, config.Config.DeviceName) { logger.Verboseln("update access token for download task") } } }(&continueFlag) if options == nil { options = &DownloadOptions{} } if options.MaxRetry < 0 { options.MaxRetry = pandownload.DefaultDownloadMaxRetry } options.IsExecutedPermission = false // 设置下载配置 cfg := &downloader.Config{ Mode: transfer.RangeGenMode_BlockSize, CacheSize: config.Config.CacheSize, BlockSize: MaxDownloadRangeSize, MaxRate: config.Config.MaxDownloadRate, InstanceStateStorageFormat: downloader.InstanceStateStorageFormatJSON, ShowProgress: options.ShowProgress, ExcludeNames: options.ExcludeNames, } if cfg.CacheSize == 0 { cfg.CacheSize = int(DownloadCacheSize) } // 设置下载最大并发量 if options.Parallel < 1 { options.Parallel = config.Config.MaxDownloadParallel if options.Parallel == 0 { options.Parallel = config.DefaultFileDownloadParallelNum } } if options.Parallel > config.MaxFileDownloadParallelNum { options.Parallel = config.MaxFileDownloadParallelNum } // 保存文件的本地根文件夹 originSaveRootPath := "" if options.SaveTo != "" { originSaveRootPath = options.SaveTo } else { // 使用默认的保存路径 originSaveRootPath = GetActiveUser().GetSavePath("") } fi, err1 := os.Stat(originSaveRootPath) if err1 != nil && !os.IsExist(err1) { os.MkdirAll(originSaveRootPath, 0777) // 首先在本地创建目录 } else { if !fi.IsDir() { fmt.Println("本地保存路径不是文件夹,请删除或者创建对应的文件夹:", originSaveRootPath) return } } fmt.Printf("\n[0] 当前文件下载最大并发量为: %d, 下载缓存为: %s\n\n", options.Parallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) var ( panClient = activeUser.PanClient() ) cfg.MaxParallel = options.Parallel var ( executor = taskframework.TaskExecutor{ IsFailedDeque: true, // 统计失败的列表 } statistic = &pandownload.DownloadStatistic{} ) // 配置执行器任务并发数,即同时下载文件并发数 executor.SetParallel(cfg.MaxParallel) // 全局速度统计 globalSpeedsStat := &speeds.Speeds{} // 处理队列 for k := range albumNames { record := getAlbumFromName(activeUser, albumNames[k]) if record == nil { continue } // 获取相簿下的所有文件 fileList, er := activeUser.PanClient().WebapiPanClient().AlbumListFileGetAll(&aliyunpan_web.AlbumListFileParam{ AlbumId: record.AlbumId, }) if er != nil { fmt.Printf("获取相簿文件出错,请稍后重试: %s\n", albumNames[k]) continue } if fileList == nil || len(fileList) == 0 { fmt.Printf("相簿里面没有文件: %s\n", albumNames[k]) continue } for _, f := range fileList { // 补全虚拟网盘路径,规则:/<相簿名称>/文件名称 f.Path = "/" + albumNames[k] + "/" + f.FileName // 生成下载项 newCfg := *cfg unit := pandownload.DownloadTaskUnit{ Cfg: &newCfg, // 复制一份新的cfg PanClient: panClient, VerbosePrinter: panCommandVerbose, PrintFormat: downloadPrintFormat(options.Load), ParentTaskExecutor: &executor, DownloadStatistic: statistic, IsPrintStatus: options.IsPrintStatus, IsExecutedPermission: options.IsExecutedPermission, IsOverwrite: options.IsOverwrite, NoCheck: options.NoCheck, FilePanPath: f.Path, DriveId: f.DriveId, // 必须使用文件的DriveId,因为一个相簿的文件会来自多个网盘(资源库/备份盘) GlobalSpeedsStat: globalSpeedsStat, FileRecorder: nil, } // TODO: 相册下载需要重构 // 设置相簿文件信息 //unit.SetFileInfo(pandownload.AlbumFileSource, f) // 设置储存的路径 if options.SaveTo != "" { unit.OriginSaveRootPath = options.SaveTo unit.SavePath = filepath.Join(options.SaveTo, f.Path) } else { // 使用默认的保存路径 unit.OriginSaveRootPath = GetActiveUser().GetSavePath("") unit.SavePath = GetActiveUser().GetSavePath(f.Path) } info := executor.Append(&unit, options.MaxRetry) fmt.Printf("[%s] 加入下载队列: %s\n", info.Id(), f.Path) } } // 开始计时 statistic.StartTimer() // 开始执行 executor.Execute() fmt.Printf("\n下载结束, 时间: %s, 数据总量: %s\n", utils.ConvertTime(statistic.Elapsed()), converter.ConvertFileSize(statistic.TotalSize(), 2)) // 输出失败的文件列表 failedList := executor.FailedDeque() if failedList.Size() != 0 { fmt.Printf("以下文件下载失败: \n") tb := cmdtable.NewTable(os.Stdout) for e := failedList.Shift(); e != nil; e = failedList.Shift() { item := e.(*taskframework.TaskInfoItem) tb.Append([]string{item.Info.Id(), item.Unit.(*pandownload.DownloadTaskUnit).FilePanPath}) } tb.Render() } }