From 3cec6b8292557d74c946e1267d2ddbb266391225 Mon Sep 17 00:00:00 2001 From: vclass <> Date: Tue, 30 Apr 2024 00:15:55 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=94=B6=E8=97=8F?= =?UTF-8?q?=E5=A4=B9=E6=9B=B4=E6=96=B0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scheduler/bilibo_job.go | 2 +- services/favour.go | 126 ++++++++++++++++++++++------------------ 2 files changed, 70 insertions(+), 58 deletions(-) diff --git a/scheduler/bilibo_job.go b/scheduler/bilibo_job.go index 0ba25e8..f141ff7 100644 --- a/scheduler/bilibo_job.go +++ b/scheduler/bilibo_job.go @@ -96,7 +96,7 @@ func (r *refreshFavListJob) SetFav() *bili_client.AllFavourFolderInfo { for _, mid := range r.bobo.ClientList() { if client, err := r.bobo.GetClient(mid); err == nil { if data, err := client.GetAllFavourFolderInfo(mid, 2, 0); err == nil { - services.SetFavourInfo(data) + services.SetFavourInfo(mid, data) return data } else { logger.Warnf("client %d get fav list error: %v", mid, err) diff --git a/services/favour.go b/services/favour.go index 111bcc9..a51d6b8 100644 --- a/services/favour.go +++ b/services/favour.go @@ -14,77 +14,89 @@ import ( "github.com/gabriel-vasile/mimetype" "github.com/maruel/natural" + "golang.org/x/exp/maps" ) -func SetFavourInfo(favInfo *bili_client.AllFavourFolderInfo) { +func SetFavourInfo(mid int, favInfo *bili_client.AllFavourFolderInfo) { if favInfo == nil { return } - for _, fav := range favInfo.List { - f := models.FavourFoldersInfo{} - db := models.GetDB() - db.Where(models.FavourFoldersInfo{ - Mid: fav.Mid, Fid: fav.Fid, - }).FirstOrInit(&f) - needUpdata := false - if f.ID == 0 { - f.Sync = consts.FAVOUR_NOT_SYNC - needUpdata = true - } - if f.Mid != fav.Mid { - f.Mid = fav.Mid - needUpdata = true - } - if f.Fid != fav.Fid { - f.Fid = fav.Fid - needUpdata = true - } + db := models.GetDB() + var existFavourInfos []models.FavourFoldersInfo + db.Model(&models.FavourFoldersInfo{}).Where("mid = ?", mid).Find(&existFavourInfos) + existMap := make(map[int]models.FavourFoldersInfo) + for _, v := range existFavourInfos { + existMap[v.Mlid] = v + } + existMlids := maps.Keys(existMap) - if f.Mlid != fav.Id { - f.Mlid = fav.Id - needUpdata = true - } + insertList := make([]*models.FavourFoldersInfo, 0) + updateList := make([]*models.FavourFoldersInfo, 0) + deleteMlids := make([]int, 0) - if f.Attr != fav.Attr { - f.Attr = fav.Attr - needUpdata = true + for _, v := range favInfo.List { + if !slices.Contains(existMlids, v.Id) { + insertList = append(insertList, &models.FavourFoldersInfo{ + Mid: mid, + Fid: v.Fid, + MediaCount: v.MediaCount, + Attr: v.Attr, + Title: v.Title, + Mlid: v.Id, + FavState: v.FavState, + Sync: consts.FAVOUR_NOT_SYNC, + }) + } else if slices.Contains(existMlids, v.Id) { + existInfo := existMap[v.Id] + if existInfo.Attr != v.Attr || existInfo.Title != v.Title || existInfo.FavState != v.FavState || existInfo.MediaCount != v.MediaCount { + updateList = append(updateList, &models.FavourFoldersInfo{ + MediaCount: v.MediaCount, + Attr: v.Attr, + Title: v.Title, + FavState: v.FavState, + }) + } + } else { + deleteMlids = append(deleteMlids, v.Id) } + } - if f.FavState != fav.FavState { - f.FavState = fav.FavState - needUpdata = true - } + if len(insertList) > 0 { + db.Create(insertList) + } - if f.MediaCount != fav.MediaCount { - f.MediaCount = fav.MediaCount - needUpdata = true - } + if len(deleteMlids) > 0 { + db.Model(&models.FavourFoldersInfo{}).Where("mlid IN (?)", deleteMlids).Delete(&models.FavourFoldersInfo{}) + } - if f.ID > 0 && f.Title != fav.Title && f.Sync == consts.FAVOUR_NEED_SYNC { - conf := config.GetConfig() - oldPath := filepath.Join( - conf.Download.Path, - strconv.Itoa(f.Mid), strings.ReplaceAll(f.Title, "/", "⁄")) - newPath := filepath.Join( - conf.Download.Path, - strconv.Itoa(f.Mid), strings.ReplaceAll(fav.Title, "/", "⁄")) - if _, err := os.Stat(oldPath); os.IsExist(err) { - os.MkdirAll(newPath, os.ModePerm) - if f, err := os.ReadDir(oldPath); err == nil { - for _, v := range f { - os.Rename(filepath.Join(oldPath, v.Name()), filepath.Join(newPath, v.Name())) + if len(updateList) > 0 { + conf := config.GetConfig() + for _, updateData := range updateList { + existInfo := existMap[updateData.Mlid] + oldTitle := strings.ReplaceAll(existInfo.Title, "/", "⁄") + newTitle := strings.ReplaceAll(updateData.Title, "/", "⁄") + if newTitle != oldTitle { + oldPath := filepath.Join( + conf.Download.Path, + strconv.Itoa(existInfo.Mid), + oldTitle, + ) + newPath := filepath.Join( + conf.Download.Path, + strconv.Itoa(updateData.Mid), + newTitle, + ) + if _, err := os.Stat(oldPath); os.IsExist(err) { + os.MkdirAll(newPath, os.ModePerm) + if f, err := os.ReadDir(oldPath); err == nil { + for _, v := range f { + os.Rename(filepath.Join(oldPath, v.Name()), filepath.Join(newPath, v.Name())) + } + os.Remove(oldPath) } - os.Remove(oldPath) } } - } - - if f.Title != fav.Title { - f.Title = fav.Title - needUpdata = true - } - if needUpdata { - db.Save(&f) + db.Model(&models.FavourFoldersInfo{}).Where("id = ?", existInfo.ID).Updates(updateData) } } } From 21c4b8087a5ae235b5c56b7a536973185335278f Mon Sep 17 00:00:00 2001 From: vclass <> Date: Tue, 30 Apr 2024 17:27:03 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=88=E6=9C=AA?= =?UTF-8?q?=E5=AE=8C=E6=88=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- build.sh | 4 +- config/config.go | 4 + consts/common.go | 13 ++ main.go | 17 +-- models/db.go | 1 - models/models.go | 11 -- realtime_job/favour.go | 74 +++++++++ services/account.go | 104 ------------- services/favour.go | 186 +---------------------- services/favour_video.go | 97 ------------ services/task.go | 25 --- tests/init.go | 31 ++++ tests/realtime_job_test.go | 209 ++++++++++++++++++++++++++ utils/file_utils.go | 58 +++++++ router.go => web/router.go | 4 +- web/services/account.go | 117 ++++++++++++++ web/services/favour.go | 138 +++++++++++++++++ web/services/favour_video.go | 103 +++++++++++++ {views => web/views}/AccountViews.go | 7 +- {views => web/views}/DistViews.go | 0 {views => web/views}/FavVideoViews.go | 2 +- {views => web/views}/FavViews.go | 2 +- {views => web/views}/TaskViews.go | 0 web/web.go | 25 +++ 25 files changed, 792 insertions(+), 445 deletions(-) create mode 100644 realtime_job/favour.go delete mode 100644 services/task.go create mode 100644 tests/init.go create mode 100644 tests/realtime_job_test.go create mode 100644 utils/file_utils.go rename router.go => web/router.go (87%) create mode 100644 web/services/account.go create mode 100644 web/services/favour.go create mode 100644 web/services/favour_video.go rename {views => web/views}/AccountViews.go (97%) rename {views => web/views}/DistViews.go (100%) rename {views => web/views}/FavVideoViews.go (97%) rename {views => web/views}/FavViews.go (99%) rename {views => web/views}/TaskViews.go (100%) create mode 100644 web/web.go diff --git a/.gitignore b/.gitignore index 4165f77..42b63ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .vscode/* config.yaml data.db -dist/* -build/* \ No newline at end of file +web/dist/* +build/* +bilibo \ No newline at end of file diff --git a/build.sh b/build.sh index 16c01d5..fe30364 100755 --- a/build.sh +++ b/build.sh @@ -3,8 +3,8 @@ appName="bilibo" FetchWeb() { - rm -rf dist - curl -L https://github.com/BoredTape/bilibo-web/releases/latest/download/dist.tar.gz -o dist.tar.gz + rm -rf ./web/dist + curl -L https://github.com/BoredTape/bilibo-web/releases/latest/download/dist.tar.gz -o ./web/dist.tar.gz tar -zxvf dist.tar.gz rm -rf dist.tar.gz } diff --git a/config/config.go b/config/config.go index f60c440..dd2352d 100644 --- a/config/config.go +++ b/config/config.go @@ -52,3 +52,7 @@ func InitConfig() { func GetConfig() *Config { return c } + +func SetConfig(conf *Config) { + c = conf +} diff --git a/consts/common.go b/consts/common.go index 0de2d73..dde51ac 100644 --- a/consts/common.go +++ b/consts/common.go @@ -40,3 +40,16 @@ const ( VIDEO_MESSAGE_ERROR = 999 VIDEO_MESSAGE_SUCCESS = 0 ) + +const ( + ACCOUNT_DIR_FAVOUR = "收藏夹" + ACCOUNT_DIR_RECYCLE = "回收站" + // ACCOUNT_DIR_WATCH_LATER = "稍后再看" +) + +func GET_ACCOUNT_DIR() []string { + return []string{ + ACCOUNT_DIR_FAVOUR, + ACCOUNT_DIR_RECYCLE, + } +} diff --git a/main.go b/main.go index 26ab961..1a394fd 100644 --- a/main.go +++ b/main.go @@ -8,12 +8,10 @@ import ( "bilibo/models" "bilibo/scheduler" "bilibo/services" + "bilibo/web" "context" - "fmt" "os" "path/filepath" - - "github.com/gin-gonic/gin" ) func init() { @@ -26,7 +24,6 @@ func init() { } func main() { - logger := log.GetLogger() conf := config.GetConfig() os.RemoveAll(filepath.Join(conf.Download.Path, ".tmp")) os.MkdirAll(filepath.Join(conf.Download.Path, ".tmp"), os.ModePerm) @@ -40,15 +37,5 @@ func main() { go download.AccountDownload(clientId, ctx) bobo.ClientSetCancal(clientId, cancel) } - gin.SetMode(gin.ReleaseMode) - r := gin.Default() - Route(r) - - for _, route := range r.Routes() { - logger.Infof("%s [%s]", route.Path, route.Method) - } - logger.Infof("web server running on %s:%d", conf.Server.Host, conf.Server.Port) - if err := r.Run(fmt.Sprintf("%s:%d", conf.Server.Host, conf.Server.Port)); err != nil { - panic(err) - } + web.Run() } diff --git a/models/db.go b/models/db.go index d0ceb48..bdc2a6c 100644 --- a/models/db.go +++ b/models/db.go @@ -28,7 +28,6 @@ func InitDB(driver, dsn string) { db.AutoMigrate( &BiliAccounts{}, - &Tasks{}, &FavourFoldersInfo{}, &FavourVideos{}, &QRCode{}, diff --git a/models/models.go b/models/models.go index f2c5c88..050a7c9 100644 --- a/models/models.go +++ b/models/models.go @@ -21,17 +21,6 @@ func (ba *BiliAccounts) TableName() string { return "bili_accounts" } -type Tasks struct { - gorm.Model - Mid int - JobId int - Type int -} - -func (t *Tasks) TableName() string { - return "tasks" -} - type FavourFoldersInfo struct { gorm.Model Mlid int // 收藏夹mlid(完整id),收藏夹原始id+创建者mid尾号2位 diff --git a/realtime_job/favour.go b/realtime_job/favour.go new file mode 100644 index 0000000..1b446cf --- /dev/null +++ b/realtime_job/favour.go @@ -0,0 +1,74 @@ +package realtime_job + +import ( + "bilibo/config" + "bilibo/consts" + "bilibo/log" + "bilibo/models" + "bilibo/utils" + "fmt" + "strings" + "time" +) + +func ChangeFavourName(mlid int, oldPath, newPath string) { + db := models.GetDB() + logger := log.GetLogger() + sqlPause := "UPDATE favour_videos SET status=status+100 WHERE status IN (?) AND deleted_at IS NULL;" + value := []int{ + consts.VIDEO_STATUS_TO_BE_DOWNLOAD, + consts.VIDEO_STATUS_DOWNLOAD_FAIL, + consts.VIDEO_STATUS_DOWNLOAD_RETRY, + } + db.Exec(sqlPause, value) + for { + logger.Info(fmt.Sprintf("收藏夹路径更改:\n%s => %s", oldPath, newPath)) + var downloadingCount int64 + db.Model(&models.FavourVideos{}).Where( + "mlid = ? AND status = ?", mlid, consts.VIDEO_STATUS_DOWNLOADING, + ).Count(&downloadingCount) + fmt.Println(downloadingCount) + if downloadingCount == 0 { + fmt.Println(oldPath, "\n", newPath) + if err := utils.RenameDir(oldPath, newPath); err != nil { + logger.Error(err.Error()) + } + sqlContinue := "UPDATE favour_videos SET status=status-100 WHERE status > 100 AND deleted_at IS NULL;" + db.Exec(sqlContinue) + break + } else { + logger.Info(fmt.Sprintf("收藏夹路径 %s 正在下载,重试中...", oldPath)) + } + time.Sleep(2 * time.Second) + } +} + +func DeleteFavours(mlids []int) { + db := models.GetDB() + logger := log.GetLogger() + favInfos := []models.FavourFoldersInfo{} + db.Where("mlid IN (?)", mlids).Find(&favInfos) + conf := config.GetConfig() + basePath := conf.Download.Path + db.Where( + "mlid IN (?) AND status != ?", mlids, consts.VIDEO_STATUS_DOWNLOADING, + ).Delete(&models.FavourVideos{}) + for { + logger.Info(fmt.Sprintf("删除收藏夹,收藏夹IDs:[%s]", strings.Trim(strings.Replace(fmt.Sprint(mlids), " ", ",", -1), "[]"))) + var downloadingCount int64 + db.Model(&models.FavourVideos{}).Where( + "mlid IN (?) AND status = ?", mlids, consts.VIDEO_STATUS_DOWNLOADING, + ).Count(&downloadingCount) + if downloadingCount == 0 { + db.Where("mlid IN (?)", mlids).Delete(&models.FavourFoldersInfo{}) + db.Where("mlid IN (?)", mlids).Delete(&models.FavourVideos{}) + for _, fav := range favInfos { + utils.RecyclePath(fav.Mid, basePath, utils.Name(fav.Title)) + } + break + } else { + logger.Info(fmt.Sprintf("收藏夹视频正在下载,重试中...")) + } + time.Sleep(2 * time.Second) + } +} diff --git a/services/account.go b/services/account.go index b92febf..28e6b56 100644 --- a/services/account.go +++ b/services/account.go @@ -1,27 +1,9 @@ package services import ( - "bilibo/consts" "bilibo/models" - "fmt" - "net/url" - - "golang.org/x/exp/maps" ) -func SaveAccountInfo(mid int, uname, face, cookies, imgKey, subKey string) { - db := models.GetDB() - account := models.BiliAccounts{} - db.Where(models.BiliAccounts{Mid: mid}).FirstOrInit(&account) - account.Cookies = cookies - account.ImgKey = imgKey - account.SubKey = subKey - account.Status = consts.ACCOUNT_STATUS_NORMAL - account.UName = uname - account.Face = face - db.Save(&account) -} - func UpdateAccountWBI(mid int, imgKey, subKey string) { db := models.GetDB() account := models.BiliAccounts{} @@ -47,98 +29,12 @@ func SetAccountStatus(mid int, status int) { } } -func DelAccount(mid int) { - db := models.GetDB() - db.Where(models.BiliAccounts{Mid: mid}).Delete(&models.BiliAccounts{}) -} - -func AddQRCodeInfo(qrId string) { - db := models.GetDB() - qrcode := models.QRCode{QRID: qrId} - qrcode.Status = consts.QRCODE_STATUS_NOT_SCAN - db.Save(&qrcode) -} - -func GetQRCodeInfo(qrId string) *models.QRCode { - db := models.GetDB() - var qrcode models.QRCode - db.Where(models.QRCode{QRID: qrId}).First(&qrcode) - return &qrcode -} - func DelQRCodeInfo(qrId string) { db := models.GetDB() db.Where(models.QRCode{QRID: qrId}).Delete(&models.QRCode{}) } -func SetQRCodeStatus(qrId string, status int) { - db := models.GetDB() - var qrcode models.QRCode - db.Where(models.QRCode{QRID: qrId}).First(&qrcode) - qrcode.Status = status - db.Save(&qrcode) -} - func ClearAllQRCode() { db := models.GetDB() db.Where("deleted_at IS NULL").Delete(&models.QRCode{}) } - -type AccountInfo struct { - Mid int `json:"mid"` - Uname string `json:"uname"` - Status int `json:"status"` - Face string `json:"face"` - FoldersCount int `json:"folders_count"` - Folders []*FavourFolders `json:"folders"` -} - -func AccountList(page, pageSize int) (*[]*AccountInfo, int64) { - db := models.GetDB() - accountMap := make(map[int]*AccountInfo, 0) - accountMids := make([]int, 0) - total := AccountTotal() - if total > 0 { - var datas []models.BiliAccounts - db.Model(&models.BiliAccounts{}).Order("updated_at DESC").Limit(pageSize).Offset((page - 1) * pageSize).Find(&datas) - for _, data := range datas { - item := AccountInfo{ - Mid: data.Mid, - Status: data.Status, - Face: fmt.Sprintf( - "/api/account/proxy/%d/?url=%s", - data.Mid, - url.QueryEscape(data.Face), - ), - Uname: data.UName, - Folders: make([]*FavourFolders, 0), - FoldersCount: 0, - } - accountMap[data.Mid] = &item - accountMids = append(accountMids, data.Mid) - } - - var favourFolderInfos []models.FavourFoldersInfo - db.Where("mid IN (?)", accountMids).Find(&favourFolderInfos) - for _, v := range favourFolderInfos { - folders := FavourFolders{ - Mlid: v.Mlid, - Fid: v.Fid, - Title: v.Title, - MediaCount: v.MediaCount, - Sync: v.Sync, - } - accountMap[v.Mid].Folders = append(accountMap[v.Mid].Folders, &folders) - accountMap[v.Mid].FoldersCount++ - } - } - items := maps.Values(accountMap) - return &items, total -} - -func AccountTotal() int64 { - db := models.GetDB() - var total int64 - db.Model(&models.BiliAccounts{}).Count(&total) - return total -} diff --git a/services/favour.go b/services/favour.go index a51d6b8..82cf788 100644 --- a/services/favour.go +++ b/services/favour.go @@ -5,15 +5,13 @@ import ( "bilibo/config" "bilibo/consts" "bilibo/models" - "os" + "bilibo/utils" "path/filepath" "slices" - "sort" - "strconv" "strings" - "github.com/gabriel-vasile/mimetype" - "github.com/maruel/natural" + "bilibo/realtime_job" + "golang.org/x/exp/maps" ) @@ -66,7 +64,7 @@ func SetFavourInfo(mid int, favInfo *bili_client.AllFavourFolderInfo) { } if len(deleteMlids) > 0 { - db.Model(&models.FavourFoldersInfo{}).Where("mlid IN (?)", deleteMlids).Delete(&models.FavourFoldersInfo{}) + go realtime_job.DeleteFavours(deleteMlids) } if len(updateList) > 0 { @@ -76,25 +74,10 @@ func SetFavourInfo(mid int, favInfo *bili_client.AllFavourFolderInfo) { oldTitle := strings.ReplaceAll(existInfo.Title, "/", "⁄") newTitle := strings.ReplaceAll(updateData.Title, "/", "⁄") if newTitle != oldTitle { - oldPath := filepath.Join( - conf.Download.Path, - strconv.Itoa(existInfo.Mid), - oldTitle, - ) - newPath := filepath.Join( - conf.Download.Path, - strconv.Itoa(updateData.Mid), - newTitle, - ) - if _, err := os.Stat(oldPath); os.IsExist(err) { - os.MkdirAll(newPath, os.ModePerm) - if f, err := os.ReadDir(oldPath); err == nil { - for _, v := range f { - os.Rename(filepath.Join(oldPath, v.Name()), filepath.Join(newPath, v.Name())) - } - os.Remove(oldPath) - } - } + favPath := utils.GetFavourPath(existInfo.Mid, conf.Download.Path) + oldPath := filepath.Join(favPath, oldTitle) + newPath := filepath.Join(favPath, newTitle) + go realtime_job.ChangeFavourName(updateData.Mlid, oldPath, newPath) } db.Model(&models.FavourFoldersInfo{}).Where("id = ?", existInfo.ID).Updates(updateData) } @@ -110,156 +93,3 @@ func GetFavourInfoByMlid(mlid int) *models.FavourFoldersInfo { } return &favourFolderInfo } - -func DelFavourInfoByMid(mid int) { - db := models.GetDB() - db.Where(models.FavourFoldersInfo{Mid: mid}).Delete(&models.FavourFoldersInfo{}) -} - -type FavourFolders struct { - Mlid int `json:"mlid"` - Fid int `json:"fid"` - Title string `json:"title"` - MediaCount int `json:"media_count"` - Sync int `json:"sync"` -} - -func GetAccountFavourInfoByMid(mid int) *[]*FavourFolders { - db := models.GetDB() - var favourFolderInfos []models.FavourFoldersInfo - db.Model(&models.FavourFoldersInfo{}).Where("mid = ?", mid).Find(&favourFolderInfos) - datas := make([]*FavourFolders, 0) - for _, v := range favourFolderInfos { - datas = append(datas, &FavourFolders{ - Mlid: v.Mlid, - Fid: v.Fid, - Title: v.Title, - MediaCount: v.MediaCount, - Sync: v.Sync, - }) - } - return &datas -} - -func SetFavourSyncStatus(mid, mlid, status int) { - db := models.GetDB() - db.Model(&models.FavourFoldersInfo{}).Where("mlid = ?", mlid).Update("sync", status) - if status == consts.FAVOUR_NEED_SYNC { - db.Model(&models.FavourVideos{}).Where("mid = ? AND mlid = ? AND status = ?", mid, mlid, consts.VIDEO_STATUS_INIT).Update("status", consts.VIDEO_STATUS_TO_BE_DOWNLOAD) - } -} - -type FavFile struct { - BaseName string `json:"basename"` - Extension string `json:"extension"` - ExtraMetadata []string `json:"extra_metadata"` - FileSize int64 `json:"file_size"` - LastModified int64 `json:"last_modified"` - MimeType *string `json:"mime_type"` - Path string `json:"path"` - Storage string `json:"storage"` - Type string `json:"type"` - Visibility string `json:"visibility"` -} - -func GetFavourIndex(mid, action, path string) map[string]interface{} { - result := make(map[string]interface{}) - conf := config.GetConfig() - rootPath := filepath.Join(conf.Download.Path, mid) - - result["adapter"] = mid - result["dirname"] = path - result["storages"] = []string{mid} - - subPath := filepath.Join(rootPath, strings.ReplaceAll(path, mid+"://", "/")) - fileMap := make(map[string]*FavFile) - fileNames := make([]string, 0) - dirFiles, err := os.ReadDir(subPath) - if err != nil { - return result - } - - for _, file := range dirFiles { - if fileInfo, err := file.Info(); err == nil { - file := FavFile{ - Path: mid + ":/" + filepath.Join(strings.ReplaceAll(path, mid+"://", "/"), fileInfo.Name()), - Visibility: "public", - ExtraMetadata: make([]string, 0), - FileSize: fileInfo.Size(), - LastModified: fileInfo.ModTime().Unix(), - Storage: mid, - BaseName: fileInfo.Name(), - MimeType: nil, - } - if fileInfo.IsDir() { - file.Type = "dir" - file.Extension = "" - } else { - file.Type = "file" - } - fileNames = append(fileNames, fileInfo.Name()) - fileMap[fileInfo.Name()] = &file - } - } - - if len(fileNames) < 1 { - return result - } - - sort.Sort(natural.StringSlice(fileNames)) - - files := make([]*FavFile, 0) - - for _, fileName := range fileNames { - file := fileMap[fileName] - if file.Type == "file" { - mtype, err := mimetype.DetectFile(filepath.Join(subPath, file.BaseName)) - if err != nil { - file.MimeType = nil - fextension := strings.Split(file.BaseName, ".") - slices.Reverse(fextension) - file.Extension = fextension[0] - continue - } else { - fmtype := mtype.String() - file.MimeType = &fmtype - file.Extension = strings.Replace(mtype.Extension(), ".", "", 1) - } - } - files = append(files, file) - } - - result["files"] = files - return result -} - -// func GetFavourFilePreview(mid, action, path string) (string, error) { -// filePath, err := GetFavourFileDownload(mid, action, path) -// if action == "download" { -// return filePath, err -// } -// mtype, err := mimetype.DetectFile(filePath) -// if err != nil { -// return filePath, err -// } -// if strings.Contains(mtype.String(), "video") { -// if _, err = os.Stat(filePath + ".png"); err == nil { -// return filePath + ".png", nil -// } else { -// return "default_video_cover.png", nil -// } -// } -// return filePath, err - -// } - -func GetFavourFileDownload(mid, action, path string) (string, error) { - conf := config.GetConfig() - rootPath := filepath.Join(conf.Download.Path, mid) - filePath := filepath.Join(rootPath, strings.ReplaceAll(path, mid+"://", "/")) - if _, err := os.Stat(filePath); err != nil { - return "", err - } else { - return filePath, nil - } -} diff --git a/services/favour_video.go b/services/favour_video.go index 42b5586..8b10b39 100644 --- a/services/favour_video.go +++ b/services/favour_video.go @@ -4,10 +4,7 @@ import ( "bilibo/consts" "bilibo/log" "bilibo/models" - "fmt" "time" - - "golang.org/x/exp/maps" ) type FavourVideoService struct { @@ -135,100 +132,6 @@ func InitSetVideoStatus() { } } -func DelFavourVideoByMid(mid int) { - db := models.GetDB() - db.Where(models.FavourVideos{Mid: mid}).Delete(&models.FavourVideos{}) -} - -type VideoInfo struct { - Part string `json:"part"` - Title string `json:"title"` - Bvid string `json:"bvid"` - Status int `json:"status"` - Mlid int `json:"mlid"` - FavTitle string `json:"fav_title"` - Mid int `json:"mid"` - AccountName string `json:"account_name"` -} - -func handleQueryStatus(status int) []int { - statusList := []int{status} - if status == consts.VIDEO_STATUS_DOWNLOAD_FAIL { - statusList = append(statusList, consts.VIDEO_STATUS_DOWNLOAD_RETRY) - } - return statusList - -} - -func GetVideosByStatus(status, page, pageSize int) (*[]*VideoInfo, int64) { - result := make([]*VideoInfo, 0) - db := models.GetDB() - var total int64 - - statusList := handleQueryStatus(status) - - query := db.Model(&models.FavourVideos{}).Where("status IN (?)", statusList) - - query.Count(&total) - - if total > 0 { - var favourVideos []models.FavourVideos - query.Order("updated_at DESC").Limit(pageSize).Offset((page - 1) * pageSize).Find(&favourVideos) - accountMap := make(map[int]*AccountInfo, 0) - favMap := make(map[int]*FavourFolders, 0) - for _, v := range favourVideos { - accountMap[v.Mid] = nil - favMap[v.Mlid] = nil - } - - var favourFolderInfos []models.FavourFoldersInfo - db.Where("mid IN (?)", maps.Keys(accountMap)).Find(&favourFolderInfos) - for _, v := range favourFolderInfos { - favMap[v.Mlid] = &FavourFolders{ - Mlid: v.Mlid, - Fid: v.Fid, - Title: v.Title, - MediaCount: v.MediaCount, - Sync: v.Sync, - } - } - - var accountInfos []models.BiliAccounts - db.Where("mid IN (?)", maps.Keys(accountMap)).Find(&accountInfos) - for _, v := range accountInfos { - accountMap[v.Mid] = &AccountInfo{ - Mid: v.Mid, - Status: v.Status, - Face: v.Face, - Uname: v.UName, - } - } - - for _, v := range favourVideos { - favTitle := "" - if favMap[v.Mlid] != nil { - favTitle = favMap[v.Mlid].Title - } - accountName := "" - if accountMap[v.Mid] != nil { - accountName = accountMap[v.Mid].Uname - } - result = append(result, &VideoInfo{ - Part: fmt.Sprintf("P%d %s", v.Page, v.Part), - Title: v.Title, - Bvid: v.Bvid, - Status: v.Status, - Mlid: v.Mlid, - FavTitle: favTitle, - Mid: v.Mid, - AccountName: accountName, - }) - } - } - - return &result, total -} - func SetVideoErrorMessage(Mlid, Mid int, Bvid, Error string) { logger := log.GetLogger() logger.Errorf(Error) diff --git a/services/task.go b/services/task.go deleted file mode 100644 index c1762e9..0000000 --- a/services/task.go +++ /dev/null @@ -1,25 +0,0 @@ -package services - -import ( - "bilibo/models" -) - -func SetTask(mid int, jobId int, taskType int) { - task := models.Tasks{} - db := models.GetDB() - db.Where(models.Tasks{Mid: mid, Type: taskType}).FirstOrInit(&task) - task.JobId = jobId - db.Save(&task) -} - -func DelTaskByMid(mid int) []int { - db := models.GetDB() - var tasks []models.Tasks - db.Where(models.Tasks{Mid: mid}).Find(&tasks) - jobIds := make([]int, 0) - for _, task := range tasks { - db.Delete(&task) - jobIds = append(jobIds, task.JobId) - } - return jobIds -} diff --git a/tests/init.go b/tests/init.go new file mode 100644 index 0000000..18e1a98 --- /dev/null +++ b/tests/init.go @@ -0,0 +1,31 @@ +package tests + +import ( + "bilibo/config" + "bilibo/log" + "bilibo/models" +) + +func InitConfig() { + c := config.Config{ + Server: config.ServerConfig{ + DB: config.DBConfig{ + Driver: "sqlite", + DSN: "./data.db", + }, + Host: "127.0.0.1", + Port: 8080, + }, + Download: config.DownloadConfig{ + Path: "./downloads", + }, + } + config.SetConfig(&c) +} + +func Init() { + InitConfig() + log.InitLogger() + conf := config.GetConfig() + models.InitDB(conf.Server.DB.Driver, conf.Server.DB.DSN) +} diff --git a/tests/realtime_job_test.go b/tests/realtime_job_test.go new file mode 100644 index 0000000..efaef21 --- /dev/null +++ b/tests/realtime_job_test.go @@ -0,0 +1,209 @@ +package tests + +import ( + "bilibo/config" + "bilibo/consts" + "bilibo/models" + "bilibo/realtime_job" + "bilibo/utils" + "os" + "path/filepath" + "testing" + "time" +) + +func setup() { + os.RemoveAll(config.GetConfig().Download.Path) + favPath := utils.GetFavourPath(1, config.GetConfig().Download.Path) + recyclePath := utils.GetRecyclePath(1, config.GetConfig().Download.Path) + os.MkdirAll(filepath.Join(favPath, "test"), os.ModePerm) + os.MkdirAll(recyclePath, os.ModePerm) + + db := models.GetDB() + db.Migrator().DropTable( + &models.BiliAccounts{}, + &models.FavourFoldersInfo{}, + &models.FavourVideos{}, + ) + db.AutoMigrate( + &models.BiliAccounts{}, + &models.FavourFoldersInfo{}, + &models.FavourVideos{}, + ) + + account := models.BiliAccounts{ + Mid: 1, + UName: "test", + Face: "test", + ImgKey: "test", + SubKey: "test", + Cookies: "test", + Status: consts.ACCOUNT_STATUS_NORMAL, + } + db.Save(&account) + + fav := models.FavourFoldersInfo{ + Mid: 1, + Fid: 1, + MediaCount: 1, + Attr: 1, + Title: "test", + Mlid: 1, + FavState: 1, + Sync: 1, + } + db.Save(&fav) + + video1 := models.FavourVideos{ + Mlid: 1, + Mid: 1, + Bvid: "abc1", + Cid: 1, + Page: 1, + Title: "testVideo2", + Part: "testPart2", + Width: 1, + Height: 1, + Rotate: 1, + Status: consts.VIDEO_STATUS_DOWNLOADING, + LastDownloadAt: nil, + } + db.Save(&video1) + + video2 := models.FavourVideos{ + Mlid: 1, + Mid: 1, + Bvid: "abc2", + Cid: 1, + Page: 1, + Title: "testVideo2", + Part: "testPart2", + Width: 1, + Height: 1, + Rotate: 1, + Status: consts.VIDEO_STATUS_DOWNLOAD_FAIL, + LastDownloadAt: nil, + } + db.Save(&video2) + + video3 := models.FavourVideos{ + Mlid: 1, + Mid: 1, + Bvid: "abc3", + Cid: 1, + Page: 1, + Title: "testVideo3", + Part: "testPart3", + Width: 1, + Height: 1, + Rotate: 1, + Status: consts.VIDEO_STATUS_DOWNLOAD_RETRY, + LastDownloadAt: nil, + } + db.Save(&video3) + + video4 := models.FavourVideos{ + Mlid: 1, + Mid: 1, + Bvid: "abc4", + Cid: 1, + Page: 1, + Title: "testVideo4", + Part: "testPart4", + Width: 1, + Height: 1, + Rotate: 1, + Status: consts.VIDEO_STATUS_TO_BE_DOWNLOAD, + LastDownloadAt: nil, + } + db.Save(&video4) +} + +func teardown() { + db := models.GetDB() + db.Migrator().DropTable( + &models.BiliAccounts{}, + &models.FavourFoldersInfo{}, + &models.FavourVideos{}, + ) + os.RemoveAll(config.GetConfig().Download.Path) +} + +func ChangeFavourName(t *testing.T) { + setup() + defer teardown() + + db := models.GetDB() + + var downloadingCount1 int64 + db.Model(&models.FavourVideos{}).Where( + "status IN (?)", []int{consts.VIDEO_STATUS_DOWNLOAD_FAIL, consts.VIDEO_STATUS_DOWNLOAD_RETRY, consts.VIDEO_STATUS_TO_BE_DOWNLOAD}, + ).Count(&downloadingCount1) + + if downloadingCount1 != 3 { + t.Fatal("downloadingCount != 3") + } + basePath := utils.GetFavourPath(1, config.GetConfig().Download.Path) + go realtime_job.ChangeFavourName(1, + filepath.Join(basePath, "test"), + filepath.Join(basePath, "test1"), + ) + + time.Sleep(5 * time.Second) + + var downloadingCount2 int64 + db.Model(&models.FavourVideos{}).Where( + "status < 100", + ).Count(&downloadingCount2) + if downloadingCount2 != 1 { + t.Log(downloadingCount2) + t.Fatal("downloadingCount != 0") + } + time.Sleep(2 * time.Second) + db.Model(&models.FavourVideos{}).Where( + "status = ?", consts.VIDEO_STATUS_DOWNLOADING, + ).Update("status", consts.VIDEO_STATUS_DOWNLOAD_DONE) + time.Sleep(3 * time.Second) + + favPath := utils.GetFavourPath(1, config.GetConfig().Download.Path) + _, err := os.Stat(filepath.Join(favPath, "test1")) + if err != nil { + t.Fatal(err) + } +} + +func DeleteFavour(t *testing.T) { + setup() + defer teardown() + db := models.GetDB() + go realtime_job.DeleteFavours([]int{1}) + + time.Sleep(5 * time.Second) + db.Model(&models.FavourVideos{}).Where( + "status = ?", consts.VIDEO_STATUS_DOWNLOADING, + ).Update("status", consts.VIDEO_STATUS_DOWNLOAD_DONE) + time.Sleep(3 * time.Second) + recyclePath := utils.GetRecyclePath(1, config.GetConfig().Download.Path) + dirFiles, err := os.ReadDir(recyclePath) + if err != nil { + t.Fatal(err) + } + fileNames := make([]string, 0) + + for _, file := range dirFiles { + f, err := file.Info() + if err != nil { + t.Fatal(err) + } + fileNames = append(fileNames, f.Name()) + } + if len(fileNames) == 0 { + t.Fatal("没有成功删除") + } +} + +func TestFileUtils(t *testing.T) { + Init() + ChangeFavourName(t) + DeleteFavour(t) +} diff --git a/utils/file_utils.go b/utils/file_utils.go new file mode 100644 index 0000000..741254d --- /dev/null +++ b/utils/file_utils.go @@ -0,0 +1,58 @@ +package utils + +import ( + "bilibo/consts" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +func Name(sourceName string) string { + return strings.ReplaceAll(sourceName, "/", "⁄") +} + +func getPath(mid int, basePath string, subPath string) string { + return filepath.Join( + basePath, + strconv.Itoa(mid), + subPath, + ) +} + +func GetFavourPath(mid int, basePath string) string { + return getPath(mid, basePath, consts.ACCOUNT_DIR_FAVOUR) +} + +func GetRecyclePath(mid int, basePath string) string { + return getPath(mid, basePath, consts.ACCOUNT_DIR_RECYCLE) +} + +func InitAccountPath(mid int, basePath string) error { + for _, dir := range consts.GET_ACCOUNT_DIR() { + fullDir := getPath(mid, basePath, dir) + if err := os.MkdirAll(fullDir, os.ModePerm); err != nil { + return err + } + } + return nil +} + +func RenameDir(oldPath string, newPath string) error { + if err := os.Rename(oldPath, newPath); err != nil { + return err + } + return nil +} + +func RecyclePath(mid int, basePath, favourName string) error { + recyclePath := GetRecyclePath(mid, basePath) + timeNow := time.Now() + favourPath := filepath.Join( + GetFavourPath(mid, basePath), favourName, + ) + deletePath := filepath.Join(recyclePath, fmt.Sprintf("%s_%d", favourName, timeNow.Unix())) + return RenameDir(favourPath, deletePath) +} diff --git a/router.go b/web/router.go similarity index 87% rename from router.go rename to web/router.go index 5a19c61..46d69d1 100644 --- a/router.go +++ b/web/router.go @@ -1,7 +1,7 @@ -package main +package web import ( - "bilibo/views" + "bilibo/web/views" "embed" "github.com/gin-gonic/gin" diff --git a/web/services/account.go b/web/services/account.go new file mode 100644 index 0000000..586648d --- /dev/null +++ b/web/services/account.go @@ -0,0 +1,117 @@ +package services + +import ( + "bilibo/consts" + "bilibo/models" + "fmt" + "net/url" + + "golang.org/x/exp/maps" +) + +func SaveAccountInfo(mid int, uname, face, cookies, imgKey, subKey string) { + db := models.GetDB() + account := models.BiliAccounts{} + db.Where(models.BiliAccounts{Mid: mid}).FirstOrInit(&account) + account.Cookies = cookies + account.ImgKey = imgKey + account.SubKey = subKey + account.Status = consts.ACCOUNT_STATUS_NORMAL + account.UName = uname + account.Face = face + db.Save(&account) +} + +func DelAccount(mid int) { + db := models.GetDB() + db.Where(models.BiliAccounts{Mid: mid}).Delete(&models.BiliAccounts{}) +} + +func AddQRCodeInfo(qrId string) { + db := models.GetDB() + qrcode := models.QRCode{QRID: qrId} + qrcode.Status = consts.QRCODE_STATUS_NOT_SCAN + db.Save(&qrcode) +} + +func GetQRCodeInfo(qrId string) *models.QRCode { + db := models.GetDB() + var qrcode models.QRCode + db.Where(models.QRCode{QRID: qrId}).First(&qrcode) + return &qrcode +} + +func SetQRCodeStatus(qrId string, status int) { + db := models.GetDB() + var qrcode models.QRCode + db.Where(models.QRCode{QRID: qrId}).First(&qrcode) + qrcode.Status = status + db.Save(&qrcode) +} + +type FavourFolders struct { + Mlid int `json:"mlid"` + Fid int `json:"fid"` + Title string `json:"title"` + MediaCount int `json:"media_count"` + Sync int `json:"sync"` +} + +type AccountInfo struct { + Mid int `json:"mid"` + Uname string `json:"uname"` + Status int `json:"status"` + Face string `json:"face"` + FoldersCount int `json:"folders_count"` + Folders []*FavourFolders `json:"folders"` +} + +func AccountList(page, pageSize int) (*[]*AccountInfo, int64) { + db := models.GetDB() + accountMap := make(map[int]*AccountInfo, 0) + accountMids := make([]int, 0) + total := AccountTotal() + if total > 0 { + var datas []models.BiliAccounts + db.Model(&models.BiliAccounts{}).Order("updated_at DESC").Limit(pageSize).Offset((page - 1) * pageSize).Find(&datas) + for _, data := range datas { + item := AccountInfo{ + Mid: data.Mid, + Status: data.Status, + Face: fmt.Sprintf( + "/api/account/proxy/%d/?url=%s", + data.Mid, + url.QueryEscape(data.Face), + ), + Uname: data.UName, + Folders: make([]*FavourFolders, 0), + FoldersCount: 0, + } + accountMap[data.Mid] = &item + accountMids = append(accountMids, data.Mid) + } + + var favourFolderInfos []models.FavourFoldersInfo + db.Where("mid IN (?)", accountMids).Find(&favourFolderInfos) + for _, v := range favourFolderInfos { + folders := FavourFolders{ + Mlid: v.Mlid, + Fid: v.Fid, + Title: v.Title, + MediaCount: v.MediaCount, + Sync: v.Sync, + } + accountMap[v.Mid].Folders = append(accountMap[v.Mid].Folders, &folders) + accountMap[v.Mid].FoldersCount++ + } + } + items := maps.Values(accountMap) + return &items, total +} + +func AccountTotal() int64 { + db := models.GetDB() + var total int64 + db.Model(&models.BiliAccounts{}).Count(&total) + return total +} diff --git a/web/services/favour.go b/web/services/favour.go new file mode 100644 index 0000000..3b4a832 --- /dev/null +++ b/web/services/favour.go @@ -0,0 +1,138 @@ +package services + +import ( + "bilibo/config" + "bilibo/consts" + "bilibo/models" + "os" + "path/filepath" + "slices" + "sort" + "strings" + + "github.com/gabriel-vasile/mimetype" + "github.com/maruel/natural" +) + +func DelFavourInfoByMid(mid int) { + db := models.GetDB() + db.Where(models.FavourFoldersInfo{Mid: mid}).Delete(&models.FavourFoldersInfo{}) +} + +func GetAccountFavourInfoByMid(mid int) *[]*FavourFolders { + db := models.GetDB() + var favourFolderInfos []models.FavourFoldersInfo + db.Model(&models.FavourFoldersInfo{}).Where("mid = ?", mid).Find(&favourFolderInfos) + datas := make([]*FavourFolders, 0) + for _, v := range favourFolderInfos { + datas = append(datas, &FavourFolders{ + Mlid: v.Mlid, + Fid: v.Fid, + Title: v.Title, + MediaCount: v.MediaCount, + Sync: v.Sync, + }) + } + return &datas +} +func SetFavourSyncStatus(mid, mlid, status int) { + db := models.GetDB() + db.Model(&models.FavourFoldersInfo{}).Where("mlid = ?", mlid).Update("sync", status) + if status == consts.FAVOUR_NEED_SYNC { + db.Model(&models.FavourVideos{}).Where("mid = ? AND mlid = ? AND status = ?", mid, mlid, consts.VIDEO_STATUS_INIT).Update("status", consts.VIDEO_STATUS_TO_BE_DOWNLOAD) + } +} + +type FavFile struct { + BaseName string `json:"basename"` + Extension string `json:"extension"` + ExtraMetadata []string `json:"extra_metadata"` + FileSize int64 `json:"file_size"` + LastModified int64 `json:"last_modified"` + MimeType *string `json:"mime_type"` + Path string `json:"path"` + Storage string `json:"storage"` + Type string `json:"type"` + Visibility string `json:"visibility"` +} + +func GetFavourIndex(mid, action, path string) map[string]interface{} { + result := make(map[string]interface{}) + conf := config.GetConfig() + rootPath := filepath.Join(conf.Download.Path, mid) + + result["adapter"] = mid + result["dirname"] = path + result["storages"] = []string{mid} + + subPath := filepath.Join(rootPath, strings.ReplaceAll(path, mid+"://", "/")) + fileMap := make(map[string]*FavFile) + fileNames := make([]string, 0) + dirFiles, err := os.ReadDir(subPath) + if err != nil { + return result + } + + for _, file := range dirFiles { + if fileInfo, err := file.Info(); err == nil { + file := FavFile{ + Path: mid + ":/" + filepath.Join(strings.ReplaceAll(path, mid+"://", "/"), fileInfo.Name()), + Visibility: "public", + ExtraMetadata: make([]string, 0), + FileSize: fileInfo.Size(), + LastModified: fileInfo.ModTime().Unix(), + Storage: mid, + BaseName: fileInfo.Name(), + MimeType: nil, + } + if fileInfo.IsDir() { + file.Type = "dir" + file.Extension = "" + } else { + file.Type = "file" + } + fileNames = append(fileNames, fileInfo.Name()) + fileMap[fileInfo.Name()] = &file + } + } + + if len(fileNames) < 1 { + return result + } + + sort.Sort(natural.StringSlice(fileNames)) + + files := make([]*FavFile, 0) + + for _, fileName := range fileNames { + file := fileMap[fileName] + if file.Type == "file" { + mtype, err := mimetype.DetectFile(filepath.Join(subPath, file.BaseName)) + if err != nil { + file.MimeType = nil + fextension := strings.Split(file.BaseName, ".") + slices.Reverse(fextension) + file.Extension = fextension[0] + continue + } else { + fmtype := mtype.String() + file.MimeType = &fmtype + file.Extension = strings.Replace(mtype.Extension(), ".", "", 1) + } + } + files = append(files, file) + } + + result["files"] = files + return result +} +func GetFavourFileDownload(mid, action, path string) (string, error) { + conf := config.GetConfig() + rootPath := filepath.Join(conf.Download.Path, mid) + filePath := filepath.Join(rootPath, strings.ReplaceAll(path, mid+"://", "/")) + if _, err := os.Stat(filePath); err != nil { + return "", err + } else { + return filePath, nil + } +} diff --git a/web/services/favour_video.go b/web/services/favour_video.go new file mode 100644 index 0000000..f3af4cd --- /dev/null +++ b/web/services/favour_video.go @@ -0,0 +1,103 @@ +package services + +import ( + "bilibo/consts" + "bilibo/models" + "fmt" + + "golang.org/x/exp/maps" +) + +func DelFavourVideoByMid(mid int) { + db := models.GetDB() + db.Where(models.FavourVideos{Mid: mid}).Delete(&models.FavourVideos{}) +} + +type VideoInfo struct { + Part string `json:"part"` + Title string `json:"title"` + Bvid string `json:"bvid"` + Status int `json:"status"` + Mlid int `json:"mlid"` + FavTitle string `json:"fav_title"` + Mid int `json:"mid"` + AccountName string `json:"account_name"` +} + +func handleQueryStatus(status int) []int { + statusList := []int{status} + if status == consts.VIDEO_STATUS_DOWNLOAD_FAIL { + statusList = append(statusList, consts.VIDEO_STATUS_DOWNLOAD_RETRY) + } + return statusList + +} + +func GetVideosByStatus(status, page, pageSize int) (*[]*VideoInfo, int64) { + result := make([]*VideoInfo, 0) + db := models.GetDB() + var total int64 + + statusList := handleQueryStatus(status) + + query := db.Model(&models.FavourVideos{}).Where("status IN (?)", statusList) + + query.Count(&total) + + if total > 0 { + var favourVideos []models.FavourVideos + query.Order("updated_at DESC").Limit(pageSize).Offset((page - 1) * pageSize).Find(&favourVideos) + accountMap := make(map[int]*AccountInfo, 0) + favMap := make(map[int]*FavourFolders, 0) + for _, v := range favourVideos { + accountMap[v.Mid] = nil + favMap[v.Mlid] = nil + } + + var favourFolderInfos []models.FavourFoldersInfo + db.Where("mid IN (?)", maps.Keys(accountMap)).Find(&favourFolderInfos) + for _, v := range favourFolderInfos { + favMap[v.Mlid] = &FavourFolders{ + Mlid: v.Mlid, + Fid: v.Fid, + Title: v.Title, + MediaCount: v.MediaCount, + Sync: v.Sync, + } + } + + var accountInfos []models.BiliAccounts + db.Where("mid IN (?)", maps.Keys(accountMap)).Find(&accountInfos) + for _, v := range accountInfos { + accountMap[v.Mid] = &AccountInfo{ + Mid: v.Mid, + Status: v.Status, + Face: v.Face, + Uname: v.UName, + } + } + + for _, v := range favourVideos { + favTitle := "" + if favMap[v.Mlid] != nil { + favTitle = favMap[v.Mlid].Title + } + accountName := "" + if accountMap[v.Mid] != nil { + accountName = accountMap[v.Mid].Uname + } + result = append(result, &VideoInfo{ + Part: fmt.Sprintf("P%d %s", v.Page, v.Part), + Title: v.Title, + Bvid: v.Bvid, + Status: v.Status, + Mlid: v.Mlid, + FavTitle: favTitle, + Mid: v.Mid, + AccountName: accountName, + }) + } + } + + return &result, total +} diff --git a/views/AccountViews.go b/web/views/AccountViews.go similarity index 97% rename from views/AccountViews.go rename to web/views/AccountViews.go index a91aec1..57992bf 100644 --- a/views/AccountViews.go +++ b/web/views/AccountViews.go @@ -6,8 +6,7 @@ import ( "bilibo/consts" "bilibo/download" "bilibo/log" - "bilibo/scheduler" - "bilibo/services" + "bilibo/web/services" "context" "fmt" "net/http" @@ -80,10 +79,6 @@ func accountDelete(c *gin.Context) { services.DelAccount(req.Mid) services.DelFavourInfoByMid(req.Mid) services.DelFavourVideoByMid(req.Mid) - jobIds := services.DelTaskByMid(req.Mid) - for _, jobId := range jobIds { - scheduler.DelJob(jobId) - } c.JSON(http.StatusOK, gin.H{ "message": "account delete", "result": 0, diff --git a/views/DistViews.go b/web/views/DistViews.go similarity index 100% rename from views/DistViews.go rename to web/views/DistViews.go diff --git a/views/FavVideoViews.go b/web/views/FavVideoViews.go similarity index 97% rename from views/FavVideoViews.go rename to web/views/FavVideoViews.go index f87c1d2..50dc3a0 100644 --- a/views/FavVideoViews.go +++ b/web/views/FavVideoViews.go @@ -1,7 +1,7 @@ package views import ( - "bilibo/services" + "bilibo/web/services" "net/http" "strconv" diff --git a/views/FavViews.go b/web/views/FavViews.go similarity index 99% rename from views/FavViews.go rename to web/views/FavViews.go index 55c1be9..3e1e54a 100644 --- a/views/FavViews.go +++ b/web/views/FavViews.go @@ -3,8 +3,8 @@ package views import ( "bilibo/consts" "bilibo/log" - "bilibo/services" "bilibo/utils" + "bilibo/web/services" "net/http" "slices" "strconv" diff --git a/views/TaskViews.go b/web/views/TaskViews.go similarity index 100% rename from views/TaskViews.go rename to web/views/TaskViews.go diff --git a/web/web.go b/web/web.go new file mode 100644 index 0000000..4e21c23 --- /dev/null +++ b/web/web.go @@ -0,0 +1,25 @@ +package web + +import ( + "bilibo/config" + "bilibo/log" + "fmt" + + "github.com/gin-gonic/gin" +) + +func Run() { + logger := log.GetLogger() + conf := config.GetConfig() + gin.SetMode(gin.ReleaseMode) + r := gin.Default() + Route(r) + + for _, route := range r.Routes() { + logger.Infof("%s [%s]", route.Path, route.Method) + } + logger.Infof("web server running on %s:%d", conf.Server.Host, conf.Server.Port) + if err := r.Run(fmt.Sprintf("%s:%d", conf.Server.Host, conf.Server.Port)); err != nil { + panic(err) + } +} From 05a2f8b4db08c2c728d13ffb82bbf5a53e71d46a Mon Sep 17 00:00:00 2001 From: vclass <> Date: Tue, 30 Apr 2024 17:55:41 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E4=BF=AE=E6=94=B9build=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E5=92=8Cdockerfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 4 ++-- build.sh | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index b751e4f..a452f84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,10 +5,10 @@ RUN apt update && apt -y --no-install-recommends install musl-tools bash curl COPY go.mod go.sum ./ RUN go mod download COPY ./ ./ -RUN rm -rf dist && \ +RUN cd web && rm -rf dist && \ curl -L https://github.com/BoredTape/bilibo-web/releases/latest/download/dist.tar.gz -o dist.tar.gz && \ tar -zxvf dist.tar.gz && \ - rm -rf dist.tar.gz && \ + rm -rf dist.tar.gz && cd .. && \ CC=musl-gcc CGO_ENABLED=1 go build -ldflags '-s -w --extldflags "-static -fpic"' -o ./bin/bilibo -tags=jsoniter . FROM alpine:latest diff --git a/build.sh b/build.sh index fe30364..628708c 100755 --- a/build.sh +++ b/build.sh @@ -3,10 +3,12 @@ appName="bilibo" FetchWeb() { - rm -rf ./web/dist - curl -L https://github.com/BoredTape/bilibo-web/releases/latest/download/dist.tar.gz -o ./web/dist.tar.gz + cd web + rm -rf dist + curl -L https://github.com/BoredTape/bilibo-web/releases/latest/download/dist.tar.gz -o dist.tar.gz tar -zxvf dist.tar.gz rm -rf dist.tar.gz + cd .. } BuildRelease() { From 02afe47fb480cc7ad35ca9821dba801c5db7a0d2 Mon Sep 17 00:00:00 2001 From: vclass <> Date: Fri, 3 May 2024 03:12:35 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E4=B8=80=E7=B3=BB=E5=88=97=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .assets/design.png | Bin 0 -> 119579 bytes bili/core.go | 102 ------- bobo/bobo.go | 101 +++++++ {bili/bili_client => bobo/client}/Wbi.go | 2 +- {bili/bili_client => bobo/client}/client.go | 4 +- .../client}/comment_type.go | 2 +- {bili/bili_client => bobo/client}/download.go | 2 +- .../client}/download_detecter.go | 2 +- {bili/bili_client => bobo/client}/fav.go | 2 +- {bili/bili_client => bobo/client}/fav_type.go | 2 +- {bili/bili_client => bobo/client}/login.go | 2 +- {bili/bili_client => bobo/client}/nav.go | 2 +- {bili/bili_client => bobo/client}/nav_type.go | 2 +- {bili/bili_client => bobo/client}/space.go | 2 +- .../bili_client => bobo/client}/space_type.go | 2 +- {bili/bili_client => bobo/client}/type.go | 2 +- {bili/bili_client => bobo/client}/utils.go | 2 +- {bili/bili_client => bobo/client}/video.go | 2 +- .../bili_client => bobo/client}/video_type.go | 2 +- {download => bobo}/downloader.go | 78 +++-- config/config.go | 7 +- consts/common.go | 3 + design.drawio | 124 ++++++++ log/logger.go | 2 +- main.go | 22 +- models/db.go | 2 +- realtime_job/favour.go | 2 +- scheduler/{bilibo_job.go => jobs.go} | 28 +- scheduler/sched.go | 4 +- services/account.go | 10 + services/favour.go | 22 +- tests/init.go | 4 +- universal/ch.go | 22 ++ web/services/account.go | 17 +- web/services/favour.go | 4 + web/services/login.go | 279 ++++++++++++++++++ web/views/AccountViews.go | 90 ++---- 37 files changed, 694 insertions(+), 263 deletions(-) create mode 100644 .assets/design.png delete mode 100644 bili/core.go create mode 100644 bobo/bobo.go rename {bili/bili_client => bobo/client}/Wbi.go (99%) rename {bili/bili_client => bobo/client}/client.go (98%) rename {bili/bili_client => bobo/client}/comment_type.go (99%) rename {bili/bili_client => bobo/client}/download.go (98%) rename {bili/bili_client => bobo/client}/download_detecter.go (99%) rename {bili/bili_client => bobo/client}/fav.go (99%) rename {bili/bili_client => bobo/client}/fav_type.go (99%) rename {bili/bili_client => bobo/client}/login.go (99%) rename {bili/bili_client => bobo/client}/nav.go (96%) rename {bili/bili_client => bobo/client}/nav_type.go (99%) rename {bili/bili_client => bobo/client}/space.go (96%) rename {bili/bili_client => bobo/client}/space_type.go (98%) rename {bili/bili_client => bobo/client}/type.go (97%) rename {bili/bili_client => bobo/client}/utils.go (99%) rename {bili/bili_client => bobo/client}/video.go (99%) rename {bili/bili_client => bobo/client}/video_type.go (99%) rename {download => bobo}/downloader.go (52%) create mode 100644 design.drawio rename scheduler/{bilibo_job.go => jobs.go} (80%) create mode 100644 universal/ch.go create mode 100644 web/services/login.go diff --git a/.assets/design.png b/.assets/design.png new file mode 100644 index 0000000000000000000000000000000000000000..d696308ca3e0e3becad5ebbe19967aff9cc408a9 GIT binary patch literal 119579 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 z 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 uzwF!p^X>3x*F||*{MRCqzR7yGz1vqyGbf{=yvq literal 0 HcmV?d00001 diff --git a/bili/core.go b/bili/core.go deleted file mode 100644 index e1202b6..0000000 --- a/bili/core.go +++ /dev/null @@ -1,102 +0,0 @@ -package bili - -import ( - "bilibo/bili/bili_client" - "bilibo/models" - "context" - "errors" - "time" -) - -var biliBo *BoBo - -type BoBo struct { - client map[int]*bili_client.Client - clientCancelFunc map[int]context.CancelFunc -} - -func InitBiliBo() { - biliBo = New() -} -func GetBilibo() *BoBo { - return biliBo -} - -func New() *BoBo { - b := &BoBo{ - client: make(map[int]*bili_client.Client), - clientCancelFunc: make(map[int]context.CancelFunc), - } - b.restore() - return b -} -func NewClient(ua string, timeout time.Duration) *bili_client.Client { - return bili_client.NewClient( - bili_client.WithUA(ua), - bili_client.WithTimeout(timeout), - ) -} - -func (b *BoBo) restore() { - var accounts []models.BiliAccounts - models.GetDB().Model(&models.BiliAccounts{}).Find(&accounts) - - for _, account := range accounts { - c := bili_client.NewClient( - bili_client.WithMid(account.Mid), - bili_client.WithCookiesStrings(account.Cookies), - bili_client.WithImgKey(account.ImgKey), - bili_client.WithSubKey(account.SubKey), - ) - b.client[account.Mid] = c - } -} - -func (b *BoBo) AddClient(c *bili_client.Client) (int, int64, error) { - nav, errorCode, err := c.GetNavigation() - if err != nil { - return 0, errorCode, err - } - if err := c.RefreshWbiKey(nav); err == nil { - if mid := c.GetMid(); mid != 0 { - b.client[mid] = c - return mid, 0, nil - } else { - return 0, 0, errors.New("get mid error") - } - } else { - return 0, errorCode, err - } -} - -func (b *BoBo) DelClient(mid int) { - delete(b.client, mid) - if cancelFunc, ok := b.clientCancelFunc[mid]; ok { - cancelFunc() - } - delete(b.clientCancelFunc, mid) -} - -func (b *BoBo) GetClient(mid int) (*bili_client.Client, error) { - if client, ok := b.client[mid]; ok { - return client, nil - } - return nil, errors.New("client not found") -} - -func (b *BoBo) ClientList() []int { - var list []int - for k := range b.client { - list = append(list, k) - } - return list -} - -func (b *BoBo) ClientSetCancal(mid int, cancelFunc context.CancelFunc) { - if _, err := b.GetClient(mid); err == nil { - if oldFunc, ok := b.clientCancelFunc[mid]; ok { - oldFunc() - } - b.clientCancelFunc[mid] = cancelFunc - } -} diff --git a/bobo/bobo.go b/bobo/bobo.go new file mode 100644 index 0000000..27f08a2 --- /dev/null +++ b/bobo/bobo.go @@ -0,0 +1,101 @@ +package bobo + +import ( + "bilibo/bobo/client" + "bilibo/consts" + "bilibo/log" + "bilibo/services" + "bilibo/universal" + "context" + "errors" + "fmt" +) + +var bobo *BoBo + +type BoBo struct { + client map[int]*client.Client + clientCancelFunc map[int]context.CancelFunc +} + +func Init() { + bobo = &BoBo{ + client: make(map[int]*client.Client), + clientCancelFunc: make(map[int]context.CancelFunc), + } + restoreClient() + go handleClient() +} + +func GetBoBo() *BoBo { + return bobo +} + +func (b *BoBo) ClientList() []int { + var list []int + for k := range b.client { + list = append(list, k) + } + return list +} + +func (b *BoBo) GetClient(mid int) (*client.Client, error) { + if client, ok := b.client[mid]; ok { + return client, nil + } + return nil, errors.New("client not found") +} + +func handleClient() { + for ch := range *universal.GetCH() { + if ch.Action == consts.CHANNEL_ACTION_ADD_CLIENT { + addClient(&ch) + } else if ch.Action == consts.CHANNEL_ACTION_DELETE_CLIENT { + delClient(&ch) + } else { + logger := log.GetLogger() + logger.Info(fmt.Sprintf("channel get unknown action: %d", ch.Action)) + } + } +} + +func addClient(ch *universal.CH) { + c := client.New( + client.WithUA(""), // 先使用默认的,可能以后会改成可修改的 + client.WithCookiesStrings(ch.Cookies), + client.WithImgKey(ch.ImgKey), + client.WithSubKey(ch.SubKey), + client.WithMid(ch.Mid), + ) + bobo.client[ch.Mid] = c + ctx, cancel := context.WithCancel(context.Background()) + bobo.clientCancelFunc[ch.Mid] = cancel + go downloadFavVideo(c, ctx) +} + +func (b *BoBo) DelClient(mid int) { + delete(b.client, mid) + if cancelFunc, ok := bobo.clientCancelFunc[mid]; ok { + cancelFunc() + } + delete(b.clientCancelFunc, mid) +} + +func delClient(ch *universal.CH) { + bobo.DelClient(ch.Mid) +} + +func restoreClient() { + accounts := services.GetAccountList() + for _, account := range *accounts { + addClient(&universal.CH{ + Mid: account.Mid, + UName: account.UName, + Face: account.Face, + ImgKey: account.ImgKey, + SubKey: account.SubKey, + Cookies: account.Cookies, + Action: consts.CHANNEL_ACTION_ADD_CLIENT, + }) + } +} diff --git a/bili/bili_client/Wbi.go b/bobo/client/Wbi.go similarity index 99% rename from bili/bili_client/Wbi.go rename to bobo/client/Wbi.go index 5d40c45..74edcd6 100644 --- a/bili/bili_client/Wbi.go +++ b/bobo/client/Wbi.go @@ -1,4 +1,4 @@ -package bili_client +package client import ( "crypto/md5" diff --git a/bili/bili_client/client.go b/bobo/client/client.go similarity index 98% rename from bili/bili_client/client.go rename to bobo/client/client.go index 44f6d78..f5a76fb 100644 --- a/bili/bili_client/client.go +++ b/bobo/client/client.go @@ -1,5 +1,5 @@ // copy from https://github.com/CuteReimu/bilibili/blob/master/client.go -package bili_client +package client import ( "net/http" @@ -66,7 +66,7 @@ func WithCookiesStrings(cookies string) ClientOption { } } -func NewClient(opts ...ClientOption) *Client { +func New(opts ...ClientOption) *Client { c := &Client{} for _, opt := range opts { opt(c) diff --git a/bili/bili_client/comment_type.go b/bobo/client/comment_type.go similarity index 99% rename from bili/bili_client/comment_type.go rename to bobo/client/comment_type.go index bc4929d..77c4574 100644 --- a/bili/bili_client/comment_type.go +++ b/bobo/client/comment_type.go @@ -1,5 +1,5 @@ // copy from https://github.com/CuteReimu/bilibili/blob/master/comment.go -package bili_client +package client type Comment struct { // 评论条目对象 Rpid int64 `json:"rpid"` // 评论 rpid diff --git a/bili/bili_client/download.go b/bobo/client/download.go similarity index 98% rename from bili/bili_client/download.go rename to bobo/client/download.go index be7bf1a..a8ea724 100644 --- a/bili/bili_client/download.go +++ b/bobo/client/download.go @@ -1,4 +1,4 @@ -package bili_client +package client import ( "bilibo/consts" diff --git a/bili/bili_client/download_detecter.go b/bobo/client/download_detecter.go similarity index 99% rename from bili/bili_client/download_detecter.go rename to bobo/client/download_detecter.go index 6fcf5ce..9b05dce 100644 --- a/bili/bili_client/download_detecter.go +++ b/bobo/client/download_detecter.go @@ -1,4 +1,4 @@ -package bili_client +package client const ( /* diff --git a/bili/bili_client/fav.go b/bobo/client/fav.go similarity index 99% rename from bili/bili_client/fav.go rename to bobo/client/fav.go index af8134b..aec42d9 100644 --- a/bili/bili_client/fav.go +++ b/bobo/client/fav.go @@ -1,6 +1,6 @@ //copy from https://github.com/CuteReimu/bilibili/blob/master/fav.go -package bili_client +package client import ( "encoding/json" diff --git a/bili/bili_client/fav_type.go b/bobo/client/fav_type.go similarity index 99% rename from bili/bili_client/fav_type.go rename to bobo/client/fav_type.go index 028efbf..81b530f 100644 --- a/bili/bili_client/fav_type.go +++ b/bobo/client/fav_type.go @@ -1,5 +1,5 @@ // copy from https://github.com/CuteReimu/bilibili/blob/master/fav.go -package bili_client +package client type FavourFolderInfo struct { Id int `json:"id"` // 收藏夹mlid(完整id),收藏夹原始id+创建者mid尾号2位 diff --git a/bili/bili_client/login.go b/bobo/client/login.go similarity index 99% rename from bili/bili_client/login.go rename to bobo/client/login.go index 422c68b..395907a 100644 --- a/bili/bili_client/login.go +++ b/bobo/client/login.go @@ -1,5 +1,5 @@ // copy from https://github.com/CuteReimu/bilibili/blob/master/login.go -package bili_client +package client import ( "encoding/json" diff --git a/bili/bili_client/nav.go b/bobo/client/nav.go similarity index 96% rename from bili/bili_client/nav.go rename to bobo/client/nav.go index d0f7945..0bb5584 100644 --- a/bili/bili_client/nav.go +++ b/bobo/client/nav.go @@ -1,4 +1,4 @@ -package bili_client +package client import ( "encoding/json" diff --git a/bili/bili_client/nav_type.go b/bobo/client/nav_type.go similarity index 99% rename from bili/bili_client/nav_type.go rename to bobo/client/nav_type.go index 2798647..6987333 100644 --- a/bili/bili_client/nav_type.go +++ b/bobo/client/nav_type.go @@ -1,4 +1,4 @@ -package bili_client +package client type LevelInfo struct { CurrentLevel int `json:"current_level"` diff --git a/bili/bili_client/space.go b/bobo/client/space.go similarity index 96% rename from bili/bili_client/space.go rename to bobo/client/space.go index 7019f5a..65d916d 100644 --- a/bili/bili_client/space.go +++ b/bobo/client/space.go @@ -1,4 +1,4 @@ -package bili_client +package client import ( "encoding/json" diff --git a/bili/bili_client/space_type.go b/bobo/client/space_type.go similarity index 98% rename from bili/bili_client/space_type.go rename to bobo/client/space_type.go index 7949ae9..426674b 100644 --- a/bili/bili_client/space_type.go +++ b/bobo/client/space_type.go @@ -1,4 +1,4 @@ -package bili_client +package client type SpaceMyInfo struct { Mid int `json:"mid"` diff --git a/bili/bili_client/type.go b/bobo/client/type.go similarity index 97% rename from bili/bili_client/type.go rename to bobo/client/type.go index a7590b2..86eba4f 100644 --- a/bili/bili_client/type.go +++ b/bobo/client/type.go @@ -1,5 +1,5 @@ // copy from https://github.com/CuteReimu/bilibili/blob/master/type.go -package bili_client +package client import ( "strconv" diff --git a/bili/bili_client/utils.go b/bobo/client/utils.go similarity index 99% rename from bili/bili_client/utils.go rename to bobo/client/utils.go index 2d59a61..7099eaf 100644 --- a/bili/bili_client/utils.go +++ b/bobo/client/utils.go @@ -1,5 +1,5 @@ // copy from https://github.com/CuteReimu/bilibili/blob/master/client.go -package bili_client +package client import ( "crypto/rand" diff --git a/bili/bili_client/video.go b/bobo/client/video.go similarity index 99% rename from bili/bili_client/video.go rename to bobo/client/video.go index 055f3ea..1037c65 100644 --- a/bili/bili_client/video.go +++ b/bobo/client/video.go @@ -1,5 +1,5 @@ // copy from https://github.com/CuteReimu/bilibili/blob/master/video.go -package bili_client +package client import ( "encoding/json" diff --git a/bili/bili_client/video_type.go b/bobo/client/video_type.go similarity index 99% rename from bili/bili_client/video_type.go rename to bobo/client/video_type.go index d29f61f..430c404 100644 --- a/bili/bili_client/video_type.go +++ b/bobo/client/video_type.go @@ -1,5 +1,5 @@ // copy from https://github.com/CuteReimu/bilibili/blob/master/video.go -package bili_client +package client // OfficialInfo 成员认证信息 type OfficialInfo struct { diff --git a/download/downloader.go b/bobo/downloader.go similarity index 52% rename from download/downloader.go rename to bobo/downloader.go index ac0c246..059a8ab 100644 --- a/download/downloader.go +++ b/bobo/downloader.go @@ -1,48 +1,37 @@ -package download +package bobo import ( - "bilibo/bili" + "bilibo/bobo/client" "bilibo/config" "bilibo/consts" "bilibo/log" "bilibo/models" "bilibo/services" + "bilibo/utils" "context" "fmt" "os" "path/filepath" - "strconv" "strings" "time" ) -func DownloadHandler(mid int, video *models.FavourVideos) { - conf := config.GetConfig() - biliBo := bili.GetBilibo() - client, _ := biliBo.GetClient(mid) +func downloadHandler(c *client.Client, video *models.FavourVideos, basePath, path string) { + mid := c.GetMid() services.SetVideoStatus(video.ID, consts.VIDEO_STATUS_DOWNLOADING) videoStatus := consts.VIDEO_STATUS_DOWNLOAD_RETRY if fav := services.GetFavourInfoByMlid(video.Mlid); fav != nil { - tmpFilePath := filepath.Join(conf.Download.Path, ".tmp") + tmpFilePath := filepath.Join(basePath, ".tmp") fileName := fmt.Sprintf("%d_%d_%s_%d", mid, video.Mlid, video.Bvid, video.Cid) - if dFilePath, dmimeType, err := client.DownloadVideoBestByBvidCid( + if dFilePath, dmimeType, err := c.DownloadVideoBestByBvidCid( video.Cid, video.Bvid, tmpFilePath, fileName, ); err == nil { videoStatus = consts.VIDEO_STATUS_DOWNLOAD_DONE - - pathDst := filepath.Join( - conf.Download.Path, - strconv.Itoa(video.Mid), - strings.ReplaceAll(fav.Title, "/", "⁄"), - strings.ReplaceAll(video.Title, "/", "⁄"), - ) - os.MkdirAll(pathDst, os.ModePerm) - distPath := filepath.Join( - pathDst, + os.MkdirAll(path, os.ModePerm) + distPath := filepath.Join(path, fmt.Sprintf("P%d %s.%s", video.Page, strings.ReplaceAll(video.Part, "/", "⁄"), dmimeType)) - os.Rename(dFilePath, distPath) - // bili_client.GenerateCover(distPath) + utils.RenameDir(dFilePath, distPath) } else if err == consts.ERROR_DOWNLOAD_403 { errorInfo := fmt.Sprintf("user [%d] download video [%s] error: %v. try it later", mid, video.Bvid, err) services.SetVideoErrorMessage(video.Mlid, mid, video.Bvid, errorInfo) @@ -58,35 +47,44 @@ func DownloadHandler(mid int, video *models.FavourVideos) { services.SetVideoStatus(video.ID, videoStatus) } -func AccountDownload(mid int, ctx context.Context) { +func downloadFavVideo(c *client.Client, ctx context.Context) { logger := log.GetLogger() + mid := c.GetMid() + conf := config.GetConfig() for { select { case <-ctx.Done(): logger.Infof("user [%d] download exit", mid) default: logger.Infof("user [%d] download start", mid) - biliBo := bili.GetBilibo() - if _, err := biliBo.GetClient(mid); err == nil { - video1 := services.GetToBeDownloadByMid(mid) - video2 := services.GetRetryByMid(mid) - if video1 == nil && video2 == nil { - logger.Infof("user [%d] download finish. wait 4minutes", mid) - time.Sleep(240 * time.Second) - continue - } - if video1 != nil { - DownloadHandler(mid, video1) + video1 := services.GetToBeDownloadByMid(mid) + video2 := services.GetRetryByMid(mid) + if video1 == nil && video2 == nil { + logger.Infof("user [%d] download finish. wait 4minutes", mid) + time.Sleep(240 * time.Second) + continue + } + if video1 != nil { + if fav := services.GetFavourInfoByMlid(video1.Mlid); fav != nil { + pathDst := filepath.Join( + utils.GetFavourPath(mid, conf.Download.Path), + strings.ReplaceAll(fav.Title, "/", "⁄"), + strings.ReplaceAll(video1.Title, "/", "⁄"), + ) + downloadHandler(c, video1, conf.Download.Path, pathDst) } - if video2 != nil { - DownloadHandler(mid, video2) + } + if video2 != nil { + if fav := services.GetFavourInfoByMlid(video1.Mlid); fav != nil { + pathDst := filepath.Join( + utils.GetFavourPath(mid, conf.Download.Path), + strings.ReplaceAll(fav.Title, "/", "⁄"), + strings.ReplaceAll(video1.Title, "/", "⁄"), + ) + downloadHandler(c, video2, conf.Download.Path, pathDst) } - } else { - logger.Errorf("user [%d] get client error: %v", mid, err) - services.SetUserVideosStatus(mid, consts.VIDEO_STATUS_DOWNLOAD_FAIL) - break } - logger.Infof("user [%d] download end", mid) } + logger.Infof("user [%d] download end", mid) } } diff --git a/config/config.go b/config/config.go index dd2352d..60c8fdd 100644 --- a/config/config.go +++ b/config/config.go @@ -28,7 +28,7 @@ type Config struct { Download DownloadConfig } -func InitConfig() { +func Init() { configPath := os.Getenv("config") if configPath == "" { configPath = "config.yaml" @@ -41,10 +41,7 @@ func InitConfig() { panic(err) } if c.Download.Path == "" { - c.Download.Path = "./data/downloads" - if _, err := os.Create(c.Download.Path); err != nil { - panic(err) - } + panic("download path not set") } } diff --git a/consts/common.go b/consts/common.go index dde51ac..8f7d64f 100644 --- a/consts/common.go +++ b/consts/common.go @@ -53,3 +53,6 @@ func GET_ACCOUNT_DIR() []string { ACCOUNT_DIR_RECYCLE, } } + +const CHANNEL_ACTION_ADD_CLIENT = 1 +const CHANNEL_ACTION_DELETE_CLIENT = 2 diff --git a/design.drawio b/design.drawio new file mode 100644 index 0000000..0363c3c --- /dev/null +++ b/design.drawio @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/log/logger.go b/log/logger.go index 3a00c4a..5c1cc0f 100644 --- a/log/logger.go +++ b/log/logger.go @@ -10,7 +10,7 @@ func GetLogger() *zap.SugaredLogger { return zap.S() } -func InitLogger() { +func Init() { encoderConfig := zap.NewDevelopmentEncoderConfig() encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder logger := zap.New(zapcore.NewCore( diff --git a/main.go b/main.go index 1a394fd..813ff54 100644 --- a/main.go +++ b/main.go @@ -1,26 +1,26 @@ package main import ( - "bilibo/bili" + "bilibo/bobo" "bilibo/config" - "bilibo/download" "bilibo/log" "bilibo/models" "bilibo/scheduler" "bilibo/services" + "bilibo/universal" "bilibo/web" - "context" "os" "path/filepath" ) func init() { - config.InitConfig() + config.Init() + universal.Init() + log.Init() conf := config.GetConfig() - log.InitLogger() - models.InitDB(conf.Server.DB.Driver, conf.Server.DB.DSN) - bili.InitBiliBo() + models.Init(conf.Server.DB.Driver, conf.Server.DB.DSN) services.InitSetVideoStatus() + bobo.Init() } func main() { @@ -28,14 +28,8 @@ func main() { os.RemoveAll(filepath.Join(conf.Download.Path, ".tmp")) os.MkdirAll(filepath.Join(conf.Download.Path, ".tmp"), os.ModePerm) - bobo := bili.GetBilibo() - scheduler.BiliBoSched(bobo) + scheduler.BiliBoSched(bobo.GetBoBo()) scheduler.Start() - for _, clientId := range bobo.ClientList() { - ctx, cancel := context.WithCancel(context.Background()) - go download.AccountDownload(clientId, ctx) - bobo.ClientSetCancal(clientId, cancel) - } web.Run() } diff --git a/models/db.go b/models/db.go index bdc2a6c..69c9148 100644 --- a/models/db.go +++ b/models/db.go @@ -9,7 +9,7 @@ import ( var db *gorm.DB -func InitDB(driver, dsn string) { +func Init(driver, dsn string) { var err error gConfig := gorm.Config{Logger: logger.Default.LogMode(logger.Info)} if driver == "mysql" { diff --git a/realtime_job/favour.go b/realtime_job/favour.go index 1b446cf..7376816 100644 --- a/realtime_job/favour.go +++ b/realtime_job/favour.go @@ -67,7 +67,7 @@ func DeleteFavours(mlids []int) { } break } else { - logger.Info(fmt.Sprintf("收藏夹视频正在下载,重试中...")) + logger.Info("收藏夹视频正在下载,重试中...") } time.Sleep(2 * time.Second) } diff --git a/scheduler/bilibo_job.go b/scheduler/jobs.go similarity index 80% rename from scheduler/bilibo_job.go rename to scheduler/jobs.go index f141ff7..08da89a 100644 --- a/scheduler/bilibo_job.go +++ b/scheduler/jobs.go @@ -1,15 +1,15 @@ package scheduler import ( - "bilibo/bili" - "bilibo/bili/bili_client" + "bilibo/bobo" + "bilibo/bobo/client" "bilibo/consts" "bilibo/log" "bilibo/services" ) type refreshWbiKeyJob struct { - bobo *bili.BoBo + bobo *bobo.BoBo } func (r *refreshWbiKeyJob) Run() { @@ -48,7 +48,7 @@ func (r *refreshWbiKeyJob) Run() { } type refreshFavListJob struct { - bobo *bili.BoBo + bobo *bobo.BoBo } func (r *refreshFavListJob) Run() { @@ -91,12 +91,28 @@ func (r *refreshFavListJob) Run() { } } -func (r *refreshFavListJob) SetFav() *bili_client.AllFavourFolderInfo { +func (r *refreshFavListJob) SetFav() *client.AllFavourFolderInfo { logger := log.GetLogger() for _, mid := range r.bobo.ClientList() { if client, err := r.bobo.GetClient(mid); err == nil { if data, err := client.GetAllFavourFolderInfo(mid, 2, 0); err == nil { - services.SetFavourInfo(mid, data) + folderInfo := make([]services.FolderInfo, 0) + for _, v := range data.List { + folderInfo = append(folderInfo, services.FolderInfo{ + Id: v.Id, + Fid: v.Fid, + Mid: v.Mid, + Attr: v.Attr, + Title: v.Title, + FavState: v.FavState, + MediaCount: v.MediaCount, + }) + } + serviceData := services.FavourFolderInfo{ + Count: data.Count, + List: folderInfo, + } + services.SetFavourInfo(mid, &serviceData) return data } else { logger.Warnf("client %d get fav list error: %v", mid, err) diff --git a/scheduler/sched.go b/scheduler/sched.go index aa0b30c..2a2d5d9 100644 --- a/scheduler/sched.go +++ b/scheduler/sched.go @@ -1,7 +1,7 @@ package scheduler import ( - "bilibo/bili" + "bilibo/bobo" "github.com/robfig/cron/v3" ) @@ -16,7 +16,7 @@ func init() { sched = cron.New() } -func BiliBoSched(bobo *bili.BoBo) { +func BiliBoSched(bobo *bobo.BoBo) { refreshWbiKey := refreshWbiKeyJob{bobo} refreshWbiKey.Run() sched.AddJob("*/15 * * * *", &refreshWbiKey) diff --git a/services/account.go b/services/account.go index 28e6b56..f5b866b 100644 --- a/services/account.go +++ b/services/account.go @@ -1,6 +1,7 @@ package services import ( + "bilibo/consts" "bilibo/models" ) @@ -38,3 +39,12 @@ func ClearAllQRCode() { db := models.GetDB() db.Where("deleted_at IS NULL").Delete(&models.QRCode{}) } + +func GetAccountList() *[]models.BiliAccounts { + db := models.GetDB() + var account []models.BiliAccounts + db.Model(&models.BiliAccounts{ + Status: consts.ACCOUNT_STATUS_NORMAL, + }).Find(&account) + return &account +} diff --git a/services/favour.go b/services/favour.go index 82cf788..ced8b53 100644 --- a/services/favour.go +++ b/services/favour.go @@ -1,7 +1,6 @@ package services import ( - "bilibo/bili/bili_client" "bilibo/config" "bilibo/consts" "bilibo/models" @@ -15,7 +14,21 @@ import ( "golang.org/x/exp/maps" ) -func SetFavourInfo(mid int, favInfo *bili_client.AllFavourFolderInfo) { +type FolderInfo struct { + Id int + Fid int + Mid int + Attr int + Title string + FavState int + MediaCount int +} +type FavourFolderInfo struct { + Count int + List []FolderInfo +} + +func SetFavourInfo(mid int, favInfo *FavourFolderInfo) { if favInfo == nil { return } @@ -48,6 +61,7 @@ func SetFavourInfo(mid int, favInfo *bili_client.AllFavourFolderInfo) { existInfo := existMap[v.Id] if existInfo.Attr != v.Attr || existInfo.Title != v.Title || existInfo.FavState != v.FavState || existInfo.MediaCount != v.MediaCount { updateList = append(updateList, &models.FavourFoldersInfo{ + Mlid: v.Id, MediaCount: v.MediaCount, Attr: v.Attr, Title: v.Title, @@ -60,7 +74,9 @@ func SetFavourInfo(mid int, favInfo *bili_client.AllFavourFolderInfo) { } if len(insertList) > 0 { - db.Create(insertList) + for _, insert_data := range insertList { + db.Model(&models.FavourFoldersInfo{}).Create(insert_data) + } } if len(deleteMlids) > 0 { diff --git a/tests/init.go b/tests/init.go index 18e1a98..d222ca2 100644 --- a/tests/init.go +++ b/tests/init.go @@ -25,7 +25,7 @@ func InitConfig() { func Init() { InitConfig() - log.InitLogger() + log.Init() conf := config.GetConfig() - models.InitDB(conf.Server.DB.Driver, conf.Server.DB.DSN) + models.Init(conf.Server.DB.Driver, conf.Server.DB.DSN) } diff --git a/universal/ch.go b/universal/ch.go new file mode 100644 index 0000000..cc5e1b3 --- /dev/null +++ b/universal/ch.go @@ -0,0 +1,22 @@ +package universal + +var ch *chan CH + +type CH struct { + Mid int + UName string + Face string + ImgKey string + SubKey string + Cookies string + Action int +} + +func Init() { + chh := make(chan CH, 1) + ch = &chh +} + +func GetCH() *chan CH { + return ch +} diff --git a/web/services/account.go b/web/services/account.go index 586648d..1025155 100644 --- a/web/services/account.go +++ b/web/services/account.go @@ -3,6 +3,7 @@ package services import ( "bilibo/consts" "bilibo/models" + "bilibo/universal" "fmt" "net/url" @@ -24,7 +25,21 @@ func SaveAccountInfo(mid int, uname, face, cookies, imgKey, subKey string) { func DelAccount(mid int) { db := models.GetDB() - db.Where(models.BiliAccounts{Mid: mid}).Delete(&models.BiliAccounts{}) + account := models.BiliAccounts{} + db.Model(&models.BiliAccounts{}).Where("mid = ?", mid).Find(&account) + if account.ID < 1 { + return + } + *universal.GetCH() <- universal.CH{ + Mid: account.Mid, + UName: account.UName, + Face: account.Face, + ImgKey: account.ImgKey, + SubKey: account.SubKey, + Cookies: account.Cookies, + Action: consts.CHANNEL_ACTION_ADD_CLIENT, + } + db.Delete(&account) } func AddQRCodeInfo(qrId string) { diff --git a/web/services/favour.go b/web/services/favour.go index 3b4a832..7a0908d 100644 --- a/web/services/favour.go +++ b/web/services/favour.go @@ -3,6 +3,7 @@ package services import ( "bilibo/config" "bilibo/consts" + "bilibo/log" "bilibo/models" "os" "path/filepath" @@ -68,6 +69,9 @@ func GetFavourIndex(mid, action, path string) map[string]interface{} { subPath := filepath.Join(rootPath, strings.ReplaceAll(path, mid+"://", "/")) fileMap := make(map[string]*FavFile) fileNames := make([]string, 0) + logger := log.GetLogger() + logger.Info(subPath) + logger.Info("fuck") dirFiles, err := os.ReadDir(subPath) if err != nil { return result diff --git a/web/services/login.go b/web/services/login.go new file mode 100644 index 0000000..e180c76 --- /dev/null +++ b/web/services/login.go @@ -0,0 +1,279 @@ +package services + +import ( + "bilibo/config" + "bilibo/consts" + "bilibo/universal" + "bilibo/utils" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/go-resty/resty/v2" + "github.com/skip2/go-qrcode" + "github.com/tidwall/gjson" +) + +type client struct { + cookies []*http.Cookie + timeout time.Duration + logger resty.Logger + ua string + imgKey string + subKey string + mid int + face string + uname string +} + +func (c *client) resty() *resty.Client { + client := resty.New().SetTimeout(c.timeout).SetHeader("user-agent", c.ua) + if c.logger != nil { + client.SetLogger(c.logger) + } + if c.cookies != nil { + client.SetCookies(c.cookies) + } + return client +} + +func getRespData(resp *resty.Response, prefix string) ([]byte, int64, error) { + var errorCode int64 = 0 + if resp.StatusCode() != 200 { + respCode := resp.StatusCode() + errorCode, _ = strconv.ParseInt(fmt.Sprintf("%d%d", 999, respCode), 10, 64) + return nil, errorCode, errors.New(prefix + "失败,status code: " + strconv.Itoa(resp.StatusCode())) + } + if !gjson.ValidBytes(resp.Body()) { + errorCode = 999 + return nil, errorCode, errors.New("json解析失败:" + resp.String()) + } + res := gjson.ParseBytes(resp.Body()) + code := res.Get("code").Int() + if code != 0 { + return nil, code, errors.New(prefix + "失败,返回值:" + strconv.FormatInt(code, 10)) + } + return []byte(res.Get("data").Raw), errorCode, nil +} + +func (c *client) setCookies(cookies []*http.Cookie) { + c.cookies = cookies +} + +func (c *client) GetCookiesString() string { + var cookieStrings []string + for _, cookie := range c.cookies { + cookieStrings = append(cookieStrings, cookie.String()) + } + return strings.Join(cookieStrings, "\n") +} + +func (c *client) GetMid() int { + return c.mid +} + +func (c *client) GetWbi() (string, string) { + return c.imgKey, c.subKey +} + +type WbiImg struct { + ImgUrl string `json:"img_url"` + SubUrl string `json:"sub_url"` +} + +type navigation struct { + Face string `json:"face"` + Mid int `json:"mid"` + Uname string `json:"uname"` + WbiImg WbiImg `json:"wbi_img"` +} + +func (c *client) getNavigation() (*navigation, int64, error) { + resp, err := c.resty().R().Get("https://api.bilibili.com/x/web-interface/nav") + if err != nil { + return nil, 0, err + } + data, errorCode, err := getRespData(resp, "导航栏用户信息") + if err != nil { + return nil, errorCode, err + } + var ret *navigation + err = json.Unmarshal(data, &ret) + if err != nil { + c.imgKey = ret.WbiImg.ImgUrl + c.subKey = ret.WbiImg.SubUrl + c.mid = ret.Mid + c.face = ret.Face + c.uname = ret.Uname + } + return ret, errorCode, err +} + +func new() (*client, *qrCode, error) { + c := &client{ + ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36", + cookies: []*http.Cookie{}, + timeout: 20 * time.Second, + logger: nil, + } + + qr, err := c.getQRCode() + if err != nil { + return nil, nil, err + } + return c, qr, nil +} + +type qrCode struct { + Url string `json:"url"` // 二维码内容url + QrcodeKey string `json:"qrcode_key"` // 扫码登录秘钥 +} + +func (result *qrCode) Encode() ([]byte, error) { + return qrcode.Encode(result.Url, qrcode.Medium, 256) +} + +func (c *client) getQRCode() (*qrCode, error) { + resp, err := c.resty().R().SetHeader("Content-Type", "application/x-www-form-urlencoded"). + Get("https://passport.bilibili.com/x/passport-login/web/qrcode/generate") + if err != nil { + return nil, err + } + data, _, err := getRespData(resp, "申请二维码") + if err != nil { + return nil, err + } + var result *qrCode + err = json.Unmarshal(data, &result) + return result, err +} + +type Info struct { + Cookies string + Mid int + UName string + Face string + ImgKey string + SubKey string +} + +func (c *client) loginWithQRCode(qrCode *qrCode) (*Info, error) { + if qrCode == nil { + return nil, errors.New("请先获取二维码") + } + for { + ok, err := c.qrCodeSuccess(qrCode) + if err != nil { + return nil, err + } + if ok { + c.getNavigation() + return &Info{ + Cookies: c.GetCookiesString(), + Mid: c.mid, + UName: c.uname, + Face: c.face, + ImgKey: c.imgKey, + SubKey: c.subKey, + }, nil + } + time.Sleep(3 * time.Second) // 主站 3s 一次请求 + } +} + +func (c *client) qrCodeSuccess(qrCode *qrCode) (bool, error) { + resp, err := c.resty().R().SetHeader("Content-Type", "application/x-www-form-urlencoded"). + SetQueryParam("qrcode_key", qrCode.QrcodeKey).Get("https://passport.bilibili.com/x/passport-login/web/qrcode/poll") + if err != nil { + return false, err + } + if resp.StatusCode() != 200 { + return false, errors.New("登录bilibili失败") + } + if !gjson.ValidBytes(resp.Body()) { + return false, errors.New("json invalid: " + resp.String()) + } + result := gjson.ParseBytes(resp.Body()) + retCode := result.Get("code").Int() + if retCode != 0 { + return false, errors.New("登录bilibili失败,错误码:" + strconv.FormatInt(retCode, 10) + ",错误信息:" + gjson.GetBytes(resp.Body(), "message").String()) + } else { + codeValue := result.Get("data.code") + if !codeValue.Exists() || codeValue.Type != gjson.Number { + return false, errors.New("扫码登录未成功,返回异常") + } + code := codeValue.Int() + switch code { + case 86038: // 二维码已失效 + return false, errors.New("扫码登录未成功,原因:二维码已失效") + case 86090: // 二维码已扫码未确认 + return false, nil + case 86101: // 未扫码 + return false, nil + case 0: + c.setCookies(resp.Cookies()) + return true, nil + default: + return false, errors.New("由于未知原因,扫码登录未成功,错误码:" + strconv.FormatInt(code, 10)) + } + } +} + +func SetAccountInfo() (string, int64, error) { + c, qr, err := new() + if err != nil { + return "", 0, err + } + qrCode, err := c.getQRCode() + if err != nil { + return "", 0, err + } + qrImgByte, err := qr.Encode() + if err != nil { + return "", 0, err + } + + conf := config.GetConfig() + qrId := time.Now().UnixNano() + fileName := fmt.Sprintf("%d.png", qrId) + filePath := filepath.Join(conf.Download.Path, ".tmp", fileName) + err = os.WriteFile(filePath, qrImgByte, os.ModePerm) + if err != nil { + return "", 0, err + } + + url := "/api/account/qrcode/" + fileName + AddQRCodeInfo(fmt.Sprintf("%d", qrId)) + go func() { + if info, err := c.loginWithQRCode(qrCode); err == nil { + SaveAccountInfo( + info.Mid, + info.UName, info.Face, + c.GetCookiesString(), + info.ImgKey, info.SubKey, + ) + SetQRCodeStatus(fmt.Sprintf("%d", qrId), consts.QRCODE_STATUS_SCANNED) + *universal.GetCH() <- universal.CH{ + Mid: info.Mid, + UName: info.UName, + Face: info.Face, + ImgKey: info.ImgKey, + SubKey: info.SubKey, + Cookies: info.Cookies, + Action: consts.CHANNEL_ACTION_ADD_CLIENT, + } + os.MkdirAll(utils.GetFavourPath(info.Mid, conf.Download.Path), os.ModePerm) + os.MkdirAll(utils.GetRecyclePath(info.Mid, conf.Download.Path), os.ModePerm) + } else { + SetQRCodeStatus(fmt.Sprintf("%d", qrId), consts.QRCODE_STATUS_INVALID) + } + os.Remove(filePath) + }() + return url, qrId, nil +} diff --git a/web/views/AccountViews.go b/web/views/AccountViews.go index 57992bf..01ee078 100644 --- a/web/views/AccountViews.go +++ b/web/views/AccountViews.go @@ -1,20 +1,14 @@ package views import ( - "bilibo/bili" "bilibo/config" - "bilibo/consts" - "bilibo/download" - "bilibo/log" "bilibo/web/services" - "context" "fmt" + "io" "net/http" "net/url" - "os" "path/filepath" "strconv" - "time" "github.com/gin-gonic/gin" ) @@ -74,11 +68,9 @@ type accountDeleteReq struct { func accountDelete(c *gin.Context) { var req accountDeleteReq c.BindJSON(&req) - bilibo := bili.GetBilibo() - bilibo.DelClient(req.Mid) - services.DelAccount(req.Mid) services.DelFavourInfoByMid(req.Mid) services.DelFavourVideoByMid(req.Mid) + services.DelAccount(req.Mid) c.JSON(http.StatusOK, gin.H{ "message": "account delete", "result": 0, @@ -92,54 +84,14 @@ func accountSave(c *gin.Context) { "message": "获取登陆二维码失败", "result": 999, } - client := bili.NewClient("", 0) - - if qr, err := client.GetQRCode(); err == nil { - if qrImgByte, err := qr.Encode(); err == nil { - conf := config.GetConfig() - qrId := time.Now().UnixNano() - fileName := fmt.Sprintf("%d.png", qrId) - filePath := filepath.Join(conf.Download.Path, ".tmp", fileName) - if err := os.WriteFile(filePath, qrImgByte, os.ModePerm); err == nil { - data["url"] = "/api/account/qrcode/" + fileName - data["id"] = fmt.Sprintf("%d", qrId) - rsp["data"] = data - rsp["message"] = "获取登陆二维码成功" - rsp["result"] = 0 - services.AddQRCodeInfo(fmt.Sprintf("%d", qrId)) - go func() { - logger := log.GetLogger() - if err := client.LoginWithQRCode(qr); err == nil { - biliBo := bili.GetBilibo() - biliBo.AddClient(client) - nav, _, err := client.GetNavigation() - if err != nil { - logger.Error(err) - return - } - if err := client.RefreshWbiKey(nav); err != nil { - logger.Error(err) - return - } - imgKey, subKey := client.GetWbiRunningTime() - services.SaveAccountInfo( - client.GetMid(), - nav.Uname, nav.Face, - client.GetCookiesString(), - imgKey, subKey, - ) - ctx, cancel := context.WithCancel(context.Background()) - biliBo.ClientSetCancal(client.GetMid(), cancel) - go download.AccountDownload(client.GetMid(), ctx) - services.SetQRCodeStatus(fmt.Sprintf("%d", qrId), consts.QRCODE_STATUS_SCANNED) - } else { - logger.Error(err) - services.SetQRCodeStatus(fmt.Sprintf("%d", qrId), consts.QRCODE_STATUS_INVALID) - } - os.Remove(filePath) - }() - } - } + + url, qrId, err := services.SetAccountInfo() + if err != nil { + data["url"] = url + data["id"] = fmt.Sprintf("%d", qrId) + rsp["data"] = data + rsp["message"] = "获取登陆二维码成功" + rsp["result"] = 0 } c.JSON(http.StatusOK, rsp) @@ -176,28 +128,30 @@ func accountQrCodeStatus(c *gin.Context) { } func accountProxy(c *gin.Context) { - mid, err := strconv.Atoi(c.Param("mid")) - if err != nil { - c.JSON(500, gin.H{"message": err.Error()}) - return - } + // mid, err := strconv.Atoi(c.Param("mid")) + // if err != nil { + // c.JSON(500, gin.H{"message": err.Error()}) + // return + // } faceUrlEncode := c.Query("url") faceUrlDecode, err := url.QueryUnescape(faceUrlEncode) if err != nil { c.JSON(500, gin.H{"message": err.Error()}) return } - bilibo := bili.GetBilibo() - client, err := bilibo.GetClient(mid) + + resp, err := http.Get(faceUrlDecode) + if err != nil { c.JSON(500, gin.H{"message": err.Error()}) return } - resty := client.GetResty() - resp, err := resty.R().Get(faceUrlDecode) + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) if err != nil { c.JSON(500, gin.H{"message": err.Error()}) return } - c.Writer.Write(resp.Body()) + c.Writer.Write(body) } From 666cf5623ebf0bce6390ff8777402dd3351d2230 Mon Sep 17 00:00:00 2001 From: vclass <> Date: Sun, 5 May 2024 18:54:20 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E7=A8=8D=E5=90=8E=E5=86=8D=E7=9C=8B&?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bobo/bobo.go | 102 ++++++++++++ bobo/client/history.go | 22 +++ bobo/client/history_type.go | 9 + bobo/downloader.go | 52 ++++-- consts/common.go | 21 ++- main.go | 2 - models/db.go | 12 +- models/models.go | 30 +++- realtime_job/favour.go | 74 --------- scheduler/jobs.go | 97 ++++------- scheduler/sched.go | 12 +- services/account.go | 7 + services/favour.go | 97 ++++++++++- services/favour_video.go | 147 ----------------- services/task.go | 72 ++++++++ services/video.go | 141 ++++++++++++++++ tests/realtime_job_test.go | 28 ++-- utils/file_utils.go | 4 + web/router.go | 2 + web/services/account.go | 155 ++++++++++++++++-- web/services/favour.go | 111 +------------ web/services/login.go | 36 ++-- web/services/task.go | 31 ++++ web/services/{favour_video.go => video.go} | 32 ++-- web/services/watch_later.go | 43 +++++ web/views/AccountViews.go | 53 +++++- web/views/FavViews.go | 44 ----- web/views/TaskViews.go | 23 +++ web/views/{FavVideoViews.go => VideoViews.go} | 0 web/views/WatchLaterViews.go | 58 +++++++ 30 files changed, 993 insertions(+), 524 deletions(-) create mode 100644 bobo/client/history.go create mode 100644 bobo/client/history_type.go delete mode 100644 realtime_job/favour.go delete mode 100644 services/favour_video.go create mode 100644 services/task.go create mode 100644 services/video.go create mode 100644 web/services/task.go rename web/services/{favour_video.go => video.go} (76%) create mode 100644 web/services/watch_later.go rename web/views/{FavVideoViews.go => VideoViews.go} (100%) create mode 100644 web/views/WatchLaterViews.go diff --git a/bobo/bobo.go b/bobo/bobo.go index 27f08a2..a24f3f7 100644 --- a/bobo/bobo.go +++ b/bobo/bobo.go @@ -46,6 +46,101 @@ func (b *BoBo) GetClient(mid int) (*client.Client, error) { return nil, errors.New("client not found") } +func (b *BoBo) RefreshFav(mid int) *client.AllFavourFolderInfo { + logger := log.GetLogger() + if client, err := b.GetClient(mid); err == nil { + if data, err := client.GetAllFavourFolderInfo(mid, 2, 0); err == nil { + folderInfo := make([]services.FolderInfo, 0) + for _, v := range data.List { + folderInfo = append(folderInfo, services.FolderInfo{ + Id: v.Id, + Fid: v.Fid, + Mid: v.Mid, + Attr: v.Attr, + Title: v.Title, + FavState: v.FavState, + MediaCount: v.MediaCount, + }) + } + serviceData := services.FavourFolderInfo{ + Count: data.Count, + List: folderInfo, + } + services.SetFavourInfo(mid, &serviceData) + return data + } else { + logger.Warnf("client %d get fav list error: %v", mid, err) + } + } + return nil +} + +func (b *BoBo) RefreshFavVideo(mid int, data *client.AllFavourFolderInfo) { + logger := log.GetLogger() + logger.Infof("user: %d refresh fav list", mid) + fv_svc := services.VideoService{} + if client, err := b.GetClient(mid); err == nil { + if data != nil { + fv_svc.SetMid(mid) + for _, fav := range data.List { + mlid := fav.Id + fv_svc.V.Mlid = mlid + if fret, err := client.GetFavourList(mlid, 0, "", "", 0, 20, 1, "web"); err == nil { + for _, media := range fret.Medias { + bvid := media.BvId + fv_svc.V.Bvid = bvid + if vret, err := client.GetVideoInfoByBvid(bvid); err == nil { + for _, page := range vret.Pages { + cid := page.Cid + fv_svc.V.Cid = cid + fv_svc.V.Title = vret.Title + fv_svc.V.Part = page.Part + fv_svc.V.Height = page.Dimension.Height + fv_svc.V.Width = page.Dimension.Width + fv_svc.V.Rotate = page.Dimension.Rotate + fv_svc.V.Page = page.Page + fv_svc.V.Type = consts.VIDEO_TYPE_FAVOUR + fv_svc.Save() + } + } + } + } + + } + } else { + logger.Warnf("user %d get fav list empty", mid) + } + } +} + +func (b *BoBo) RefreshToView(mid int) { + fv_svc := services.VideoService{} + if client, err := b.GetClient(mid); err == nil { + fv_svc.SetMid(mid) + if toViewData, err := client.GetToView(); err == nil { + for _, data := range toViewData.List { + bvid := data.Bvid + fv_svc.V.Bvid = bvid + fv_svc.V.Mlid = 0 + if vret, err := client.GetVideoInfoByBvid(bvid); err == nil { + for _, page := range vret.Pages { + cid := page.Cid + fv_svc.V.Cid = cid + fv_svc.V.Title = vret.Title + fv_svc.V.Part = page.Part + fv_svc.V.Height = page.Dimension.Height + fv_svc.V.Width = page.Dimension.Width + fv_svc.V.Rotate = page.Dimension.Rotate + fv_svc.V.Page = page.Page + fv_svc.V.Type = consts.VIDEO_TYPE_WATCH_LATER + fv_svc.Save() + } + } + } + } + } +} + func handleClient() { for ch := range *universal.GetCH() { if ch.Action == consts.CHANNEL_ACTION_ADD_CLIENT { @@ -70,9 +165,16 @@ func addClient(ch *universal.CH) { bobo.client[ch.Mid] = c ctx, cancel := context.WithCancel(context.Background()) bobo.clientCancelFunc[ch.Mid] = cancel + go bobo.RefreshAll(ch.Mid) go downloadFavVideo(c, ctx) } +func (b *BoBo) RefreshAll(mid int) { + data := bobo.RefreshFav(mid) + bobo.RefreshFavVideo(mid, data) + bobo.RefreshToView(mid) +} + func (b *BoBo) DelClient(mid int) { delete(b.client, mid) if cancelFunc, ok := bobo.clientCancelFunc[mid]; ok { diff --git a/bobo/client/history.go b/bobo/client/history.go new file mode 100644 index 0000000..e358f82 --- /dev/null +++ b/bobo/client/history.go @@ -0,0 +1,22 @@ +package client + +import ( + "encoding/json" + + "github.com/pkg/errors" +) + +func (c *Client) GetToView() (*ToViewInfo, error) { + resp, err := c.resty().R().SetHeader("Content-Type", "application/x-www-form-urlencoded"). + Get("https://api.bilibili.com/x/v2/history/toview") + if err != nil { + return nil, errors.WithStack(err) + } + data, err := getRespData(resp, "获取稍后再看视频列表") + if err != nil { + return nil, err + } + var ret *ToViewInfo + err = json.Unmarshal(data, &ret) + return ret, err +} diff --git a/bobo/client/history_type.go b/bobo/client/history_type.go new file mode 100644 index 0000000..8a29783 --- /dev/null +++ b/bobo/client/history_type.go @@ -0,0 +1,9 @@ +package client + +// 对于这个项目,该接口,只拿bvid足以 +type ToViewInfo struct { + Count int `json:"count"` + List []struct { + Bvid string `json:"bvid"` + } `json:"list"` +} diff --git a/bobo/downloader.go b/bobo/downloader.go index 059a8ab..3cb352c 100644 --- a/bobo/downloader.go +++ b/bobo/downloader.go @@ -16,7 +16,7 @@ import ( "time" ) -func downloadHandler(c *client.Client, video *models.FavourVideos, basePath, path string) { +func downloadHandler(c *client.Client, video *models.Videos, basePath, path string) { mid := c.GetMid() services.SetVideoStatus(video.ID, consts.VIDEO_STATUS_DOWNLOADING) @@ -52,6 +52,13 @@ func downloadFavVideo(c *client.Client, ctx context.Context) { mid := c.GetMid() conf := config.GetConfig() for { + accountInfo := services.GetAccountByMid(mid) + t := services.NewTask( + services.WithTaskType(consts.TASK_TYPE_RUNNING_TIME), + services.WithName(fmt.Sprintf("用户 [%s] 的定时下载", accountInfo.UName)), + services.WithTaskId(fmt.Sprintf("user_download_%d", mid)), + ) + t.Save() select { case <-ctx.Done(): logger.Infof("user [%d] download exit", mid) @@ -61,30 +68,55 @@ func downloadFavVideo(c *client.Client, ctx context.Context) { video2 := services.GetRetryByMid(mid) if video1 == nil && video2 == nil { logger.Infof("user [%d] download finish. wait 4minutes", mid) + t.UpdateNextRunningAt(4 * 60) time.Sleep(240 * time.Second) continue } if video1 != nil { - if fav := services.GetFavourInfoByMlid(video1.Mlid); fav != nil { - pathDst := filepath.Join( - utils.GetFavourPath(mid, conf.Download.Path), - strings.ReplaceAll(fav.Title, "/", "⁄"), + pathDst := "" + if video1.Type == consts.VIDEO_TYPE_FAVOUR { + if fav := services.GetFavourInfoByMlid(video1.Mlid); fav != nil { + pathDst = filepath.Join( + utils.GetFavourPath(mid, conf.Download.Path), + strings.ReplaceAll(fav.Title, "/", "⁄"), + strings.ReplaceAll(video1.Title, "/", "⁄"), + ) + } + } else if video1.Type == consts.VIDEO_TYPE_WATCH_LATER { + pathDst = filepath.Join( + utils.GetWatchLaterPath(mid, conf.Download.Path), strings.ReplaceAll(video1.Title, "/", "⁄"), ) + } + + if len(pathDst) > 0 { downloadHandler(c, video1, conf.Download.Path, pathDst) } + } if video2 != nil { - if fav := services.GetFavourInfoByMlid(video1.Mlid); fav != nil { - pathDst := filepath.Join( - utils.GetFavourPath(mid, conf.Download.Path), - strings.ReplaceAll(fav.Title, "/", "⁄"), - strings.ReplaceAll(video1.Title, "/", "⁄"), + pathDst := "" + if video2.Type == consts.VIDEO_TYPE_FAVOUR { + if fav := services.GetFavourInfoByMlid(video2.Mlid); fav != nil { + pathDst = filepath.Join( + utils.GetFavourPath(mid, conf.Download.Path), + strings.ReplaceAll(fav.Title, "/", "⁄"), + strings.ReplaceAll(video2.Title, "/", "⁄"), + ) + + } + } else if video2.Type == consts.VIDEO_TYPE_WATCH_LATER { + pathDst = filepath.Join( + utils.GetWatchLaterPath(mid, conf.Download.Path), + strings.ReplaceAll(video2.Title, "/", "⁄"), ) + } + if len(pathDst) > 0 { downloadHandler(c, video2, conf.Download.Path, pathDst) } } } logger.Infof("user [%d] download end", mid) + t.UpdateNextRunningAt(4 * 60) } } diff --git a/consts/common.go b/consts/common.go index 8f7d64f..b0f5a65 100644 --- a/consts/common.go +++ b/consts/common.go @@ -4,6 +4,11 @@ import "errors" var ERROR_DOWNLOAD_403 = errors.New("download failed,status code: 403") +const ( + WATCH_LATER_NOT_SYNC = 0 + WATCH_LATER_NEED_SYNC = 1 +) + const ( FAVOUR_NOT_SYNC = 0 FAVOUR_NEED_SYNC = 1 @@ -11,8 +16,8 @@ const ( // 任务类型 const ( - TASK_TYPE_FAVOUR = 1 - TASK_TYPE_DOWNLOAD = 2 + TASK_TYPE_SCHEDULER = 1 + TASK_TYPE_RUNNING_TIME = 2 ) const ( @@ -24,6 +29,11 @@ const ( VIDEO_STATUS_DOWNLOAD_RETRY = 4 ) +const ( + VIDEO_TYPE_FAVOUR = 1 + VIDEO_TYPE_WATCH_LATER = 2 +) + const ( QRCODE_STATUS_NOT_SCAN = 1 QRCODE_STATUS_SCANNED = 2 @@ -42,15 +52,16 @@ const ( ) const ( - ACCOUNT_DIR_FAVOUR = "收藏夹" - ACCOUNT_DIR_RECYCLE = "回收站" - // ACCOUNT_DIR_WATCH_LATER = "稍后再看" + ACCOUNT_DIR_FAVOUR = "收藏夹" + ACCOUNT_DIR_RECYCLE = "回收站" + ACCOUNT_DIR_WATCH_LATER = "稍后再看" ) func GET_ACCOUNT_DIR() []string { return []string{ ACCOUNT_DIR_FAVOUR, ACCOUNT_DIR_RECYCLE, + ACCOUNT_DIR_WATCH_LATER, } } diff --git a/main.go b/main.go index 813ff54..f324356 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,6 @@ import ( "bilibo/log" "bilibo/models" "bilibo/scheduler" - "bilibo/services" "bilibo/universal" "bilibo/web" "os" @@ -19,7 +18,6 @@ func init() { log.Init() conf := config.GetConfig() models.Init(conf.Server.DB.Driver, conf.Server.DB.DSN) - services.InitSetVideoStatus() bobo.Init() } diff --git a/models/db.go b/models/db.go index 69c9148..9f5f3da 100644 --- a/models/db.go +++ b/models/db.go @@ -1,6 +1,8 @@ package models import ( + "bilibo/consts" + "gorm.io/driver/mysql" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -11,7 +13,9 @@ var db *gorm.DB func Init(driver, dsn string) { var err error - gConfig := gorm.Config{Logger: logger.Default.LogMode(logger.Info)} + gConfig := gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + } if driver == "mysql" { db, err = gorm.Open(mysql.Open(dsn), &gConfig) if err != nil { @@ -26,13 +30,17 @@ func Init(driver, dsn string) { panic("数据库驱动不支持") } + db.Migrator().DropTable(&Task{}, &QRCode{}) db.AutoMigrate( &BiliAccounts{}, &FavourFoldersInfo{}, - &FavourVideos{}, + &Videos{}, &QRCode{}, &VideoDownloadMessage{}, + &Task{}, + &WatchLater{}, ) + db.Model(&Videos{}).Where("status = ?", consts.VIDEO_STATUS_DOWNLOADING).Update("status", consts.VIDEO_STATUS_TO_BE_DOWNLOAD) } func GetDB() *gorm.DB { diff --git a/models/models.go b/models/models.go index 050a7c9..167d546 100644 --- a/models/models.go +++ b/models/models.go @@ -37,7 +37,7 @@ func (f *FavourFoldersInfo) TableName() string { return "favour_folders_info" } -type FavourVideos struct { +type Videos struct { gorm.Model Mlid int Mid int @@ -51,11 +51,12 @@ type FavourVideos struct { Rotate int // 是否将宽高对换,0:正常,1:对换 Status int + Type int LastDownloadAt *time.Time } -func (f *FavourVideos) TableName() string { - return "favour_videos" +func (f *Videos) TableName() string { + return "videos" } type QRCode struct { @@ -80,3 +81,26 @@ type VideoDownloadMessage struct { func (f *VideoDownloadMessage) TableName() string { return "video_download_message" } + +type Task struct { + gorm.Model + TaskId string `gorm:"column:task_id"` + Name string + LastRunningAt *time.Time + NextRunningAt *time.Time + Type int +} + +func (f *Task) TableName() string { + return "task" +} + +type WatchLater struct { + gorm.Model + Mid int + Sync int +} + +func (f *WatchLater) TableName() string { + return "watch_later" +} diff --git a/realtime_job/favour.go b/realtime_job/favour.go deleted file mode 100644 index 7376816..0000000 --- a/realtime_job/favour.go +++ /dev/null @@ -1,74 +0,0 @@ -package realtime_job - -import ( - "bilibo/config" - "bilibo/consts" - "bilibo/log" - "bilibo/models" - "bilibo/utils" - "fmt" - "strings" - "time" -) - -func ChangeFavourName(mlid int, oldPath, newPath string) { - db := models.GetDB() - logger := log.GetLogger() - sqlPause := "UPDATE favour_videos SET status=status+100 WHERE status IN (?) AND deleted_at IS NULL;" - value := []int{ - consts.VIDEO_STATUS_TO_BE_DOWNLOAD, - consts.VIDEO_STATUS_DOWNLOAD_FAIL, - consts.VIDEO_STATUS_DOWNLOAD_RETRY, - } - db.Exec(sqlPause, value) - for { - logger.Info(fmt.Sprintf("收藏夹路径更改:\n%s => %s", oldPath, newPath)) - var downloadingCount int64 - db.Model(&models.FavourVideos{}).Where( - "mlid = ? AND status = ?", mlid, consts.VIDEO_STATUS_DOWNLOADING, - ).Count(&downloadingCount) - fmt.Println(downloadingCount) - if downloadingCount == 0 { - fmt.Println(oldPath, "\n", newPath) - if err := utils.RenameDir(oldPath, newPath); err != nil { - logger.Error(err.Error()) - } - sqlContinue := "UPDATE favour_videos SET status=status-100 WHERE status > 100 AND deleted_at IS NULL;" - db.Exec(sqlContinue) - break - } else { - logger.Info(fmt.Sprintf("收藏夹路径 %s 正在下载,重试中...", oldPath)) - } - time.Sleep(2 * time.Second) - } -} - -func DeleteFavours(mlids []int) { - db := models.GetDB() - logger := log.GetLogger() - favInfos := []models.FavourFoldersInfo{} - db.Where("mlid IN (?)", mlids).Find(&favInfos) - conf := config.GetConfig() - basePath := conf.Download.Path - db.Where( - "mlid IN (?) AND status != ?", mlids, consts.VIDEO_STATUS_DOWNLOADING, - ).Delete(&models.FavourVideos{}) - for { - logger.Info(fmt.Sprintf("删除收藏夹,收藏夹IDs:[%s]", strings.Trim(strings.Replace(fmt.Sprint(mlids), " ", ",", -1), "[]"))) - var downloadingCount int64 - db.Model(&models.FavourVideos{}).Where( - "mlid IN (?) AND status = ?", mlids, consts.VIDEO_STATUS_DOWNLOADING, - ).Count(&downloadingCount) - if downloadingCount == 0 { - db.Where("mlid IN (?)", mlids).Delete(&models.FavourFoldersInfo{}) - db.Where("mlid IN (?)", mlids).Delete(&models.FavourVideos{}) - for _, fav := range favInfos { - utils.RecyclePath(fav.Mid, basePath, utils.Name(fav.Title)) - } - break - } else { - logger.Info("收藏夹视频正在下载,重试中...") - } - time.Sleep(2 * time.Second) - } -} diff --git a/scheduler/jobs.go b/scheduler/jobs.go index 08da89a..8ce23e3 100644 --- a/scheduler/jobs.go +++ b/scheduler/jobs.go @@ -9,10 +9,12 @@ import ( ) type refreshWbiKeyJob struct { - bobo *bobo.BoBo + taskId string + bobo *bobo.BoBo } func (r *refreshWbiKeyJob) Run() { + t := InitTaskInfo(r.taskId, "更新用户信息") logger := log.GetLogger() logger.Info("refresh wbi key") for _, clientId := range r.bobo.ClientList() { @@ -44,80 +46,49 @@ func (r *refreshWbiKeyJob) Run() { } } } - + t.UpdateNextRunningAt(15 * 60) } type refreshFavListJob struct { - bobo *bobo.BoBo + taskId string + bobo *bobo.BoBo } func (r *refreshFavListJob) Run() { - logger := log.GetLogger() + t := InitTaskInfo(r.taskId, "更新收藏夹信息") for _, mid := range r.bobo.ClientList() { - if client, err := r.bobo.GetClient(mid); err == nil { - logger.Infof("user: %d refresh fav list", mid) - fv_svc := services.FavourVideoService{} - if data := r.SetFav(); data != nil { - fv_svc.SetMid(mid) - for _, fav := range data.List { - mlid := fav.Id - fv_svc.V.Mlid = mlid - if fret, err := client.GetFavourList(mlid, 0, "", "", 0, 20, 1, "web"); err == nil { - for _, media := range fret.Medias { - bvid := media.BvId - fv_svc.V.Bvid = bvid - if vret, err := client.GetVideoInfoByBvid(bvid); err == nil { - for _, page := range vret.Pages { - cid := page.Cid - fv_svc.V.Cid = cid - fv_svc.V.Title = vret.Title - fv_svc.V.Part = page.Part - fv_svc.V.Height = page.Dimension.Height - fv_svc.V.Width = page.Dimension.Width - fv_svc.V.Rotate = page.Dimension.Rotate - fv_svc.V.Page = page.Page - fv_svc.Save() - } - - } - } - } - - } - } else { - logger.Warnf("user %d get fav list empty", mid) - } - } + data := r.bobo.RefreshFav(mid) + r.bobo.RefreshFavVideo(mid, data) } + t.UpdateNextRunningAt(15 * 60) } func (r *refreshFavListJob) SetFav() *client.AllFavourFolderInfo { - logger := log.GetLogger() for _, mid := range r.bobo.ClientList() { - if client, err := r.bobo.GetClient(mid); err == nil { - if data, err := client.GetAllFavourFolderInfo(mid, 2, 0); err == nil { - folderInfo := make([]services.FolderInfo, 0) - for _, v := range data.List { - folderInfo = append(folderInfo, services.FolderInfo{ - Id: v.Id, - Fid: v.Fid, - Mid: v.Mid, - Attr: v.Attr, - Title: v.Title, - FavState: v.FavState, - MediaCount: v.MediaCount, - }) - } - serviceData := services.FavourFolderInfo{ - Count: data.Count, - List: folderInfo, - } - services.SetFavourInfo(mid, &serviceData) - return data - } else { - logger.Warnf("client %d get fav list error: %v", mid, err) - } - } + r.bobo.RefreshFav(mid) } return nil } + +type refreshToViewJob struct { + taskId string + bobo *bobo.BoBo +} + +func (r *refreshToViewJob) Run() { + t := InitTaskInfo(r.taskId, "更新稍后再看视频") + for _, mid := range r.bobo.ClientList() { + r.bobo.RefreshToView(mid) + } + t.UpdateNextRunningAt(15 * 60) +} + +func InitTaskInfo(taskId string, name string) *services.TaskInfo { + t := services.NewTask( + services.WithTaskId(taskId), + services.WithName(name), + services.WithTaskType(consts.TASK_TYPE_SCHEDULER), + ) + t.Save() + return t +} diff --git a/scheduler/sched.go b/scheduler/sched.go index 2a2d5d9..a27fad4 100644 --- a/scheduler/sched.go +++ b/scheduler/sched.go @@ -17,17 +17,23 @@ func init() { } func BiliBoSched(bobo *bobo.BoBo) { - refreshWbiKey := refreshWbiKeyJob{bobo} + refreshWbiKey := refreshWbiKeyJob{"RefreshWbiKey", bobo} refreshWbiKey.Run() sched.AddJob("*/15 * * * *", &refreshWbiKey) - refreshFavList := refreshFavListJob{bobo} - refreshFavList.SetFav() + refreshFavList := refreshFavListJob{"RefreshFavListJob", bobo} sched.AddJob("*/15 * * * *", &refreshFavList) + + refreshToView := refreshToViewJob{"RefreshToViewJob", bobo} + sched.AddJob("*/15 * * * *", &refreshToView) } func Start() { sched.Start() + t := InitTaskInfo("RefreshFavListJob", "更新收藏夹信息") + t.UpdateNextRunningAt(15 * 60) + t = InitTaskInfo("RefreshToViewJob", "更新稍后再看视频") + t.UpdateNextRunningAt(15 * 60) } func DelJob(jobId int) { diff --git a/services/account.go b/services/account.go index f5b866b..9bc6066 100644 --- a/services/account.go +++ b/services/account.go @@ -48,3 +48,10 @@ func GetAccountList() *[]models.BiliAccounts { }).Find(&account) return &account } + +func GetAccountByMid(mid int) *models.BiliAccounts { + db := models.GetDB() + var account models.BiliAccounts + db.Model(&models.BiliAccounts{}).Where("mid = ?", mid).First(&account) + return &account +} diff --git a/services/favour.go b/services/favour.go index ced8b53..cad8f74 100644 --- a/services/favour.go +++ b/services/favour.go @@ -3,13 +3,14 @@ package services import ( "bilibo/config" "bilibo/consts" + "bilibo/log" "bilibo/models" "bilibo/utils" + "fmt" "path/filepath" "slices" "strings" - - "bilibo/realtime_job" + "time" "golang.org/x/exp/maps" ) @@ -40,12 +41,14 @@ func SetFavourInfo(mid int, favInfo *FavourFolderInfo) { existMap[v.Mlid] = v } existMlids := maps.Keys(existMap) + accountMlids := make([]int, 0) insertList := make([]*models.FavourFoldersInfo, 0) updateList := make([]*models.FavourFoldersInfo, 0) deleteMlids := make([]int, 0) for _, v := range favInfo.List { + accountMlids = append(accountMlids, v.Id) if !slices.Contains(existMlids, v.Id) { insertList = append(insertList, &models.FavourFoldersInfo{ Mid: mid, @@ -68,8 +71,12 @@ func SetFavourInfo(mid int, favInfo *FavourFolderInfo) { FavState: v.FavState, }) } - } else { - deleteMlids = append(deleteMlids, v.Id) + } + } + + for _, v := range existMlids { + if !slices.Contains(accountMlids, v) { + deleteMlids = append(deleteMlids, v) } } @@ -80,7 +87,7 @@ func SetFavourInfo(mid int, favInfo *FavourFolderInfo) { } if len(deleteMlids) > 0 { - go realtime_job.DeleteFavours(deleteMlids) + go DeleteFavours(deleteMlids) } if len(updateList) > 0 { @@ -93,13 +100,91 @@ func SetFavourInfo(mid int, favInfo *FavourFolderInfo) { favPath := utils.GetFavourPath(existInfo.Mid, conf.Download.Path) oldPath := filepath.Join(favPath, oldTitle) newPath := filepath.Join(favPath, newTitle) - go realtime_job.ChangeFavourName(updateData.Mlid, oldPath, newPath) + go ChangeFavourName(updateData.Mlid, oldPath, newPath) } db.Model(&models.FavourFoldersInfo{}).Where("id = ?", existInfo.ID).Updates(updateData) } } } +func ChangeFavourName(mlid int, oldPath, newPath string) { + db := models.GetDB() + logger := log.GetLogger() + sqlPause := "UPDATE favour_videos SET status=status+100 WHERE status IN (?) AND deleted_at IS NULL;" + value := []int{ + consts.VIDEO_STATUS_TO_BE_DOWNLOAD, + consts.VIDEO_STATUS_DOWNLOAD_FAIL, + consts.VIDEO_STATUS_DOWNLOAD_RETRY, + } + db.Exec(sqlPause, value) + for { + t := NewTask( + WithTaskType(consts.TASK_TYPE_RUNNING_TIME), + WithName("更改收藏夹名字: "+oldPath+" => "+newPath), + WithTaskId(fmt.Sprintf("change_favour_name_%d", mlid)), + ) + t.Save() + logger.Info(fmt.Sprintf("收藏夹路径更改:\n%s => %s", oldPath, newPath)) + var downloadingCount int64 + db.Model(&models.Videos{}).Where( + "mlid = ? AND status = ?", mlid, consts.VIDEO_STATUS_DOWNLOADING, + ).Count(&downloadingCount) + fmt.Println(downloadingCount) + if downloadingCount == 0 { + fmt.Println(oldPath, "\n", newPath) + if err := utils.RenameDir(oldPath, newPath); err != nil { + logger.Error(err.Error()) + } + sqlContinue := "UPDATE favour_videos SET status=status-100 WHERE status > 100 AND deleted_at IS NULL;" + db.Exec(sqlContinue) + break + } else { + logger.Info(fmt.Sprintf("收藏夹路径 %s 正在下载,重试中...", oldPath)) + } + t.UpdateNextRunningAt(2) + time.Sleep(2 * time.Second) + } +} + +func DeleteFavours(mlids []int) { + db := models.GetDB() + logger := log.GetLogger() + favInfos := []models.FavourFoldersInfo{} + db.Where("mlid IN (?)", mlids).Find(&favInfos) + conf := config.GetConfig() + basePath := conf.Download.Path + db.Where( + "mlid IN (?) AND status != ?", mlids, consts.VIDEO_STATUS_DOWNLOADING, + ).Delete(&models.Videos{}) + for { + mlidsStr := strings.Trim(strings.Replace(fmt.Sprint(mlids), " ", ",", -1), "[]") + fullMlidsStr := fmt.Sprintf("删除收藏夹,收藏夹IDs:[%s]", mlidsStr) + t := NewTask( + WithTaskType(consts.TASK_TYPE_RUNNING_TIME), + WithName(fullMlidsStr), + WithTaskId(fmt.Sprintf("delete_favours:%s", mlidsStr)), + ) + t.Save() + logger.Info(fullMlidsStr) + var downloadingCount int64 + db.Model(&models.Videos{}).Where( + "mlid IN (?) AND status = ?", mlids, consts.VIDEO_STATUS_DOWNLOADING, + ).Count(&downloadingCount) + if downloadingCount == 0 { + db.Where("mlid IN (?)", mlids).Delete(&models.FavourFoldersInfo{}) + db.Where("mlid IN (?)", mlids).Delete(&models.Videos{}) + for _, fav := range favInfos { + utils.RecyclePath(fav.Mid, basePath, utils.Name(fav.Title)) + } + break + } else { + logger.Info("收藏夹视频正在下载,重试中...") + } + t.UpdateNextRunningAt(2) + time.Sleep(2 * time.Second) + } +} + func GetFavourInfoByMlid(mlid int) *models.FavourFoldersInfo { db := models.GetDB() var favourFolderInfo models.FavourFoldersInfo diff --git a/services/favour_video.go b/services/favour_video.go deleted file mode 100644 index 8b10b39..0000000 --- a/services/favour_video.go +++ /dev/null @@ -1,147 +0,0 @@ -package services - -import ( - "bilibo/consts" - "bilibo/log" - "bilibo/models" - "time" -) - -type FavourVideoService struct { - V *models.FavourVideos -} - -func (f *FavourVideoService) SetMid(mid int) { - f.V = &models.FavourVideos{ - Mid: mid, - } -} - -func (f *FavourVideoService) Save() { - db := models.GetDB() - var favourVideo models.FavourVideos - db.Where(models.FavourVideos{ - Bvid: f.V.Bvid, - Mlid: f.V.Mlid, - Mid: f.V.Mid, - Cid: f.V.Cid, - }).FirstOrInit(&favourVideo) - needUpdata := false - if favourVideo.ID == 0 { - favInfo := GetFavourInfoByMlid(f.V.Mlid) - favourVideo.Status = consts.VIDEO_STATUS_INIT - if favInfo != nil && favInfo.Sync == consts.FAVOUR_NEED_SYNC { - favourVideo.Status = consts.VIDEO_STATUS_TO_BE_DOWNLOAD - } - needUpdata = true - } - if favourVideo.Part != f.V.Part { - favourVideo.Part = f.V.Part - needUpdata = true - } - if favourVideo.Width != f.V.Width { - favourVideo.Width = f.V.Width - needUpdata = true - } - if favourVideo.Height != f.V.Height { - favourVideo.Height = f.V.Height - needUpdata = true - } - if favourVideo.Rotate != f.V.Rotate { - favourVideo.Rotate = f.V.Rotate - needUpdata = true - } - if favourVideo.Title != f.V.Title { - favourVideo.Title = f.V.Title - needUpdata = true - } - if favourVideo.Page != f.V.Page { - favourVideo.Page = f.V.Page - needUpdata = true - } - if needUpdata { - db.Save(&favourVideo) - } - -} - -func GetVideoByMidStatus(mid, status int) *models.FavourVideos { - db := models.GetDB() - var favourVideo models.FavourVideos - subQuery := db.Model(&models.FavourFoldersInfo{}).Where( - &models.FavourFoldersInfo{Mid: mid, Sync: consts.FAVOUR_NEED_SYNC}, - ).Select("mlid") - db.Where( - "mid = ? AND status = ? AND mlid IN (?)", - mid, status, subQuery, - ).First(&favourVideo) - if favourVideo.ID == 0 { - return nil - } - return &favourVideo -} - -func GetToBeDownloadByMid(mid int) *models.FavourVideos { - return GetVideoByMidStatus(mid, consts.VIDEO_STATUS_TO_BE_DOWNLOAD) -} - -func GetRetryByMid(mid int) *models.FavourVideos { - before, _ := time.ParseDuration("-2h") - last_time := time.Now().Add(before) - db := models.GetDB() - var favourVideo models.FavourVideos - subQuery := db.Model(&models.FavourFoldersInfo{}).Where( - &models.FavourFoldersInfo{Mid: mid, Sync: consts.FAVOUR_NEED_SYNC}, - ).Select("mlid") - db.Model(&models.FavourVideos{}).Where( - "mid = ? AND status = ? AND mlid IN (?) AND last_download_at < ?", - mid, consts.VIDEO_STATUS_DOWNLOAD_RETRY, subQuery, last_time, - ).First(&favourVideo) - if favourVideo.ID == 0 { - return nil - } - return &favourVideo -} - -func SetVideoStatus(id uint, status int) { - db := models.GetDB() - var favourVideo models.FavourVideos - db.Where("id = ?", id).First(&favourVideo) - favourVideo.Status = status - if favourVideo.Status == consts.VIDEO_STATUS_DOWNLOADING { - timeNow := time.Now() - favourVideo.LastDownloadAt = &timeNow - } - db.Save(&favourVideo) -} - -func SetUserVideosStatus(mid int, status int) { - db := models.GetDB() - db.Model(&models.FavourVideos{}).Where( - "mid = ? AND status != ?", mid, status, - ).Updates(map[string]interface{}{"status": status}) -} - -func InitSetVideoStatus() { - db := models.GetDB() - var favourVideo []models.FavourVideos - db.Where("status = ?", consts.VIDEO_STATUS_DOWNLOADING).Find(&favourVideo) - for _, v := range favourVideo { - v.Status = consts.VIDEO_STATUS_TO_BE_DOWNLOAD - db.Save(&v) - } -} - -func SetVideoErrorMessage(Mlid, Mid int, Bvid, Error string) { - logger := log.GetLogger() - logger.Errorf(Error) - db := models.GetDB() - errorInfo := models.VideoDownloadMessage{ - Mlid: Mlid, - Mid: Mid, - Bvid: Bvid, - Message: Error, - Type: consts.VIDEO_MESSAGE_ERROR, - } - db.Create(&errorInfo) -} diff --git a/services/task.go b/services/task.go new file mode 100644 index 0000000..af5995e --- /dev/null +++ b/services/task.go @@ -0,0 +1,72 @@ +package services + +import ( + "bilibo/models" + "time" +) + +type TaskInfo struct { + id uint + taskId string + name string + lastRunningAt *time.Time + nextRunningAt *time.Time + taskType int +} + +type TaskInfoOption func(t *TaskInfo) + +func WithTaskId(taskId string) TaskInfoOption { + return func(t *TaskInfo) { + t.taskId = taskId + } +} + +func WithName(name string) TaskInfoOption { + return func(t *TaskInfo) { + t.name = name + } +} + +func WithTaskType(taskType int) TaskInfoOption { + return func(t *TaskInfo) { + t.taskType = taskType + } +} + +func NewTask(opts ...TaskInfoOption) *TaskInfo { + t := &TaskInfo{lastRunningAt: nil} + for _, opt := range opts { + opt(t) + } + return t +} + +func (t *TaskInfo) Save() { + timeNow := time.Now() + db := models.GetDB() + task := &models.Task{} + db.Model(&models.Task{}).Where("task_id = ?", t.taskId).FirstOrInit(&task) + task.TaskId = t.taskId + task.Name = t.name + task.LastRunningAt = &timeNow + task.NextRunningAt = t.nextRunningAt + task.Type = t.taskType + if t.id > 0 { + task.ID = t.id + } + db.Save(&task) + t.id = task.ID +} + +func (t *TaskInfo) UpdateNextRunningAt(seconds int) { + nextTime := time.Now().Add(time.Second * time.Duration(seconds)) + t.nextRunningAt = &nextTime + models.GetDB().Model( + &models.Task{}, + ).Select("next_running_at").Where("id = ?", t.id).Updates( + &models.Task{ + NextRunningAt: &nextTime, + }, + ) +} diff --git a/services/video.go b/services/video.go new file mode 100644 index 0000000..7492f42 --- /dev/null +++ b/services/video.go @@ -0,0 +1,141 @@ +package services + +import ( + "bilibo/consts" + "bilibo/log" + "bilibo/models" + "time" +) + +type VideoService struct { + V *models.Videos +} + +func (f *VideoService) SetMid(mid int) { + f.V = &models.Videos{ + Mid: mid, + } +} + +func (f *VideoService) Save() { + db := models.GetDB() + var video models.Videos + db.Where(models.Videos{ + Bvid: f.V.Bvid, + Mlid: f.V.Mlid, + Mid: f.V.Mid, + Cid: f.V.Cid, + Type: f.V.Type, + }).FirstOrInit(&video) + needUpdata := false + if video.ID == 0 && f.V.Type == consts.VIDEO_TYPE_WATCH_LATER { + video.Status = consts.VIDEO_STATUS_INIT + needUpdata = true + } else if video.ID == 0 && f.V.Type == consts.VIDEO_TYPE_FAVOUR { + favInfo := GetFavourInfoByMlid(f.V.Mlid) + video.Status = consts.VIDEO_STATUS_INIT + if favInfo != nil && favInfo.Sync == consts.FAVOUR_NEED_SYNC { + video.Status = consts.VIDEO_STATUS_TO_BE_DOWNLOAD + } + needUpdata = true + } + if video.Part != f.V.Part { + video.Part = f.V.Part + needUpdata = true + } + if video.Width != f.V.Width { + video.Width = f.V.Width + needUpdata = true + } + if video.Height != f.V.Height { + video.Height = f.V.Height + needUpdata = true + } + if video.Rotate != f.V.Rotate { + video.Rotate = f.V.Rotate + needUpdata = true + } + if video.Title != f.V.Title { + video.Title = f.V.Title + needUpdata = true + } + if video.Page != f.V.Page { + video.Page = f.V.Page + needUpdata = true + } + if needUpdata { + db.Save(&video) + } + +} + +func GetVideoByMidStatus(mid, status int) *models.Videos { + db := models.GetDB() + var video models.Videos + subQuery := db.Model(&models.FavourFoldersInfo{}).Where( + &models.FavourFoldersInfo{Mid: mid, Sync: consts.FAVOUR_NEED_SYNC}, + ).Select("mlid") + db.Where( + "mid = ? AND status = ? AND (mlid IN (?) OR mlid = 0)", + mid, status, subQuery, + ).First(&video) + if video.ID == 0 { + return nil + } + return &video +} + +func GetToBeDownloadByMid(mid int) *models.Videos { + return GetVideoByMidStatus(mid, consts.VIDEO_STATUS_TO_BE_DOWNLOAD) +} + +func GetRetryByMid(mid int) *models.Videos { + before, _ := time.ParseDuration("-2h") + last_time := time.Now().Add(before) + db := models.GetDB() + var video models.Videos + subQuery := db.Model(&models.FavourFoldersInfo{}).Where( + &models.FavourFoldersInfo{Mid: mid, Sync: consts.FAVOUR_NEED_SYNC}, + ).Select("mlid") + db.Model(&models.Videos{}).Where( + "mid = ? AND status = ? AND (mlid IN (?) OR mlid = 0) AND last_download_at < ?", + mid, consts.VIDEO_STATUS_DOWNLOAD_RETRY, subQuery, last_time, + ).First(&video) + if video.ID == 0 { + return nil + } + return &video +} + +func SetVideoStatus(id uint, status int) { + db := models.GetDB() + var video models.Videos + db.Where("id = ?", id).First(&video) + video.Status = status + if video.Status == consts.VIDEO_STATUS_DOWNLOADING { + timeNow := time.Now() + video.LastDownloadAt = &timeNow + } + db.Save(&video) +} + +func SetUserVideosStatus(mid int, status int) { + db := models.GetDB() + db.Model(&models.Videos{}).Where( + "mid = ? AND status != ?", mid, status, + ).Updates(map[string]interface{}{"status": status}) +} + +func SetVideoErrorMessage(Mlid, Mid int, Bvid, Error string) { + logger := log.GetLogger() + logger.Errorf(Error) + db := models.GetDB() + errorInfo := models.VideoDownloadMessage{ + Mlid: Mlid, + Mid: Mid, + Bvid: Bvid, + Message: Error, + Type: consts.VIDEO_MESSAGE_ERROR, + } + db.Create(&errorInfo) +} diff --git a/tests/realtime_job_test.go b/tests/realtime_job_test.go index efaef21..3a54e18 100644 --- a/tests/realtime_job_test.go +++ b/tests/realtime_job_test.go @@ -4,7 +4,7 @@ import ( "bilibo/config" "bilibo/consts" "bilibo/models" - "bilibo/realtime_job" + "bilibo/services" "bilibo/utils" "os" "path/filepath" @@ -23,12 +23,12 @@ func setup() { db.Migrator().DropTable( &models.BiliAccounts{}, &models.FavourFoldersInfo{}, - &models.FavourVideos{}, + &models.Videos{}, ) db.AutoMigrate( &models.BiliAccounts{}, &models.FavourFoldersInfo{}, - &models.FavourVideos{}, + &models.Videos{}, ) account := models.BiliAccounts{ @@ -54,7 +54,7 @@ func setup() { } db.Save(&fav) - video1 := models.FavourVideos{ + video1 := models.Videos{ Mlid: 1, Mid: 1, Bvid: "abc1", @@ -70,7 +70,7 @@ func setup() { } db.Save(&video1) - video2 := models.FavourVideos{ + video2 := models.Videos{ Mlid: 1, Mid: 1, Bvid: "abc2", @@ -86,7 +86,7 @@ func setup() { } db.Save(&video2) - video3 := models.FavourVideos{ + video3 := models.Videos{ Mlid: 1, Mid: 1, Bvid: "abc3", @@ -102,7 +102,7 @@ func setup() { } db.Save(&video3) - video4 := models.FavourVideos{ + video4 := models.Videos{ Mlid: 1, Mid: 1, Bvid: "abc4", @@ -124,7 +124,7 @@ func teardown() { db.Migrator().DropTable( &models.BiliAccounts{}, &models.FavourFoldersInfo{}, - &models.FavourVideos{}, + &models.Videos{}, ) os.RemoveAll(config.GetConfig().Download.Path) } @@ -136,7 +136,7 @@ func ChangeFavourName(t *testing.T) { db := models.GetDB() var downloadingCount1 int64 - db.Model(&models.FavourVideos{}).Where( + db.Model(&models.Videos{}).Where( "status IN (?)", []int{consts.VIDEO_STATUS_DOWNLOAD_FAIL, consts.VIDEO_STATUS_DOWNLOAD_RETRY, consts.VIDEO_STATUS_TO_BE_DOWNLOAD}, ).Count(&downloadingCount1) @@ -144,7 +144,7 @@ func ChangeFavourName(t *testing.T) { t.Fatal("downloadingCount != 3") } basePath := utils.GetFavourPath(1, config.GetConfig().Download.Path) - go realtime_job.ChangeFavourName(1, + go services.ChangeFavourName(1, filepath.Join(basePath, "test"), filepath.Join(basePath, "test1"), ) @@ -152,7 +152,7 @@ func ChangeFavourName(t *testing.T) { time.Sleep(5 * time.Second) var downloadingCount2 int64 - db.Model(&models.FavourVideos{}).Where( + db.Model(&models.Videos{}).Where( "status < 100", ).Count(&downloadingCount2) if downloadingCount2 != 1 { @@ -160,7 +160,7 @@ func ChangeFavourName(t *testing.T) { t.Fatal("downloadingCount != 0") } time.Sleep(2 * time.Second) - db.Model(&models.FavourVideos{}).Where( + db.Model(&models.Videos{}).Where( "status = ?", consts.VIDEO_STATUS_DOWNLOADING, ).Update("status", consts.VIDEO_STATUS_DOWNLOAD_DONE) time.Sleep(3 * time.Second) @@ -176,10 +176,10 @@ func DeleteFavour(t *testing.T) { setup() defer teardown() db := models.GetDB() - go realtime_job.DeleteFavours([]int{1}) + go services.DeleteFavours([]int{1}) time.Sleep(5 * time.Second) - db.Model(&models.FavourVideos{}).Where( + db.Model(&models.Videos{}).Where( "status = ?", consts.VIDEO_STATUS_DOWNLOADING, ).Update("status", consts.VIDEO_STATUS_DOWNLOAD_DONE) time.Sleep(3 * time.Second) diff --git a/utils/file_utils.go b/utils/file_utils.go index 741254d..17c9ca1 100644 --- a/utils/file_utils.go +++ b/utils/file_utils.go @@ -26,6 +26,10 @@ func GetFavourPath(mid int, basePath string) string { return getPath(mid, basePath, consts.ACCOUNT_DIR_FAVOUR) } +func GetWatchLaterPath(mid int, basePath string) string { + return getPath(mid, basePath, consts.ACCOUNT_DIR_WATCH_LATER) +} + func GetRecyclePath(mid int, basePath string) string { return getPath(mid, basePath, consts.ACCOUNT_DIR_RECYCLE) } diff --git a/web/router.go b/web/router.go index 46d69d1..e93f182 100644 --- a/web/router.go +++ b/web/router.go @@ -16,4 +16,6 @@ func Route(r *gin.Engine) { views.RegAccount(api) views.RegFav(api) views.RegVideo(api) + views.RegWatchLater(api) + views.RegTask(api) } diff --git a/web/services/account.go b/web/services/account.go index 1025155..dd9392b 100644 --- a/web/services/account.go +++ b/web/services/account.go @@ -1,12 +1,20 @@ package services import ( + "bilibo/config" "bilibo/consts" "bilibo/models" "bilibo/universal" "fmt" "net/url" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "github.com/gabriel-vasile/mimetype" + "github.com/maruel/natural" "golang.org/x/exp/maps" ) @@ -73,12 +81,24 @@ type FavourFolders struct { } type AccountInfo struct { - Mid int `json:"mid"` - Uname string `json:"uname"` - Status int `json:"status"` - Face string `json:"face"` - FoldersCount int `json:"folders_count"` - Folders []*FavourFolders `json:"folders"` + Mid int `json:"mid"` + Uname string `json:"uname"` + Status int `json:"status"` + Face string `json:"face"` + FoldersCount int `json:"folders_count"` + Folders []*FavourFolders `json:"folders"` + WatchLaterCount int64 `json:"watch_later_count"` + WatchLaterSync int `json:"watch_later_sync"` +} + +type AccountWatchLaterCount struct { + Mid int `json:"mid"` + Count int64 `json:"count"` +} + +type AccountWatchLaterSync struct { + Mid int `json:"mid"` + Sync int `json:"sync"` } func AccountList(page, pageSize int) (*[]*AccountInfo, int64) { @@ -98,16 +118,18 @@ func AccountList(page, pageSize int) (*[]*AccountInfo, int64) { data.Mid, url.QueryEscape(data.Face), ), - Uname: data.UName, - Folders: make([]*FavourFolders, 0), - FoldersCount: 0, + Uname: data.UName, + Folders: make([]*FavourFolders, 0), + FoldersCount: 0, + WatchLaterCount: 0, + WatchLaterSync: 0, } accountMap[data.Mid] = &item accountMids = append(accountMids, data.Mid) } var favourFolderInfos []models.FavourFoldersInfo - db.Where("mid IN (?)", accountMids).Find(&favourFolderInfos) + db.Model(&models.FavourFoldersInfo{}).Where("mid IN (?)", accountMids).Find(&favourFolderInfos) for _, v := range favourFolderInfos { folders := FavourFolders{ Mlid: v.Mlid, @@ -119,6 +141,24 @@ func AccountList(page, pageSize int) (*[]*AccountInfo, int64) { accountMap[v.Mid].Folders = append(accountMap[v.Mid].Folders, &folders) accountMap[v.Mid].FoldersCount++ } + + watchLaterCount := make([]AccountWatchLaterCount, 0) + db.Model(&models.Videos{}).Select("COUNT(mid) AS count", "mid").Where( + "mid IN (?) AND type = ?", accountMids, + consts.VIDEO_TYPE_WATCH_LATER, + ).Group("mid").Find(&watchLaterCount) + for _, v := range watchLaterCount { + accountMap[v.Mid].WatchLaterCount = v.Count + } + + watchLaterSync := make([]AccountWatchLaterSync, 0) + db.Model(&models.WatchLater{}).Select("sync", "mid").Where( + "mid IN (?)", accountMids, + ).Find(&watchLaterSync) + for _, v := range watchLaterSync { + accountMap[v.Mid].WatchLaterSync = v.Sync + } + } items := maps.Values(accountMap) return &items, total @@ -130,3 +170,98 @@ func AccountTotal() int64 { db.Model(&models.BiliAccounts{}).Count(&total) return total } + +type AccountFile struct { + BaseName string `json:"basename"` + Extension string `json:"extension"` + ExtraMetadata []string `json:"extra_metadata"` + FileSize int64 `json:"file_size"` + LastModified int64 `json:"last_modified"` + MimeType *string `json:"mime_type"` + Path string `json:"path"` + Storage string `json:"storage"` + Type string `json:"type"` + Visibility string `json:"visibility"` +} + +func GetAccountIndex(mid, action, path string) map[string]interface{} { + result := make(map[string]interface{}) + conf := config.GetConfig() + rootPath := filepath.Join(conf.Download.Path, mid) + + result["adapter"] = mid + result["dirname"] = path + result["storages"] = []string{mid} + + files := make([]*AccountFile, 0) + result["files"] = files + + subPath := filepath.Join(rootPath, strings.ReplaceAll(path, mid+"://", "/")) + fileMap := make(map[string]*AccountFile) + fileNames := make([]string, 0) + dirFiles, err := os.ReadDir(subPath) + if err != nil { + return result + } + + for _, file := range dirFiles { + if fileInfo, err := file.Info(); err == nil { + file := AccountFile{ + Path: mid + ":/" + filepath.Join(strings.ReplaceAll(path, mid+"://", "/"), fileInfo.Name()), + Visibility: "public", + ExtraMetadata: make([]string, 0), + FileSize: fileInfo.Size(), + LastModified: fileInfo.ModTime().Unix(), + Storage: mid, + BaseName: fileInfo.Name(), + MimeType: nil, + } + if fileInfo.IsDir() { + file.Type = "dir" + file.Extension = "" + } else { + file.Type = "file" + } + fileNames = append(fileNames, fileInfo.Name()) + fileMap[fileInfo.Name()] = &file + } + } + + if len(fileNames) < 1 { + return result + } + + sort.Sort(natural.StringSlice(fileNames)) + + for _, fileName := range fileNames { + file := fileMap[fileName] + if file.Type == "file" { + mtype, err := mimetype.DetectFile(filepath.Join(subPath, file.BaseName)) + if err != nil { + file.MimeType = nil + fextension := strings.Split(file.BaseName, ".") + slices.Reverse(fextension) + file.Extension = fextension[0] + continue + } else { + fmtype := mtype.String() + file.MimeType = &fmtype + file.Extension = strings.Replace(mtype.Extension(), ".", "", 1) + } + } + files = append(files, file) + } + + result["files"] = files + return result +} +func GetAccountFileDownload(mid, action, path string) (string, error) { + conf := config.GetConfig() + rootPath := filepath.Join(conf.Download.Path, mid) + filePath := filepath.Join(rootPath, strings.ReplaceAll(path, mid+"://", "/")) + if _, err := os.Stat(filePath); err != nil { + return "", err + } else { + return filePath, nil + } +} diff --git a/web/services/favour.go b/web/services/favour.go index 7a0908d..799ca60 100644 --- a/web/services/favour.go +++ b/web/services/favour.go @@ -1,18 +1,8 @@ package services import ( - "bilibo/config" "bilibo/consts" - "bilibo/log" "bilibo/models" - "os" - "path/filepath" - "slices" - "sort" - "strings" - - "github.com/gabriel-vasile/mimetype" - "github.com/maruel/natural" ) func DelFavourInfoByMid(mid int) { @@ -23,7 +13,7 @@ func DelFavourInfoByMid(mid int) { func GetAccountFavourInfoByMid(mid int) *[]*FavourFolders { db := models.GetDB() var favourFolderInfos []models.FavourFoldersInfo - db.Model(&models.FavourFoldersInfo{}).Where("mid = ?", mid).Find(&favourFolderInfos) + db.Model(&models.FavourFoldersInfo{}).Where("mid = ?", mid).Order("mlid DESC").Find(&favourFolderInfos) datas := make([]*FavourFolders, 0) for _, v := range favourFolderInfos { datas = append(datas, &FavourFolders{ @@ -40,103 +30,6 @@ func SetFavourSyncStatus(mid, mlid, status int) { db := models.GetDB() db.Model(&models.FavourFoldersInfo{}).Where("mlid = ?", mlid).Update("sync", status) if status == consts.FAVOUR_NEED_SYNC { - db.Model(&models.FavourVideos{}).Where("mid = ? AND mlid = ? AND status = ?", mid, mlid, consts.VIDEO_STATUS_INIT).Update("status", consts.VIDEO_STATUS_TO_BE_DOWNLOAD) - } -} - -type FavFile struct { - BaseName string `json:"basename"` - Extension string `json:"extension"` - ExtraMetadata []string `json:"extra_metadata"` - FileSize int64 `json:"file_size"` - LastModified int64 `json:"last_modified"` - MimeType *string `json:"mime_type"` - Path string `json:"path"` - Storage string `json:"storage"` - Type string `json:"type"` - Visibility string `json:"visibility"` -} - -func GetFavourIndex(mid, action, path string) map[string]interface{} { - result := make(map[string]interface{}) - conf := config.GetConfig() - rootPath := filepath.Join(conf.Download.Path, mid) - - result["adapter"] = mid - result["dirname"] = path - result["storages"] = []string{mid} - - subPath := filepath.Join(rootPath, strings.ReplaceAll(path, mid+"://", "/")) - fileMap := make(map[string]*FavFile) - fileNames := make([]string, 0) - logger := log.GetLogger() - logger.Info(subPath) - logger.Info("fuck") - dirFiles, err := os.ReadDir(subPath) - if err != nil { - return result - } - - for _, file := range dirFiles { - if fileInfo, err := file.Info(); err == nil { - file := FavFile{ - Path: mid + ":/" + filepath.Join(strings.ReplaceAll(path, mid+"://", "/"), fileInfo.Name()), - Visibility: "public", - ExtraMetadata: make([]string, 0), - FileSize: fileInfo.Size(), - LastModified: fileInfo.ModTime().Unix(), - Storage: mid, - BaseName: fileInfo.Name(), - MimeType: nil, - } - if fileInfo.IsDir() { - file.Type = "dir" - file.Extension = "" - } else { - file.Type = "file" - } - fileNames = append(fileNames, fileInfo.Name()) - fileMap[fileInfo.Name()] = &file - } - } - - if len(fileNames) < 1 { - return result - } - - sort.Sort(natural.StringSlice(fileNames)) - - files := make([]*FavFile, 0) - - for _, fileName := range fileNames { - file := fileMap[fileName] - if file.Type == "file" { - mtype, err := mimetype.DetectFile(filepath.Join(subPath, file.BaseName)) - if err != nil { - file.MimeType = nil - fextension := strings.Split(file.BaseName, ".") - slices.Reverse(fextension) - file.Extension = fextension[0] - continue - } else { - fmtype := mtype.String() - file.MimeType = &fmtype - file.Extension = strings.Replace(mtype.Extension(), ".", "", 1) - } - } - files = append(files, file) - } - - result["files"] = files - return result -} -func GetFavourFileDownload(mid, action, path string) (string, error) { - conf := config.GetConfig() - rootPath := filepath.Join(conf.Download.Path, mid) - filePath := filepath.Join(rootPath, strings.ReplaceAll(path, mid+"://", "/")) - if _, err := os.Stat(filePath); err != nil { - return "", err - } else { - return filePath, nil + db.Model(&models.Videos{}).Where("mid = ? AND mlid = ? AND status = ?", mid, mlid, consts.VIDEO_STATUS_INIT).Update("status", consts.VIDEO_STATUS_TO_BE_DOWNLOAD) } } diff --git a/web/services/login.go b/web/services/login.go index e180c76..b903b80 100644 --- a/web/services/login.go +++ b/web/services/login.go @@ -3,6 +3,7 @@ package services import ( "bilibo/config" "bilibo/consts" + "bilibo/log" "bilibo/universal" "bilibo/utils" "encoding/json" @@ -105,7 +106,7 @@ func (c *client) getNavigation() (*navigation, int64, error) { } var ret *navigation err = json.Unmarshal(data, &ret) - if err != nil { + if err == nil { c.imgKey = ret.WbiImg.ImgUrl c.subKey = ret.WbiImg.SubUrl c.mid = ret.Mid @@ -164,24 +165,32 @@ type Info struct { } func (c *client) loginWithQRCode(qrCode *qrCode) (*Info, error) { + logger := log.GetLogger() if qrCode == nil { return nil, errors.New("请先获取二维码") } for { ok, err := c.qrCodeSuccess(qrCode) if err != nil { + logger.Info("qrCodeSuccess") + logger.Info(err) return nil, err } if ok { - c.getNavigation() - return &Info{ - Cookies: c.GetCookiesString(), - Mid: c.mid, - UName: c.uname, - Face: c.face, - ImgKey: c.imgKey, - SubKey: c.subKey, - }, nil + if _, _, err := c.getNavigation(); err != nil { + logger.Info("getNavigation") + logger.Info(err) + return nil, err + } else { + return &Info{ + Cookies: c.GetCookiesString(), + Mid: c.mid, + UName: c.uname, + Face: c.face, + ImgKey: c.imgKey, + SubKey: c.subKey, + }, nil + } } time.Sleep(3 * time.Second) // 主站 3s 一次请求 } @@ -230,10 +239,7 @@ func SetAccountInfo() (string, int64, error) { if err != nil { return "", 0, err } - qrCode, err := c.getQRCode() - if err != nil { - return "", 0, err - } + qrImgByte, err := qr.Encode() if err != nil { return "", 0, err @@ -251,7 +257,7 @@ func SetAccountInfo() (string, int64, error) { url := "/api/account/qrcode/" + fileName AddQRCodeInfo(fmt.Sprintf("%d", qrId)) go func() { - if info, err := c.loginWithQRCode(qrCode); err == nil { + if info, err := c.loginWithQRCode(qr); err == nil { SaveAccountInfo( info.Mid, info.UName, info.Face, diff --git a/web/services/task.go b/web/services/task.go new file mode 100644 index 0000000..a9534aa --- /dev/null +++ b/web/services/task.go @@ -0,0 +1,31 @@ +package services + +import ( + "bilibo/models" + "time" +) + +type Task struct { + TaskId string `json:"task_id"` + Name string `json:"name"` + LastRunningAt *time.Time `json:"last_running_at"` + NextRunningAt *time.Time `json:"next_running_at"` + Type int `json:"type"` +} + +func TaskList() []*Task { + db := models.GetDB() + taskList := make([]*Task, 0) + dbTaskList := make([]*models.Task, 0) + db.Model(&models.Task{}).Order("next_running_at asc").Find(&dbTaskList) + for _, v := range dbTaskList { + taskList = append(taskList, &Task{ + TaskId: v.TaskId, + Name: v.Name, + LastRunningAt: v.LastRunningAt, + NextRunningAt: v.NextRunningAt, + Type: v.Type, + }) + } + return taskList +} diff --git a/web/services/favour_video.go b/web/services/video.go similarity index 76% rename from web/services/favour_video.go rename to web/services/video.go index f3af4cd..6e46d99 100644 --- a/web/services/favour_video.go +++ b/web/services/video.go @@ -8,9 +8,9 @@ import ( "golang.org/x/exp/maps" ) -func DelFavourVideoByMid(mid int) { +func DelVideoByMid(mid int) { db := models.GetDB() - db.Where(models.FavourVideos{Mid: mid}).Delete(&models.FavourVideos{}) + db.Where(models.Videos{Mid: mid}).Delete(&models.Videos{}) } type VideoInfo struct { @@ -40,18 +40,20 @@ func GetVideosByStatus(status, page, pageSize int) (*[]*VideoInfo, int64) { statusList := handleQueryStatus(status) - query := db.Model(&models.FavourVideos{}).Where("status IN (?)", statusList) + query := db.Model(&models.Videos{}).Where("status IN (?)", statusList) query.Count(&total) if total > 0 { - var favourVideos []models.FavourVideos - query.Order("updated_at DESC").Limit(pageSize).Offset((page - 1) * pageSize).Find(&favourVideos) + var videos []models.Videos + query.Order("updated_at DESC").Limit(pageSize).Offset((page - 1) * pageSize).Find(&videos) accountMap := make(map[int]*AccountInfo, 0) favMap := make(map[int]*FavourFolders, 0) - for _, v := range favourVideos { + for _, v := range videos { accountMap[v.Mid] = nil - favMap[v.Mlid] = nil + if v.Mlid > 0 { + favMap[v.Mlid] = nil + } } var favourFolderInfos []models.FavourFoldersInfo @@ -77,11 +79,16 @@ func GetVideosByStatus(status, page, pageSize int) (*[]*VideoInfo, int64) { } } - for _, v := range favourVideos { + for _, v := range videos { favTitle := "" - if favMap[v.Mlid] != nil { - favTitle = favMap[v.Mlid].Title + if v.Mlid > 0 { + if favMap[v.Mlid] != nil { + favTitle = favMap[v.Mlid].Title + } + } else if v.Mlid == 0 { + favTitle = consts.ACCOUNT_DIR_WATCH_LATER } + accountName := "" if accountMap[v.Mid] != nil { accountName = accountMap[v.Mid].Uname @@ -101,3 +108,8 @@ func GetVideosByStatus(status, page, pageSize int) (*[]*VideoInfo, int64) { return &result, total } + +func SetToViewStatus(mid int, status int) { + db := models.GetDB() + db.Model(&models.Videos{}).Where("mid = ? AND mlid = 0", mid).Update("status", status) +} diff --git a/web/services/watch_later.go b/web/services/watch_later.go new file mode 100644 index 0000000..689c333 --- /dev/null +++ b/web/services/watch_later.go @@ -0,0 +1,43 @@ +package services + +import ( + "bilibo/consts" + "bilibo/models" +) + +func GetWatchLaterInfoByMid(mid int) *models.WatchLater { + db := models.GetDB() + var info models.WatchLater + db.Where("mid = ?", mid).First(&info) + return &info +} + +func SetWatchLaterSync(mid int, sync int) { + db := models.GetDB() + var info models.WatchLater + db.Where("mid = ?", mid).First(&info) + if info.ID == 0 { + info.Mid = mid + } + info.Sync = sync + db.Save(&info) + sql := db.Model(&models.Videos{}).Where( + "mid = ? AND mlid = 0 AND type = ?", mid, consts.VIDEO_TYPE_WATCH_LATER, + ) + if sync == consts.WATCH_LATER_NEED_SYNC { + sql.Where("status NOT IN (?)", + []int{ + consts.VIDEO_STATUS_DOWNLOAD_DONE, + consts.VIDEO_STATUS_DOWNLOAD_FAIL, + consts.VIDEO_STATUS_DOWNLOAD_RETRY, + consts.VIDEO_STATUS_TO_BE_DOWNLOAD, + }) + sql.Update("status", consts.VIDEO_STATUS_TO_BE_DOWNLOAD) + } else if sync == consts.WATCH_LATER_NOT_SYNC { + sql.Where( + "status = ?", consts.VIDEO_STATUS_TO_BE_DOWNLOAD, + ) + sql.Update("status", consts.VIDEO_STATUS_INIT) + } + db.Save(&info) +} diff --git a/web/views/AccountViews.go b/web/views/AccountViews.go index 01ee078..9046567 100644 --- a/web/views/AccountViews.go +++ b/web/views/AccountViews.go @@ -2,13 +2,17 @@ package views import ( "bilibo/config" + "bilibo/log" + "bilibo/utils" "bilibo/web/services" "fmt" "io" "net/http" "net/url" "path/filepath" + "slices" "strconv" + "strings" "github.com/gin-gonic/gin" ) @@ -21,6 +25,7 @@ func RegAccount(rg *gin.RouterGroup) { account.GET("proxy/:mid", accountProxy) account.GET("/qrcode/:fileName", accountQrCode) account.GET("/qrcode/status/:id", accountQrCodeStatus) + account.GET("dir", AccountDir) } func accountList(c *gin.Context) { @@ -69,7 +74,7 @@ func accountDelete(c *gin.Context) { var req accountDeleteReq c.BindJSON(&req) services.DelFavourInfoByMid(req.Mid) - services.DelFavourVideoByMid(req.Mid) + services.DelVideoByMid(req.Mid) services.DelAccount(req.Mid) c.JSON(http.StatusOK, gin.H{ "message": "account delete", @@ -86,7 +91,7 @@ func accountSave(c *gin.Context) { } url, qrId, err := services.SetAccountInfo() - if err != nil { + if err == nil { data["url"] = url data["id"] = fmt.Sprintf("%d", qrId) rsp["data"] = data @@ -128,11 +133,6 @@ func accountQrCodeStatus(c *gin.Context) { } func accountProxy(c *gin.Context) { - // mid, err := strconv.Atoi(c.Param("mid")) - // if err != nil { - // c.JSON(500, gin.H{"message": err.Error()}) - // return - // } faceUrlEncode := c.Query("url") faceUrlDecode, err := url.QueryUnescape(faceUrlEncode) if err != nil { @@ -155,3 +155,42 @@ func accountProxy(c *gin.Context) { } c.Writer.Write(body) } + +func AccountDir(c *gin.Context) { + logger := log.GetLogger() + rsp := gin.H{ + "message": "", + "result": 0, + } + if queryMap, err := utils.GetQueryMap(c, []string{"q", "adapter"}); err != nil { + rsp["result"] = 999 + rsp["message"] = err.Error() + c.JSON(http.StatusOK, rsp) + return + } else { + path := c.DefaultQuery("path", queryMap["adapter"]+"://") + if queryMap["q"] == "index" { + rsp = services.GetAccountIndex(queryMap["adapter"], queryMap["q"], path) + c.JSON(http.StatusOK, rsp) + return + } else if (queryMap["q"] == "preview" || queryMap["q"] == "download") && path != "" { + filePath, err := services.GetAccountFileDownload(queryMap["adapter"], queryMap["q"], path) + if err != nil { + logger.Error("get favour file download error: %v", err) + rsp["result"] = 999 + rsp["message"] = err.Error() + c.JSON(http.StatusOK, rsp) + } else { + logger.Info("get favour file download: %s", filePath) + fileNameSplit := strings.Split(path, "/") + slices.Reverse(fileNameSplit) + fileName := fileNameSplit[0] + c.Header("Content-Description", "Simulation File Download") + c.Header("Content-Transfer-Encoding", "binary") + c.Header("Content-Disposition", "attachment; filename="+fileName) + c.Header("Content-Type", "application/octet-stream") + c.File(filePath) + } + } + } +} diff --git a/web/views/FavViews.go b/web/views/FavViews.go index 3e1e54a..d15f106 100644 --- a/web/views/FavViews.go +++ b/web/views/FavViews.go @@ -2,13 +2,9 @@ package views import ( "bilibo/consts" - "bilibo/log" - "bilibo/utils" "bilibo/web/services" "net/http" - "slices" "strconv" - "strings" "github.com/gin-gonic/gin" ) @@ -18,7 +14,6 @@ func RegFav(rg *gin.RouterGroup) { fav.GET("account_fav", accountFav) fav.POST("set_sync", SetSyncFav) fav.POST("set_not_sync", SetNotSyncFav) - fav.GET("dir", FavDir) } func accountFav(c *gin.Context) { @@ -70,42 +65,3 @@ func SetNotSyncFav(c *gin.Context) { } c.JSON(http.StatusOK, rsp) } - -func FavDir(c *gin.Context) { - logger := log.GetLogger() - rsp := gin.H{ - "message": "", - "result": 0, - } - if queryMap, err := utils.GetQueryMap(c, []string{"q", "adapter"}); err != nil { - rsp["result"] = 999 - rsp["message"] = err.Error() - c.JSON(http.StatusOK, rsp) - return - } else { - path := c.DefaultQuery("path", queryMap["adapter"]+"://") - if queryMap["q"] == "index" { - rsp = services.GetFavourIndex(queryMap["adapter"], queryMap["q"], path) - c.JSON(http.StatusOK, rsp) - return - } else if (queryMap["q"] == "preview" || queryMap["q"] == "download") && path != "" { - filePath, err := services.GetFavourFileDownload(queryMap["adapter"], queryMap["q"], path) - if err != nil { - logger.Error("get favour file download error: %v", err) - rsp["result"] = 999 - rsp["message"] = err.Error() - c.JSON(http.StatusOK, rsp) - } else { - logger.Info("get favour file download: %s", filePath) - fileNameSplit := strings.Split(path, "/") - slices.Reverse(fileNameSplit) - fileName := fileNameSplit[0] - c.Header("Content-Description", "Simulation File Download") - c.Header("Content-Transfer-Encoding", "binary") - c.Header("Content-Disposition", "attachment; filename="+fileName) - c.Header("Content-Type", "application/octet-stream") - c.File(filePath) - } - } - } -} diff --git a/web/views/TaskViews.go b/web/views/TaskViews.go index e29e57d..db70641 100644 --- a/web/views/TaskViews.go +++ b/web/views/TaskViews.go @@ -1 +1,24 @@ package views + +import ( + "bilibo/web/services" + "net/http" + + "github.com/gin-gonic/gin" +) + +func RegTask(rg *gin.RouterGroup) { + task := rg.Group("task") + task.GET("list", taskList) +} + +func taskList(c *gin.Context) { + rsp := gin.H{ + "data": nil, + "message": "", + "result": 0, + } + rsp["data"] = services.TaskList() + + c.JSON(http.StatusOK, rsp) +} diff --git a/web/views/FavVideoViews.go b/web/views/VideoViews.go similarity index 100% rename from web/views/FavVideoViews.go rename to web/views/VideoViews.go diff --git a/web/views/WatchLaterViews.go b/web/views/WatchLaterViews.go new file mode 100644 index 0000000..6c85622 --- /dev/null +++ b/web/views/WatchLaterViews.go @@ -0,0 +1,58 @@ +package views + +import ( + "bilibo/consts" + "bilibo/web/services" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +func RegWatchLater(rg *gin.RouterGroup) { + wl := rg.Group("watch_later") + wl.GET("status", GetWLStatus) + wl.POST("set_sync", SetWLSync) + wl.POST("set_not_sync", SetWLNotSync) +} + +func GetWLStatus(c *gin.Context) { + resp := gin.H{ + "data": nil, + "message": "", + "result": 0, + } + midStr := c.DefaultQuery("mid", "") + if midStr != "" { + if mid, err := strconv.Atoi(midStr); err == nil { + resp["data"] = services.GetWatchLaterInfoByMid(mid) + } + } + c.JSON(http.StatusOK, resp) +} + +type SetWLStatusReq struct { + Mid int `json:"mid" binding:"required"` +} + +func SetWLSync(c *gin.Context) { + var req SetWLStatusReq + c.BindJSON(&req) + services.SetWatchLaterSync(req.Mid, consts.WATCH_LATER_NEED_SYNC) + rsp := gin.H{ + "message": "", + "result": 0, + } + c.JSON(http.StatusOK, rsp) +} + +func SetWLNotSync(c *gin.Context) { + var req SetWLStatusReq + c.BindJSON(&req) + services.SetWatchLaterSync(req.Mid, consts.WATCH_LATER_NOT_SYNC) + rsp := gin.H{ + "message": "", + "result": 0, + } + c.JSON(http.StatusOK, rsp) +}