From 9edef8d683ecc454901a3594757bdfd3fb46b03c Mon Sep 17 00:00:00 2001 From: zijiren233 Date: Sat, 16 Nov 2024 12:06:40 +0800 Subject: [PATCH] fix: case --- Dockerfile | 2 +- internal/provider/plugins/plugin.go | 2 +- internal/sysnotify/signal.go | 26 ++ internal/sysnotify/signal_windows.go | 21 + internal/sysnotify/sysnotify.go | 121 ++++++ server/handlers/vendors/vendoralist/alist.go | 364 ++++++++++++++++++ server/handlers/vendors/vendoralist/list.go | 178 +++++++++ server/handlers/vendors/vendoralist/login.go | 127 ++++++ server/handlers/vendors/vendoralist/me.go | 83 ++++ .../vendors/vendorbilibili/bilibili.go | 289 ++++++++++++++ .../handlers/vendors/vendorbilibili/login.go | 243 ++++++++++++ server/handlers/vendors/vendorbilibili/me.go | 50 +++ .../handlers/vendors/vendorbilibili/parse.go | 141 +++++++ server/handlers/vendors/vendoremby/emby.go | 344 +++++++++++++++++ server/handlers/vendors/vendoremby/list.go | 177 +++++++++ server/handlers/vendors/vendoremby/login.go | 138 +++++++ server/handlers/vendors/vendoremby/me.go | 83 ++++ 17 files changed, 2387 insertions(+), 2 deletions(-) create mode 100644 internal/sysnotify/signal.go create mode 100644 internal/sysnotify/signal_windows.go create mode 100644 internal/sysnotify/sysnotify.go create mode 100644 server/handlers/vendors/vendoralist/alist.go create mode 100644 server/handlers/vendors/vendoralist/list.go create mode 100644 server/handlers/vendors/vendoralist/login.go create mode 100644 server/handlers/vendors/vendoralist/me.go create mode 100644 server/handlers/vendors/vendorbilibili/bilibili.go create mode 100644 server/handlers/vendors/vendorbilibili/login.go create mode 100644 server/handlers/vendors/vendorbilibili/me.go create mode 100644 server/handlers/vendors/vendorbilibili/parse.go create mode 100644 server/handlers/vendors/vendoremby/emby.go create mode 100644 server/handlers/vendors/vendoremby/list.go create mode 100644 server/handlers/vendors/vendoremby/login.go create mode 100644 server/handlers/vendors/vendoremby/me.go diff --git a/Dockerfile b/Dockerfile index 301fc28e..21bc128c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine as builder +FROM golang:1.23-alpine AS builder ARG VERSION=dev diff --git a/internal/provider/plugins/plugin.go b/internal/provider/plugins/plugin.go index 78733437..3caf5f49 100644 --- a/internal/provider/plugins/plugin.go +++ b/internal/provider/plugins/plugin.go @@ -9,7 +9,7 @@ import ( "github.com/hashicorp/go-plugin" "github.com/synctv-org/synctv/internal/provider" "github.com/synctv-org/synctv/internal/provider/providers" - sysnotify "github.com/synctv-org/synctv/internal/sysnotify" + "github.com/synctv-org/synctv/internal/sysnotify" providerpb "github.com/synctv-org/synctv/proto/provider" "google.golang.org/grpc" ) diff --git a/internal/sysnotify/signal.go b/internal/sysnotify/signal.go new file mode 100644 index 00000000..610c5a3f --- /dev/null +++ b/internal/sysnotify/signal.go @@ -0,0 +1,26 @@ +//go:build !windows +// +build !windows + +package sysnotify + +import ( + "os" + "os/signal" + "syscall" +) + +func (sn *SysNotify) Init() { + sn.c = make(chan os.Signal, 1) + signal.Notify(sn.c, syscall.SIGHUP /*1*/, syscall.SIGINT /*2*/, syscall.SIGQUIT /*3*/, syscall.SIGTERM /*15*/, syscall.SIGUSR1 /*10*/, syscall.SIGUSR2 /*12*/) +} + +func parseSysNotifyType(s os.Signal) NotifyType { + switch s { + case syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM: + return NotifyTypeEXIT + case syscall.SIGUSR1, syscall.SIGUSR2: + return NotifyTypeRELOAD + default: + return 0 + } +} diff --git a/internal/sysnotify/signal_windows.go b/internal/sysnotify/signal_windows.go new file mode 100644 index 00000000..40b3a22d --- /dev/null +++ b/internal/sysnotify/signal_windows.go @@ -0,0 +1,21 @@ +package sysnotify + +import ( + "os" + "os/signal" + "syscall" +) + +func (sn *SysNotify) Init() { + sn.c = make(chan os.Signal, 1) + signal.Notify(sn.c, syscall.SIGHUP /*1*/, syscall.SIGINT /*2*/, syscall.SIGQUIT /*3*/, syscall.SIGTERM /*15*/) +} + +func parseSysNotifyType(s os.Signal) NotifyType { + switch s { + case syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM: + return NotifyTypeEXIT + default: + return 0 + } +} diff --git a/internal/sysnotify/sysnotify.go b/internal/sysnotify/sysnotify.go new file mode 100644 index 00000000..52ef2eb9 --- /dev/null +++ b/internal/sysnotify/sysnotify.go @@ -0,0 +1,121 @@ +package sysnotify + +import ( + "errors" + "os" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/zijiren233/gencontainer/pqueue" + "github.com/zijiren233/gencontainer/rwmap" +) + +var sysNotify SysNotify + +func Init() { + sysNotify.Init() +} + +func RegisterSysNotifyTask(priority int, task *Task) error { + return sysNotify.RegisterSysNotifyTask(priority, task) +} + +func WaitCbk() { + sysNotify.WaitCbk() +} + +type SysNotify struct { + c chan os.Signal + taskGroup rwmap.RWMap[NotifyType, *taskQueue] + once sync.Once +} + +type NotifyType int + +const ( + NotifyTypeEXIT NotifyType = iota + 1 + NotifyTypeRELOAD +) + +type taskQueue struct { + notifyTaskQueue *pqueue.PQueue[*Task] + notifyTaskLock sync.Mutex +} + +type Task struct { + Task func() error + Name string + NotifyType NotifyType +} + +func NewSysNotifyTask(name string, notifyType NotifyType, task func() error) *Task { + return &Task{ + Name: name, + NotifyType: notifyType, + Task: task, + } +} + +func runTask(tq *taskQueue) { + tq.notifyTaskLock.Lock() + defer tq.notifyTaskLock.Unlock() + for tq.notifyTaskQueue.Len() > 0 { + _, task := tq.notifyTaskQueue.Pop() + func() { + defer func() { + if err := recover(); err != nil { + log.Errorf("task: %s panic has returned: %v", task.Name, err) + } + }() + log.Infof("task: %s running", task.Name) + if err := task.Task(); err != nil { + log.Errorf("task: %s an error occurred: %v", task.Name, err) + } + log.Infof("task: %s done", task.Name) + }() + } +} + +func (sn *SysNotify) RegisterSysNotifyTask(priority int, task *Task) error { + if task == nil || task.Task == nil { + return errors.New("task is nil") + } + if task.NotifyType == 0 { + panic("task notify type is 0") + } + tasks, _ := sn.taskGroup.LoadOrStore(task.NotifyType, &taskQueue{ + notifyTaskQueue: pqueue.NewMinPriorityQueue[*Task](), + }) + tasks.notifyTaskLock.Lock() + defer tasks.notifyTaskLock.Unlock() + tasks.notifyTaskQueue.Push(priority, task) + return nil +} + +func (sn *SysNotify) waitCbk() { + log.Info("wait sys notify") + for s := range sn.c { + log.Infof("receive sys notify: %v", s) + switch parseSysNotifyType(s) { + case NotifyTypeEXIT: + tq, ok := sn.taskGroup.Load(NotifyTypeEXIT) + if ok { + log.Info("task: NotifyTypeEXIT running...") + runTask(tq) + } + return + case NotifyTypeRELOAD: + tq, ok := sn.taskGroup.Load(NotifyTypeRELOAD) + if ok { + log.Info("task: NotifyTypeRELOAD running...") + runTask(tq) + } + } + } + log.Info("task: all done") +} + +func (sn *SysNotify) WaitCbk() { + sn.once.Do(sn.waitCbk) +} diff --git a/server/handlers/vendors/vendoralist/alist.go b/server/handlers/vendors/vendoralist/alist.go new file mode 100644 index 00000000..3d939401 --- /dev/null +++ b/server/handlers/vendors/vendoralist/alist.go @@ -0,0 +1,364 @@ +package vendoralist + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "path" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/synctv-org/synctv/internal/cache" + "github.com/synctv-org/synctv/internal/db" + dbModel "github.com/synctv-org/synctv/internal/model" + "github.com/synctv-org/synctv/internal/op" + "github.com/synctv-org/synctv/internal/vendor" + "github.com/synctv-org/synctv/server/handlers/proxy" + "github.com/synctv-org/synctv/server/model" + "github.com/synctv-org/synctv/utils" + "github.com/synctv-org/vendors/api/alist" +) + +type AlistVendorService struct { + room *op.Room + movie *op.Movie +} + +func NewAlistVendorService(room *op.Room, movie *op.Movie) (*AlistVendorService, error) { + if movie.VendorInfo.Vendor != dbModel.VendorAlist { + return nil, fmt.Errorf("alist vendor not support vendor %s", movie.MovieBase.VendorInfo.Vendor) + } + return &AlistVendorService{ + room: room, + movie: movie, + }, nil +} + +func (s *AlistVendorService) Client() alist.AlistHTTPServer { + return vendor.LoadAlistClient(s.movie.VendorInfo.Backend) +} + +func (s *AlistVendorService) ListDynamicMovie(ctx context.Context, reqUser *op.User, subPath string, page, _max int) (*model.MovieList, error) { + if reqUser.ID != s.movie.CreatorID { + return nil, fmt.Errorf("list vendor dynamic folder error: %w", dbModel.ErrNoPermission) + } + user := reqUser + + resp := &model.MovieList{ + Paths: []*model.MoviePath{}, + } + + serverID, truePath, err := s.movie.VendorInfo.Alist.ServerIDAndFilePath() + if err != nil { + return nil, fmt.Errorf("load alist server id error: %w", err) + } + newPath := path.Join(truePath, subPath) + // check new path is in parent path + if !strings.HasPrefix(newPath, truePath) { + return nil, errors.New("sub path is not in parent path") + } + truePath = newPath + aucd, err := user.AlistCache().LoadOrStore(ctx, serverID) + if err != nil { + if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { + return nil, errors.New("alist server not found") + } + return nil, err + } + data, err := s.Client().FsList(ctx, &alist.FsListReq{ + Token: aucd.Token, + Password: s.movie.VendorInfo.Alist.Password, + Path: truePath, + Host: aucd.Host, + Refresh: false, + Page: uint64(page), + PerPage: uint64(_max), + }) + if err != nil { + return nil, err + } + resp.Total = int64(data.Total) + resp.Movies = make([]*model.Movie, len(data.Content)) + for i, flr := range data.Content { + resp.Movies[i] = &model.Movie{ + ID: s.movie.ID, + CreatedAt: s.movie.CreatedAt.UnixMilli(), + Creator: op.GetUserName(s.movie.CreatorID), + CreatorID: s.movie.CreatorID, + SubPath: "/" + strings.Trim(fmt.Sprintf("%s/%s", subPath, flr.Name), "/"), + Base: dbModel.MovieBase{ + Name: flr.Name, + IsFolder: flr.IsDir, + ParentID: dbModel.EmptyNullString(s.movie.ID), + VendorInfo: dbModel.VendorInfo{ + Vendor: dbModel.VendorAlist, + Backend: s.movie.VendorInfo.Backend, + Alist: &dbModel.AlistStreamingInfo{ + Path: dbModel.FormatAlistPath(serverID, + "/"+strings.Trim(fmt.Sprintf("%s/%s", truePath, flr.Name), "/"), + ), + }, + }, + }, + } + } + resp.Paths = model.GenDefaultSubPaths(s.movie.ID, subPath, true) + return resp, nil +} + +func (s *AlistVendorService) ProxyMovie(ctx *gin.Context) { + log := ctx.MustGet("log").(*logrus.Entry) + + // Get user and cache data + data, err := s.getUserAndCacheData(ctx) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + // Handle different providers + switch data.Provider { + case cache.AlistProviderAli: + s.handleAliProvider(ctx, log, data) + case cache.AlistProvider115: + fallthrough + default: + s.handleDefaultProvider(ctx, log, data) + } +} + +func (s *AlistVendorService) getUserAndCacheData(ctx *gin.Context) (*cache.AlistMovieCacheData, error) { + u, err := op.LoadOrInitUserByID(s.movie.Movie.CreatorID) + if err != nil { + return nil, err + } + + data, err := s.movie.AlistCache().Get(ctx, &cache.AlistMovieCacheFuncArgs{ + UserCache: u.Value().AlistCache(), + UserAgent: utils.UA, + }) + if err != nil { + return nil, err + } + + return data, nil +} + +func (s *AlistVendorService) handleAliProvider(ctx *gin.Context, log *logrus.Entry, data *cache.AlistMovieCacheData) { + t := ctx.Query("t") + switch t { + case "": + ctx.Data(http.StatusOK, "audio/mpegurl", data.Ali.M3U8ListFile) + case "raw": + s.proxyURL(ctx, log, data.URL) + case "subtitle": + s.handleSubtitle(ctx, log, data) + } +} + +func (s *AlistVendorService) handleDefaultProvider(ctx *gin.Context, log *logrus.Entry, data *cache.AlistMovieCacheData) { + if !s.movie.Movie.MovieBase.Proxy { + log.Errorf("proxy vendor movie error: %v", "proxy is not enabled") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp("proxy is not enabled")) + return + } + s.proxyURL(ctx, log, data.URL) +} + +func (s *AlistVendorService) proxyURL(ctx *gin.Context, log *logrus.Entry, url string) { + err := proxy.AutoProxyURL(ctx, + url, + s.movie.MovieBase.Type, + nil, + ctx.GetString("token"), + s.movie.RoomID, + s.movie.ID, + proxy.WithProxyURLCache(true), + ) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + } +} + +func (s *AlistVendorService) handleSubtitle(ctx *gin.Context, log *logrus.Entry, data *cache.AlistMovieCacheData) { + idS := ctx.Query("id") + if idS == "" { + log.Errorf("proxy vendor movie error: %v", "id is empty") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp("id is empty")) + return + } + + id, err := strconv.Atoi(idS) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + if id >= len(data.Subtitles) { + log.Errorf("proxy vendor movie error: %v", "id out of range") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp("id out of range")) + return + } + + b, err := data.Subtitles[id].Cache.Get(ctx) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + http.ServeContent(ctx.Writer, ctx.Request, data.Subtitles[id].Name, time.Now(), bytes.NewReader(b)) +} + +func (s *AlistVendorService) GenMovieInfo(ctx context.Context, user *op.User, userAgent, userToken string) (*dbModel.Movie, error) { + if s.movie.Proxy { + return s.GenProxyMovieInfo(ctx, user, userAgent, userToken) + } + + movie := s.movie.Clone() + var err error + + creator, err := op.LoadOrInitUserByID(movie.CreatorID) + if err != nil { + return nil, err + } + alistCache := s.movie.AlistCache() + data, err := alistCache.Get(ctx, &cache.AlistMovieCacheFuncArgs{ + UserCache: creator.Value().AlistCache(), + UserAgent: utils.UA, + }) + if err != nil { + return nil, err + } + + for _, subt := range data.Subtitles { + if movie.MovieBase.Subtitles == nil { + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(data.Subtitles)) + } + movie.MovieBase.Subtitles[subt.Name] = &dbModel.Subtitle{ + URL: subt.URL, + Type: subt.Type, + } + } + + switch data.Provider { + case cache.AlistProviderAli: + movie.MovieBase.URL = fmt.Sprintf("/api/room/movie/proxy/%s?token=%s&roomId=%s", movie.ID, userToken, movie.RoomID) + movie.MovieBase.Type = "m3u8" + + rawStreamURL := data.URL + movie.MovieBase.MoreSources = []*dbModel.MoreSource{ + { + Name: "raw", + Type: utils.GetURLExtension(movie.MovieBase.VendorInfo.Alist.Path), + URL: rawStreamURL, + }, + } + + for i, subt := range data.Subtitles { + if movie.MovieBase.Subtitles == nil { + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(data.Subtitles)) + } + movie.MovieBase.Subtitles[subt.Name] = &dbModel.Subtitle{ + URL: fmt.Sprintf("/api/room/movie/proxy/%s?t=subtitle&id=%d&token=%s&roomId=%s", movie.ID, i, userToken, movie.RoomID), + Type: subt.Type, + } + } + + case cache.AlistProvider115: + data, err = alistCache.GetRefreshFunc()(ctx, &cache.AlistMovieCacheFuncArgs{ + UserCache: creator.Value().AlistCache(), + UserAgent: userAgent, + }) + if err != nil { + return nil, fmt.Errorf("refresh 115 movie cache error: %w", err) + } + movie.MovieBase.URL = data.URL + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(data.Subtitles)) + for _, subt := range data.Subtitles { + movie.MovieBase.Subtitles[subt.Name] = &dbModel.Subtitle{ + URL: subt.URL, + Type: subt.Type, + } + } + + default: + movie.MovieBase.URL = data.URL + } + + movie.MovieBase.VendorInfo.Alist.Password = "" + return movie, nil +} + +func (s *AlistVendorService) GenProxyMovieInfo(ctx context.Context, user *op.User, userAgent, userToken string) (*dbModel.Movie, error) { + movie := s.movie.Clone() + var err error + + creator, err := op.LoadOrInitUserByID(movie.CreatorID) + if err != nil { + return nil, err + } + alistCache := s.movie.AlistCache() + data, err := alistCache.Get(ctx, &cache.AlistMovieCacheFuncArgs{ + UserCache: creator.Value().AlistCache(), + UserAgent: utils.UA, + }) + if err != nil { + return nil, err + } + + for _, subt := range data.Subtitles { + if movie.MovieBase.Subtitles == nil { + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(data.Subtitles)) + } + movie.MovieBase.Subtitles[subt.Name] = &dbModel.Subtitle{ + URL: subt.URL, + Type: subt.Type, + } + } + + switch data.Provider { + case cache.AlistProviderAli: + movie.MovieBase.URL = fmt.Sprintf("/api/room/movie/proxy/%s?token=%s&roomId=%s", movie.ID, userToken, movie.RoomID) + movie.MovieBase.Type = "m3u8" + + rawStreamURL := fmt.Sprintf("/api/room/movie/proxy/%s?t=raw&token=%s&roomId=%s", movie.ID, userToken, movie.RoomID) + movie.MovieBase.MoreSources = []*dbModel.MoreSource{ + { + Name: "raw", + Type: utils.GetURLExtension(movie.MovieBase.VendorInfo.Alist.Path), + URL: rawStreamURL, + }, + } + + for i, subt := range data.Subtitles { + if movie.MovieBase.Subtitles == nil { + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(data.Subtitles)) + } + movie.MovieBase.Subtitles[subt.Name] = &dbModel.Subtitle{ + URL: fmt.Sprintf("/api/room/movie/proxy/%s?t=subtitle&id=%d&token=%s&roomId=%s", movie.ID, i, userToken, movie.RoomID), + Type: subt.Type, + } + } + + case cache.AlistProvider115: + movie.MovieBase.URL = fmt.Sprintf("/api/room/movie/proxy/%s?token=%s&roomId=%s", movie.ID, userToken, movie.RoomID) + movie.MovieBase.Type = utils.GetURLExtension(data.URL) + + // TODO: proxy subtitle + + default: + movie.MovieBase.URL = fmt.Sprintf("/api/room/movie/proxy/%s?token=%s&roomId=%s", movie.ID, userToken, movie.RoomID) + movie.MovieBase.Type = utils.GetURLExtension(data.URL) + } + + movie.MovieBase.VendorInfo.Alist.Password = "" + return movie, nil +} diff --git a/server/handlers/vendors/vendoralist/list.go b/server/handlers/vendors/vendoralist/list.go new file mode 100644 index 00000000..508f2550 --- /dev/null +++ b/server/handlers/vendors/vendoralist/list.go @@ -0,0 +1,178 @@ +package vendoralist + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + json "github.com/json-iterator/go" + "github.com/synctv-org/synctv/internal/db" + dbModel "github.com/synctv-org/synctv/internal/model" + "github.com/synctv-org/synctv/internal/op" + "github.com/synctv-org/synctv/internal/vendor" + "github.com/synctv-org/synctv/server/model" + "github.com/synctv-org/synctv/utils" + "github.com/synctv-org/vendors/api/alist" + "gorm.io/gorm" +) + +type ListReq struct { + Path string `json:"path"` + Password string `json:"password"` + Refresh bool `json:"refresh"` +} + +func (r *ListReq) Validate() (err error) { + return nil +} + +func (r *ListReq) Decode(ctx *gin.Context) error { + return json.NewDecoder(ctx.Request.Body).Decode(r) +} + +type AlistFileItem struct { + *model.Item + Size uint64 `json:"size"` + Modified uint64 `json:"modified"` +} + +type AlistFSListResp = model.VendorFSListResp[*AlistFileItem] + +func List(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + + req := ListReq{} + if err := model.Decode(ctx, &req); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + page, size, err := utils.GetPageAndMax(ctx) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + if req.Path == "" { + socpes := [](func(*gorm.DB) *gorm.DB){ + db.OrderByCreatedAtAsc, + } + + total, err := db.GetAlistVendorsCount(user.ID, socpes...) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + if total == 0 { + ctx.JSON(http.StatusBadRequest, model.NewAPIErrorStringResp("alist server not found")) + return + } + + ev, err := db.GetAlistVendors(user.ID, append(socpes, db.Paginate(page, size))...) + if err != nil { + if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { + ctx.JSON(http.StatusBadRequest, model.NewAPIErrorStringResp("alist server not found")) + return + } + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + if total == 1 { + req.Path = ev[0].ServerID + "/" + goto AlistFSListResp + } + + resp := AlistFSListResp{ + Paths: []*model.Path{ + { + Name: "", + Path: "", + }, + }, + Total: uint64(total), + } + + for _, evi := range ev { + resp.Items = append(resp.Items, &AlistFileItem{ + Item: &model.Item{ + Name: evi.Host, + Path: evi.ServerID + `/`, + IsDir: true, + }, + }) + } + + ctx.JSON(http.StatusOK, model.NewAPIDataResp(resp)) + + return + } + +AlistFSListResp: + + var serverID string + serverID, req.Path, err = dbModel.GetAlistServerIDFromPath(req.Path) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + if !strings.HasPrefix(req.Path, "/") { + req.Path = "/" + req.Path + } + + aucd, err := user.AlistCache().LoadOrStore(ctx, serverID) + if err != nil { + if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { + ctx.JSON(http.StatusBadRequest, model.NewAPIErrorStringResp("alist server not found")) + return + } + + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + cli := vendor.LoadAlistClient(ctx.Query("backend")) + data, err := cli.FsList(ctx, &alist.FsListReq{ + Token: aucd.Token, + Password: req.Password, + Path: req.Path, + Host: aucd.Host, + Refresh: req.Refresh, + Page: uint64(page), + PerPage: uint64(size), + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + req.Path = strings.Trim(req.Path, "/") + resp := AlistFSListResp{ + Total: data.Total, + Paths: model.GenDefaultPaths(req.Path, true, + &model.Path{ + Name: "", + Path: "", + }, + &model.Path{ + Name: aucd.Host, + Path: aucd.ServerID + "/", + }), + } + for _, flr := range data.Content { + resp.Items = append(resp.Items, &AlistFileItem{ + Item: &model.Item{ + Name: flr.Name, + Path: fmt.Sprintf("%s/%s", aucd.ServerID, strings.Trim(fmt.Sprintf("%s/%s", req.Path, flr.Name), "/")), + IsDir: flr.IsDir, + }, + Size: flr.Size, + Modified: flr.Modified, + }) + } + + ctx.JSON(http.StatusOK, model.NewAPIDataResp(&resp)) +} diff --git a/server/handlers/vendors/vendoralist/login.go b/server/handlers/vendors/vendoralist/login.go new file mode 100644 index 00000000..5ed86a29 --- /dev/null +++ b/server/handlers/vendors/vendoralist/login.go @@ -0,0 +1,127 @@ +package vendoralist + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" + json "github.com/json-iterator/go" + "github.com/sirupsen/logrus" + "github.com/synctv-org/synctv/internal/cache" + "github.com/synctv-org/synctv/internal/db" + dbModel "github.com/synctv-org/synctv/internal/model" + "github.com/synctv-org/synctv/internal/op" + "github.com/synctv-org/synctv/server/model" +) + +type LoginReq struct { + Host string `json:"host"` + Username string `json:"username"` + Password string `json:"password"` + HashedPassword string `json:"hashedPassword"` +} + +func (r *LoginReq) Validate() error { + if r.Host == "" { + return errors.New("host is required") + } + url, err := url.Parse(r.Host) + if err != nil { + return err + } + if url.Scheme != "http" && url.Scheme != "https" { + return errors.New("host is invalid") + } + r.Host = strings.TrimRight(url.String(), "/") + if r.Password != "" && r.HashedPassword != "" { + return errors.New("password and hashedPassword can't be both set") + } + return nil +} + +func (r *LoginReq) Decode(ctx *gin.Context) error { + return json.NewDecoder(ctx.Request.Body).Decode(r) +} + +func Login(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + + req := LoginReq{} + if err := model.Decode(ctx, &req); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + if req.Password != "" { + h := sha256.New() + h.Write([]byte(req.Password + `-https://github.com/alist-org/alist`)) + req.HashedPassword = hex.EncodeToString(h.Sum(nil)) + } + + backend := ctx.Query("backend") + + data, err := cache.AlistAuthorizationCacheWithConfigInitFunc(ctx, &dbModel.AlistVendor{ + Host: req.Host, + Username: req.Username, + HashedPassword: []byte(req.HashedPassword), + Backend: backend, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + _, err = db.CreateOrSaveAlistVendor(&dbModel.AlistVendor{ + UserID: user.ID, + ServerID: data.ServerID, + Backend: data.Backend, + Host: data.Host, + Username: req.Username, + HashedPassword: []byte(req.HashedPassword), + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + _, err = user.AlistCache().StoreOrRefreshWithDynamicFunc(ctx, data.ServerID, func(ctx context.Context, key string, args ...struct{}) (*cache.AlistUserCacheData, error) { + return data, nil + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + ctx.Status(http.StatusNoContent) +} + +func Logout(ctx *gin.Context) { + log := ctx.MustGet("log").(*logrus.Entry) + user := ctx.MustGet("user").(*op.UserEntry).Value() + + var req model.ServerIDReq + if err := model.Decode(ctx, &req); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + err := db.DeleteAlistVendor(user.ID, req.ServerID) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + if rc, ok := user.AlistCache().LoadCache(req.ServerID); ok { + err = rc.Clear(ctx) + if err != nil { + log.Errorf("clear alist cache error: %v", err) + } + } + + ctx.Status(http.StatusNoContent) +} diff --git a/server/handlers/vendors/vendoralist/me.go b/server/handlers/vendors/vendoralist/me.go new file mode 100644 index 00000000..c54fa8c3 --- /dev/null +++ b/server/handlers/vendors/vendoralist/me.go @@ -0,0 +1,83 @@ +package vendoralist + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/synctv-org/synctv/internal/db" + "github.com/synctv-org/synctv/internal/op" + "github.com/synctv-org/synctv/internal/vendor" + "github.com/synctv-org/synctv/server/model" + "github.com/synctv-org/vendors/api/alist" +) + +type AlistMeResp = model.VendorMeResp[*alist.MeResp] + +func Me(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + + serverID := ctx.Query("serverID") + if serverID == "" { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(errors.New("serverID is required"))) + return + } + + aucd, err := user.AlistCache().LoadOrStore(ctx, serverID) + if err != nil { + if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { + ctx.JSON(http.StatusBadRequest, model.NewAPIErrorStringResp("alist server not found")) + return + } + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + resp, err := vendor.LoadAlistClient(aucd.Backend).Me(ctx, &alist.MeReq{ + Host: aucd.Host, + Token: aucd.Token, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + ctx.JSON(http.StatusOK, model.NewAPIDataResp(&AlistMeResp{ + IsLogin: true, + Info: resp, + })) +} + +type AlistBindsResp []*struct { + ServerID string `json:"serverId"` + Host string `json:"host"` +} + +func Binds(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + + ev, err := db.GetAlistVendors(user.ID) + if err != nil { + if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { + ctx.JSON(http.StatusOK, model.NewAPIDataResp(&AlistMeResp{ + IsLogin: false, + })) + return + } + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + resp := make(AlistBindsResp, len(ev)) + for i, v := range ev { + resp[i] = &struct { + ServerID string `json:"serverId"` + Host string `json:"host"` + }{ + ServerID: v.ServerID, + Host: v.Host, + } + } + + ctx.JSON(http.StatusOK, model.NewAPIDataResp(resp)) +} diff --git a/server/handlers/vendors/vendorbilibili/bilibili.go b/server/handlers/vendors/vendorbilibili/bilibili.go new file mode 100644 index 00000000..f3eed2f7 --- /dev/null +++ b/server/handlers/vendors/vendorbilibili/bilibili.go @@ -0,0 +1,289 @@ +package vendorbilibili + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/synctv-org/synctv/internal/cache" + dbModel "github.com/synctv-org/synctv/internal/model" + "github.com/synctv-org/synctv/internal/op" + "github.com/synctv-org/synctv/internal/vendor" + "github.com/synctv-org/synctv/server/handlers/proxy" + "github.com/synctv-org/synctv/server/model" + "github.com/synctv-org/synctv/utils" + "github.com/synctv-org/vendors/api/bilibili" + "github.com/zijiren233/stream" + "golang.org/x/exp/maps" +) + +type BilibiliVendorService struct { + room *op.Room + movie *op.Movie +} + +func NewBilibiliVendorService(room *op.Room, movie *op.Movie) (*BilibiliVendorService, error) { + if movie.VendorInfo.Vendor != dbModel.VendorBilibili { + return nil, fmt.Errorf("bilibili vendor not support vendor %s", movie.MovieBase.VendorInfo.Vendor) + } + return &BilibiliVendorService{ + room: room, + movie: movie, + }, nil +} + +func (s *BilibiliVendorService) Client() bilibili.BilibiliHTTPServer { + return vendor.LoadBilibiliClient(s.movie.VendorInfo.Backend) +} + +func (s *BilibiliVendorService) ListDynamicMovie(ctx context.Context, reqUser *op.User, subPath string, page, _max int) (*model.MovieList, error) { + return nil, errors.New("bilibili vendor not support list dynamic movie") +} + +func (s *BilibiliVendorService) ProxyMovie(ctx *gin.Context) { + log := ctx.MustGet("log").(*logrus.Entry) + + if s.movie.MovieBase.Live { + s.handleLiveProxy(ctx, log) + return + } + + t := ctx.Query("t") + switch t { + case "", "hevc": + s.handleVideoProxy(ctx, log, t) + case "subtitle": + s.handleSubtitleProxy(ctx, log) + } +} + +func (s *BilibiliVendorService) handleLiveProxy(ctx *gin.Context, log *logrus.Entry) { + data, err := s.movie.BilibiliCache().Live.Get(ctx) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + if len(data) == 0 { + log.Error("proxy vendor movie error: live data is empty") + ctx.AbortWithStatusJSON(http.StatusNotFound, model.NewAPIErrorStringResp("live data is empty")) + return + } + ctx.Data(http.StatusOK, "application/vnd.apple.mpegurl", data) +} + +func (s *BilibiliVendorService) handleVideoProxy(ctx *gin.Context, log *logrus.Entry, t string) { + if !s.movie.Movie.MovieBase.Proxy { + log.Errorf("proxy vendor movie error: %v", "proxy is not enabled") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp("proxy is not enabled")) + return + } + + u, err := op.LoadOrInitUserByID(s.movie.Movie.CreatorID) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + mpdC, err := s.movie.BilibiliCache().SharedMpd.Get(ctx, u.Value().BilibiliCache()) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + id := ctx.Query("id") + if id == "" { + s.handleMpdProxy(ctx, log, t, mpdC) + return + } + + s.handleStreamProxy(ctx, log, id, mpdC) +} + +func (s *BilibiliVendorService) handleMpdProxy(ctx *gin.Context, log *logrus.Entry, t string, mpdC *cache.BilibiliMpdCache) { + var mpd string + var err error + if t == "hevc" { + mpd, err = cache.BilibiliMpdToString(mpdC.HevcMpd, ctx.MustGet("token").(string)) + } else { + mpd, err = cache.BilibiliMpdToString(mpdC.Mpd, ctx.MustGet("token").(string)) + } + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + ctx.Data(http.StatusOK, "application/dash+xml", stream.StringToBytes(mpd)) +} + +func (s *BilibiliVendorService) handleStreamProxy(ctx *gin.Context, log *logrus.Entry, id string, mpdC *cache.BilibiliMpdCache) { + streamID, err := strconv.Atoi(id) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + if streamID >= len(mpdC.URLs) { + log.Errorf("proxy vendor movie error: %v", "stream id out of range") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp("stream id out of range")) + return + } + + headers := s.getProxyHeaders() + err = proxy.URL(ctx, + mpdC.URLs[streamID], + headers, + proxy.WithProxyURLCache(true), + ) + if err != nil { + log.Errorf("proxy vendor movie [%s] error: %v", mpdC.URLs[streamID], err) + } +} + +func (s *BilibiliVendorService) getProxyHeaders() map[string]string { + headers := maps.Clone(s.movie.Movie.MovieBase.Headers) + if headers == nil { + headers = map[string]string{ + "Referer": "https://www.bilibili.com", + "User-Agent": utils.UA, + } + } else { + headers["Referer"] = "https://www.bilibili.com" + headers["User-Agent"] = utils.UA + } + return headers +} + +func (s *BilibiliVendorService) handleSubtitleProxy(ctx *gin.Context, log *logrus.Entry) { + id := ctx.Query("n") + if id == "" { + log.Errorf("proxy vendor movie error: %v", "n is empty") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp("n is empty")) + return + } + + u, err := op.LoadOrInitUserByID(s.movie.Movie.CreatorID) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + srtI, err := s.movie.BilibiliCache().Subtitle.Get(ctx, u.Value().BilibiliCache()) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + if s, ok := srtI[id]; ok { + srtData, err := s.Srt.Get(ctx) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + http.ServeContent(ctx.Writer, ctx.Request, id, time.Now(), bytes.NewReader(srtData)) + return + } + + log.Errorf("proxy vendor movie error: %v", "subtitle not found") + ctx.AbortWithStatusJSON(http.StatusNotFound, model.NewAPIErrorStringResp("subtitle not found")) +} + +func (s *BilibiliVendorService) GenMovieInfo(ctx context.Context, user *op.User, userAgent, userToken string) (*dbModel.Movie, error) { + if s.movie.Proxy { + return s.GenProxyMovieInfo(ctx, user, userAgent, userToken) + } + + movie := s.movie.Clone() + var err error + if movie.IsFolder { + return nil, errors.New("bilibili folder not support") + } + + bmc := s.movie.BilibiliCache() + if movie.MovieBase.Live { + movie.MovieBase.URL = fmt.Sprintf("/api/room/movie/proxy/%s?token=%s&roomId=%s", movie.ID, userToken, movie.RoomID) + movie.MovieBase.Type = "m3u8" + return movie, nil + } + + var str string + if movie.MovieBase.VendorInfo.Bilibili.Shared { + var u *op.UserEntry + u, err = op.LoadOrInitUserByID(movie.CreatorID) + if err != nil { + return nil, err + } + str, err = s.movie.BilibiliCache().NoSharedMovie.LoadOrStore(ctx, movie.CreatorID, u.Value().BilibiliCache()) + } else { + str, err = s.movie.BilibiliCache().NoSharedMovie.LoadOrStore(ctx, user.ID, user.BilibiliCache()) + } + if err != nil { + return nil, err + } + movie.MovieBase.URL = str + + srt, err := bmc.Subtitle.Get(ctx, user.BilibiliCache()) + if err != nil { + return nil, err + } + for k := range srt { + if movie.MovieBase.Subtitles == nil { + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(srt)) + } + movie.MovieBase.Subtitles[k] = &dbModel.Subtitle{ + URL: fmt.Sprintf("/api/room/movie/proxy/%s?t=subtitle&n=%s&token=%s&roomId=%s", movie.ID, k, userToken, movie.RoomID), + Type: "srt", + } + } + return movie, nil +} + +func (s *BilibiliVendorService) GenProxyMovieInfo(ctx context.Context, user *op.User, userAgent, userToken string) (*dbModel.Movie, error) { + movie := s.movie.Clone() + var err error + if movie.IsFolder { + return nil, errors.New("bilibili folder not support") + } + + bmc := s.movie.BilibiliCache() + if movie.MovieBase.Live { + movie.MovieBase.URL = fmt.Sprintf("/api/room/movie/proxy/%s?token=%s&roomId=%s", movie.ID, userToken, movie.RoomID) + movie.MovieBase.Type = "m3u8" + return movie, nil + } + + movie.MovieBase.URL = fmt.Sprintf("/api/room/movie/proxy/%s?token=%s&roomId=%s", movie.ID, userToken, movie.RoomID) + movie.MovieBase.Type = "mpd" + movie.MovieBase.MoreSources = []*dbModel.MoreSource{ + { + Name: "hevc", + Type: "mpd", + URL: fmt.Sprintf("/api/room/movie/proxy/%s?token=%s&t=hevc&roomId=%s", movie.ID, userToken, movie.RoomID), + }, + } + srt, err := bmc.Subtitle.Get(ctx, user.BilibiliCache()) + if err != nil { + return nil, err + } + for k := range srt { + if movie.MovieBase.Subtitles == nil { + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(srt)) + } + movie.MovieBase.Subtitles[k] = &dbModel.Subtitle{ + URL: fmt.Sprintf("/api/room/movie/proxy/%s?t=subtitle&n=%s&token=%s&roomId=%s", movie.ID, k, userToken, movie.RoomID), + Type: "srt", + } + } + return movie, nil +} diff --git a/server/handlers/vendors/vendorbilibili/login.go b/server/handlers/vendors/vendorbilibili/login.go new file mode 100644 index 00000000..b2560c27 --- /dev/null +++ b/server/handlers/vendors/vendorbilibili/login.go @@ -0,0 +1,243 @@ +package vendorbilibili + +import ( + "context" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + json "github.com/json-iterator/go" + log "github.com/sirupsen/logrus" + "github.com/synctv-org/synctv/internal/cache" + "github.com/synctv-org/synctv/internal/db" + dbModel "github.com/synctv-org/synctv/internal/model" + "github.com/synctv-org/synctv/internal/op" + "github.com/synctv-org/synctv/internal/vendor" + "github.com/synctv-org/synctv/server/model" + "github.com/synctv-org/synctv/utils" + "github.com/synctv-org/vendors/api/bilibili" +) + +func NewQRCode(ctx *gin.Context) { + r, err := vendor.LoadBilibiliClient("").NewQRCode(ctx, &bilibili.Empty{}) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + ctx.JSON(http.StatusOK, model.NewAPIDataResp(r)) +} + +type QRCodeLoginReq struct { + Key string `json:"key"` +} + +func (r *QRCodeLoginReq) Validate() error { + if r.Key == "" { + return errors.New("key is empty") + } + return nil +} + +func (r *QRCodeLoginReq) Decode(ctx *gin.Context) error { + return json.NewDecoder(ctx.Request.Body).Decode(r) +} + +func LoginWithQR(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + + req := QRCodeLoginReq{} + if err := model.Decode(ctx, &req); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + backend := ctx.Query("backend") + resp, err := vendor.LoadBilibiliClient(backend).LoginWithQRCode(ctx, &bilibili.LoginWithQRCodeReq{ + Key: req.Key, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + switch resp.Status { + case bilibili.QRCodeStatus_EXPIRED: + ctx.JSON(http.StatusOK, model.NewAPIDataResp(gin.H{ + "status": "expired", + })) + return + case bilibili.QRCodeStatus_SCANNED: + ctx.JSON(http.StatusOK, model.NewAPIDataResp(gin.H{ + "status": "scanned", + })) + return + case bilibili.QRCodeStatus_NOTSCANNED: + ctx.JSON(http.StatusOK, model.NewAPIDataResp(gin.H{ + "status": "notScanned", + })) + return + case bilibili.QRCodeStatus_SUCCESS: + _, err = db.CreateOrSaveBilibiliVendor(&dbModel.BilibiliVendor{ + UserID: user.ID, + Cookies: resp.Cookies, + Backend: backend, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + _, err = user.BilibiliCache().Data().Refresh(ctx, func(ctx context.Context, args ...struct{}) (*cache.BilibiliUserCacheData, error) { + return &cache.BilibiliUserCacheData{ + Backend: backend, + Cookies: utils.MapToHTTPCookie(resp.Cookies), + }, nil + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + ctx.JSON(http.StatusOK, model.NewAPIDataResp(gin.H{ + "status": "success", + })) + default: + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorStringResp("unknown status")) + return + } +} + +func NewCaptcha(ctx *gin.Context) { + r, err := vendor.LoadBilibiliClient("").NewCaptcha(ctx, &bilibili.Empty{}) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + ctx.JSON(http.StatusOK, model.NewAPIDataResp(r)) +} + +type SMSReq struct { + Token string `json:"token"` + Challenge string `json:"challenge"` + V string `json:"validate"` + Telephone string `json:"telephone"` +} + +func (r *SMSReq) Validate() error { + if r.Token == "" { + return errors.New("token is empty") + } + if r.Challenge == "" { + return errors.New("challenge is empty") + } + if r.V == "" { + return errors.New("validate is empty") + } + if r.Telephone == "" { + return errors.New("telephone is empty") + } + return nil +} + +func (r *SMSReq) Decode(ctx *gin.Context) error { + return json.NewDecoder(ctx.Request.Body).Decode(r) +} + +func NewSMS(ctx *gin.Context) { + var req SMSReq + if err := model.Decode(ctx, &req); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + r, err := vendor.LoadBilibiliClient("").NewSMS(ctx, &bilibili.NewSMSReq{ + Phone: req.Telephone, + Token: req.Token, + Challenge: req.Challenge, + Validate: req.V, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + ctx.JSON(http.StatusOK, model.NewAPIDataResp(gin.H{ + "captchaKey": r.CaptchaKey, + })) +} + +type SMSLoginReq struct { + Telephone string `json:"telephone"` + CaptchaKey string `json:"captchaKey"` + Code string `json:"code"` +} + +func (r *SMSLoginReq) Validate() error { + if r.Telephone == "" { + return errors.New("telephone is empty") + } + if r.CaptchaKey == "" { + return errors.New("captchaKey is empty") + } + if r.Code == "" { + return errors.New("code is empty") + } + return nil +} + +func (r *SMSLoginReq) Decode(ctx *gin.Context) error { + return json.NewDecoder(ctx.Request.Body).Decode(r) +} + +func LoginWithSMS(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + + var req SMSLoginReq + if err := model.Decode(ctx, &req); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + backend := ctx.Query("backend") + c, err := vendor.LoadBilibiliClient(backend).LoginWithSMS(ctx, &bilibili.LoginWithSMSReq{ + Phone: req.Telephone, + CaptchaKey: req.CaptchaKey, + Code: req.Code, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + _, err = db.CreateOrSaveBilibiliVendor(&dbModel.BilibiliVendor{ + UserID: user.ID, + Backend: backend, + Cookies: c.Cookies, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + _, err = user.BilibiliCache().Data().Refresh(ctx, func(ctx context.Context, args ...struct{}) (*cache.BilibiliUserCacheData, error) { + return &cache.BilibiliUserCacheData{ + Backend: backend, + Cookies: utils.MapToHTTPCookie(c.Cookies), + }, nil + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + ctx.Status(http.StatusNoContent) +} + +func Logout(ctx *gin.Context) { + log := ctx.MustGet("log").(*log.Entry) + user := ctx.MustGet("user").(*op.UserEntry).Value() + err := db.DeleteBilibiliVendor(user.ID) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + err = user.BilibiliCache().Clear(ctx) + if err != nil { + log.Errorf("clear bilibili cache: %v", err) + } + ctx.Status(http.StatusNoContent) +} diff --git a/server/handlers/vendors/vendorbilibili/me.go b/server/handlers/vendors/vendorbilibili/me.go new file mode 100644 index 00000000..278ac2fb --- /dev/null +++ b/server/handlers/vendors/vendorbilibili/me.go @@ -0,0 +1,50 @@ +package vendorbilibili + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/synctv-org/synctv/internal/db" + "github.com/synctv-org/synctv/internal/op" + "github.com/synctv-org/synctv/internal/vendor" + "github.com/synctv-org/synctv/server/model" + "github.com/synctv-org/synctv/utils" + "github.com/synctv-org/vendors/api/bilibili" +) + +type BilibiliMeResp = model.VendorMeResp[*bilibili.UserInfoResp] + +func Me(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + + bucd, err := user.BilibiliCache().Get(ctx) + if err != nil { + if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { + ctx.JSON(http.StatusOK, model.NewAPIDataResp(&BilibiliMeResp{ + IsLogin: false, + })) + return + } + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + if len(bucd.Cookies) == 0 { + ctx.JSON(http.StatusOK, model.NewAPIDataResp(&BilibiliMeResp{ + IsLogin: false, + })) + return + } + resp, err := vendor.LoadBilibiliClient(bucd.Backend).UserInfo(ctx, &bilibili.UserInfoReq{ + Cookies: utils.HTTPCookieToMap(bucd.Cookies), + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + ctx.JSON(http.StatusOK, model.NewAPIDataResp(&BilibiliMeResp{ + IsLogin: resp.IsLogin, + Info: resp, + })) +} diff --git a/server/handlers/vendors/vendorbilibili/parse.go b/server/handlers/vendors/vendorbilibili/parse.go new file mode 100644 index 00000000..ba379a68 --- /dev/null +++ b/server/handlers/vendors/vendorbilibili/parse.go @@ -0,0 +1,141 @@ +package vendorbilibili + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + json "github.com/json-iterator/go" + "github.com/synctv-org/synctv/internal/db" + "github.com/synctv-org/synctv/internal/op" + "github.com/synctv-org/synctv/internal/vendor" + "github.com/synctv-org/synctv/server/model" + "github.com/synctv-org/synctv/utils" + "github.com/synctv-org/vendors/api/bilibili" +) + +type ParseReq struct { + URL string `json:"url"` +} + +func (r *ParseReq) Validate() error { + if r.URL == "" { + return errors.New("url is empty") + } + return nil +} + +func (r *ParseReq) Decode(ctx *gin.Context) error { + return json.NewDecoder(ctx.Request.Body).Decode(r) +} + +func Parse(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + + req := ParseReq{} + if err := model.Decode(ctx, &req); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + cli := vendor.LoadBilibiliClient(ctx.Query("backend")) + + resp, err := cli.Match(ctx, &bilibili.MatchReq{ + Url: req.URL, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + // can be no login + var cookies []*http.Cookie + bucd, err := user.BilibiliCache().Get(ctx) + if err != nil { + if !errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + } else { + cookies = bucd.Cookies + } + + switch resp.Type { + case "bv": + resp, err := cli.ParseVideoPage(ctx, &bilibili.ParseVideoPageReq{ + Cookies: utils.HTTPCookieToMap(cookies), + Bvid: resp.Id, + Sections: ctx.DefaultQuery("sections", "false") == "true", + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + ctx.JSON(http.StatusOK, model.NewAPIDataResp(resp)) + case "av": + aid, err := strconv.ParseUint(resp.Id, 10, 64) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + resp, err := cli.ParseVideoPage(ctx, &bilibili.ParseVideoPageReq{ + Cookies: utils.HTTPCookieToMap(cookies), + Aid: aid, + Sections: ctx.DefaultQuery("sections", "false") == "true", + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + ctx.JSON(http.StatusOK, model.NewAPIDataResp(resp)) + case "ep": + epid, err := strconv.ParseUint(resp.Id, 10, 64) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + resp, err := cli.ParsePGCPage(ctx, &bilibili.ParsePGCPageReq{ + Cookies: utils.HTTPCookieToMap(cookies), + Epid: epid, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + ctx.JSON(http.StatusOK, model.NewAPIDataResp(resp)) + case "ss": + ssid, err := strconv.ParseUint(resp.Id, 10, 64) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + resp, err := cli.ParsePGCPage(ctx, &bilibili.ParsePGCPageReq{ + Cookies: utils.HTTPCookieToMap(cookies), + Ssid: ssid, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + ctx.JSON(http.StatusOK, model.NewAPIDataResp(resp)) + case "live": + roomid, err := strconv.ParseUint(resp.Id, 10, 64) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + resp, err := cli.ParseLivePage(ctx, &bilibili.ParseLivePageReq{ + // Cookies: utils.HttpCookieToMap(cookies), // maybe no need login + RoomID: roomid, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + ctx.JSON(http.StatusOK, model.NewAPIDataResp(resp)) + default: + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorStringResp("unknown match type "+resp.Type)) + return + } +} diff --git a/server/handlers/vendors/vendoremby/emby.go b/server/handlers/vendors/vendoremby/emby.go new file mode 100644 index 00000000..fbfb7535 --- /dev/null +++ b/server/handlers/vendors/vendoremby/emby.go @@ -0,0 +1,344 @@ +package vendoremby + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" + "github.com/synctv-org/synctv/internal/db" + dbModel "github.com/synctv-org/synctv/internal/model" + "github.com/synctv-org/synctv/internal/op" + "github.com/synctv-org/synctv/internal/vendor" + "github.com/synctv-org/synctv/server/handlers/proxy" + "github.com/synctv-org/synctv/server/model" + "github.com/synctv-org/synctv/utils" + "github.com/synctv-org/vendors/api/emby" +) + +type EmbyVendorService struct { + room *op.Room + movie *op.Movie +} + +func NewEmbyVendorService(room *op.Room, movie *op.Movie) (*EmbyVendorService, error) { + if movie.VendorInfo.Vendor != dbModel.VendorEmby { + return nil, fmt.Errorf("emby vendor not support vendor %s", movie.MovieBase.VendorInfo.Vendor) + } + return &EmbyVendorService{ + room: room, + movie: movie, + }, nil +} + +func (s *EmbyVendorService) Client() emby.EmbyHTTPServer { + return vendor.LoadEmbyClient(s.movie.VendorInfo.Backend) +} + +func (s *EmbyVendorService) ListDynamicMovie(ctx context.Context, reqUser *op.User, subPath string, page, _max int) (*model.MovieList, error) { + if reqUser.ID != s.movie.CreatorID { + return nil, fmt.Errorf("list vendor dynamic folder error: %w", dbModel.ErrNoPermission) + } + user := reqUser + + resp := &model.MovieList{ + Paths: []*model.MoviePath{}, + } + + serverID, truePath, err := s.movie.VendorInfo.Emby.ServerIDAndFilePath() + if err != nil { + return nil, fmt.Errorf("load emby server id error: %w", err) + } + if subPath != "" { + truePath = subPath + } + aucd, err := user.EmbyCache().LoadOrStore(ctx, serverID) + if err != nil { + if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { + return nil, errors.New("emby server not found") + } + return nil, err + } + data, err := s.Client().FsList(ctx, &emby.FsListReq{ + Host: aucd.Host, + Path: truePath, + Token: aucd.APIKey, + UserId: aucd.UserID, + Limit: uint64(_max), + StartIndex: uint64((page - 1) * _max), + }) + if err != nil { + return nil, fmt.Errorf("emby fs list error: %w", err) + } + resp.Total = int64(data.Total) + resp.Movies = make([]*model.Movie, len(data.Items)) + for i, flr := range data.Items { + resp.Movies[i] = &model.Movie{ + ID: s.movie.ID, + CreatedAt: s.movie.CreatedAt.UnixMilli(), + Creator: op.GetUserName(s.movie.CreatorID), + CreatorID: s.movie.CreatorID, + SubPath: flr.Id, + Base: dbModel.MovieBase{ + Name: flr.Name, + IsFolder: flr.IsFolder, + ParentID: dbModel.EmptyNullString(s.movie.ID), + VendorInfo: dbModel.VendorInfo{ + Vendor: dbModel.VendorEmby, + Backend: s.movie.VendorInfo.Backend, + Emby: &dbModel.EmbyStreamingInfo{ + Path: dbModel.FormatEmbyPath(serverID, flr.Id), + }, + }, + }, + } + } + return resp, nil +} + +func (s *EmbyVendorService) handleProxyMovie(ctx *gin.Context) { + log := ctx.MustGet("log").(*log.Entry) + + if !s.movie.Movie.MovieBase.Proxy { + log.Errorf("proxy vendor movie error: %v", "proxy is not enabled") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp("proxy is not enabled")) + return + } + + u, err := op.LoadOrInitUserByID(s.movie.Movie.CreatorID) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp(err.Error())) + return + } + + embyC, err := s.movie.EmbyCache().Get(ctx, u.Value().EmbyCache()) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp(err.Error())) + return + } + + if len(embyC.Sources) == 0 { + log.Errorf("proxy vendor movie error: %v", "no source") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp("no source")) + return + } + + source, err := strconv.Atoi(ctx.Query("source")) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp(err.Error())) + return + } + + if source >= len(embyC.Sources) { + log.Errorf("proxy vendor movie error: %v", "source out of range") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp("source out of range")) + return + } + + if embyC.Sources[source].IsTranscode { + ctx.Redirect(http.StatusFound, embyC.Sources[source].URL) + return + } + + // ignore DeviceId, PlaySessionId as cache key + sourceCacheKey, err := url.Parse(embyC.Sources[source].URL) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp(err.Error())) + return + } + + query := sourceCacheKey.Query() + query.Del("DeviceId") + query.Del("PlaySessionId") + sourceCacheKey.RawQuery = query.Encode() + + err = proxy.AutoProxyURL(ctx, + embyC.Sources[source].URL, + "", + nil, + ctx.GetString("token"), + s.movie.RoomID, + s.movie.ID, + proxy.WithProxyURLCache(true), + proxy.WithProxyURLCacheKey(sourceCacheKey.String()), + ) + if err != nil { + log.Errorf("proxy vendor movie error: %v", err) + } +} + +func (s *EmbyVendorService) handleSubtitle(ctx *gin.Context) error { + u, err := op.LoadOrInitUserByID(s.movie.Movie.CreatorID) + if err != nil { + return err + } + + embyC, err := s.movie.EmbyCache().Get(ctx, u.Value().EmbyCache()) + if err != nil { + return err + } + + source, err := strconv.Atoi(ctx.Query("source")) + if err != nil { + return err + } + + if source >= len(embyC.Sources) { + return errors.New("source out of range") + } + + id, err := strconv.Atoi(ctx.Query("id")) + if err != nil { + return err + } + + if id >= len(embyC.Sources[source].Subtitles) { + return errors.New("id out of range") + } + + data, err := embyC.Sources[source].Subtitles[id].Cache.Get(ctx) + if err != nil { + return err + } + + http.ServeContent(ctx.Writer, ctx.Request, embyC.Sources[source].Subtitles[id].Name, time.Now(), bytes.NewReader(data)) + return nil +} + +func (s *EmbyVendorService) ProxyMovie(ctx *gin.Context) { + switch t := ctx.Query("t"); t { + case "": + s.handleProxyMovie(ctx) + case "subtitle": + s.handleSubtitle(ctx) + default: + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp(fmt.Sprintf("unknown proxy type: %s", t))) + } +} + +func (s *EmbyVendorService) GenMovieInfo(ctx context.Context, user *op.User, userAgent, userToken string) (*dbModel.Movie, error) { + if s.movie.Proxy { + return s.GenProxyMovieInfo(ctx, user, userAgent, userToken) + } + + movie := s.movie.Clone() + var err error + + u, err := op.LoadOrInitUserByID(movie.CreatorID) + if err != nil { + return nil, err + } + data, err := s.movie.EmbyCache().Get(ctx, u.Value().EmbyCache()) + if err != nil { + return nil, err + } + + if len(data.Sources) == 0 { + return nil, errors.New("no source") + } + movie.MovieBase.URL = data.Sources[0].URL + for _, s := range data.Sources[0].Subtitles { + if movie.MovieBase.Subtitles == nil { + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(data.Sources[0].Subtitles)) + } + movie.MovieBase.Subtitles[s.Name] = &dbModel.Subtitle{ + URL: s.URL, + Type: s.Type, + } + } + for _, s := range data.Sources[1:] { + movie.MovieBase.MoreSources = append(movie.MovieBase.MoreSources, + &dbModel.MoreSource{ + Name: s.Name, + URL: s.URL, + }, + ) + + for _, subt := range s.Subtitles { + if movie.MovieBase.Subtitles == nil { + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(s.Subtitles)) + } + movie.MovieBase.Subtitles[subt.Name] = &dbModel.Subtitle{ + URL: subt.URL, + Type: subt.Type, + } + } + } + + return movie, nil +} + +func (s *EmbyVendorService) GenProxyMovieInfo(ctx context.Context, user *op.User, userAgent, userToken string) (*dbModel.Movie, error) { + movie := s.movie.Clone() + var err error + + u, err := op.LoadOrInitUserByID(movie.CreatorID) + if err != nil { + return nil, err + } + data, err := s.movie.EmbyCache().Get(ctx, u.Value().EmbyCache()) + if err != nil { + return nil, err + } + + for si, es := range data.Sources { + if len(es.URL) == 0 { + if si != len(data.Sources)-1 { + continue + } + if movie.MovieBase.URL == "" { + return nil, errors.New("no source") + } + } + + rawPath, err := url.JoinPath("/api/room/movie/proxy", movie.ID) + if err != nil { + return nil, err + } + rawQuery := url.Values{} + rawQuery.Set("source", strconv.Itoa(si)) + rawQuery.Set("token", userToken) + rawQuery.Set("roomId", movie.RoomID) + u := url.URL{ + Path: rawPath, + RawQuery: rawQuery.Encode(), + } + movie.MovieBase.URL = u.String() + movie.MovieBase.Type = utils.GetURLExtension(es.URL) + + if len(es.Subtitles) == 0 { + continue + } + for sbi, s := range es.Subtitles { + if movie.MovieBase.Subtitles == nil { + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(es.Subtitles)) + } + rawQuery := url.Values{} + rawQuery.Set("t", "subtitle") + rawQuery.Set("source", strconv.Itoa(si)) + rawQuery.Set("id", strconv.Itoa(sbi)) + rawQuery.Set("token", userToken) + rawQuery.Set("roomId", movie.RoomID) + u := url.URL{ + Path: rawPath, + RawQuery: rawQuery.Encode(), + } + movie.MovieBase.Subtitles[s.Name] = &dbModel.Subtitle{ + URL: u.String(), + Type: s.Type, + } + } + } + + return movie, nil +} diff --git a/server/handlers/vendors/vendoremby/list.go b/server/handlers/vendors/vendoremby/list.go new file mode 100644 index 00000000..fd38178b --- /dev/null +++ b/server/handlers/vendors/vendoremby/list.go @@ -0,0 +1,177 @@ +package vendoremby + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + json "github.com/json-iterator/go" + "github.com/synctv-org/synctv/internal/db" + dbModel "github.com/synctv-org/synctv/internal/model" + "github.com/synctv-org/synctv/internal/op" + "github.com/synctv-org/synctv/internal/vendor" + "github.com/synctv-org/synctv/server/model" + "github.com/synctv-org/synctv/utils" + "github.com/synctv-org/vendors/api/emby" + "gorm.io/gorm" +) + +type ListReq struct { + Path string `json:"path"` + Keywords string `json:"keywords"` +} + +func (r *ListReq) Validate() (err error) { + return nil +} + +func (r *ListReq) Decode(ctx *gin.Context) error { + return json.NewDecoder(ctx.Request.Body).Decode(r) +} + +type EmbyFileItem struct { + *model.Item + Type string `json:"type"` +} + +type EmbyFSListResp = model.VendorFSListResp[*EmbyFileItem] + +func List(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + + req := ListReq{} + if err := model.Decode(ctx, &req); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + page, size, err := utils.GetPageAndMax(ctx) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + if req.Path == "" { + if req.Keywords != "" { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp("keywords is not supported when not choose server (server id is empty)")) + return + } + socpes := [](func(*gorm.DB) *gorm.DB){ + db.OrderByCreatedAtAsc, + } + + total, err := db.GetEmbyVendorsCount(user.ID, socpes...) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + if total == 0 { + ctx.JSON(http.StatusBadRequest, model.NewAPIErrorStringResp("emby server not found")) + return + } + + ev, err := db.GetEmbyVendors(user.ID, append(socpes, db.Paginate(page, size))...) + if err != nil { + if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { + ctx.JSON(http.StatusBadRequest, model.NewAPIErrorStringResp("emby server not found")) + return + } + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + if total == 1 { + req.Path = ev[0].ServerID + "/" + goto EmbyFSListResp + } + + resp := EmbyFSListResp{ + Paths: []*model.Path{ + { + Name: "", + Path: "", + }, + }, + Total: uint64(total), + } + + for _, evi := range ev { + resp.Items = append(resp.Items, &EmbyFileItem{ + Item: &model.Item{ + Name: evi.Host, + Path: evi.ServerID + `/`, + IsDir: true, + }, + Type: "server", + }) + } + + ctx.JSON(http.StatusOK, model.NewAPIDataResp(resp)) + + return + } + +EmbyFSListResp: + + var serverID string + serverID, req.Path, err = dbModel.GetEmbyServerIDFromPath(req.Path) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + aucd, err := user.EmbyCache().LoadOrStore(ctx, serverID) + if err != nil { + if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { + ctx.JSON(http.StatusBadRequest, model.NewAPIErrorStringResp("emby server not found")) + return + } + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + cli := vendor.LoadEmbyClient(ctx.Query("backend")) + data, err := cli.FsList(ctx, &emby.FsListReq{ + Host: aucd.Host, + Path: req.Path, + Token: aucd.APIKey, + UserId: aucd.UserID, + Limit: uint64(size), + StartIndex: uint64((page - 1) * size), + SearchTerm: req.Keywords, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(fmt.Errorf("emby fs list error: %w", err))) + return + } + + var resp EmbyFSListResp = EmbyFSListResp{ + Paths: []*model.Path{ + {}, + }, + } + for _, p := range data.Paths { + n := p.Name + if p.Path == "1" { + n = aucd.Host + } + resp.Paths = append(resp.Paths, &model.Path{ + Name: n, + Path: fmt.Sprintf("%s/%s", aucd.ServerID, p.Path), + }) + } + for _, i := range data.Items { + resp.Items = append(resp.Items, &EmbyFileItem{ + Item: &model.Item{ + Name: i.Name, + Path: fmt.Sprintf("%s/%s", aucd.ServerID, i.Id), + IsDir: i.IsFolder, + }, + Type: i.Type, + }) + } + + resp.Total = data.Total + ctx.JSON(http.StatusOK, model.NewAPIDataResp(resp)) +} diff --git a/server/handlers/vendors/vendoremby/login.go b/server/handlers/vendors/vendoremby/login.go new file mode 100644 index 00000000..6eb421cf --- /dev/null +++ b/server/handlers/vendors/vendoremby/login.go @@ -0,0 +1,138 @@ +package vendoremby + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" + json "github.com/json-iterator/go" + "github.com/synctv-org/synctv/internal/cache" + "github.com/synctv-org/synctv/internal/db" + dbModel "github.com/synctv-org/synctv/internal/model" + "github.com/synctv-org/synctv/internal/op" + "github.com/synctv-org/synctv/internal/vendor" + "github.com/synctv-org/synctv/server/model" + "github.com/synctv-org/vendors/api/emby" +) + +type LoginReq struct { + Host string `json:"host"` + Username string `json:"username"` + Password string `json:"password"` +} + +func (r *LoginReq) Validate() error { + if r.Host == "" { + return errors.New("host is required") + } + url, err := url.Parse(r.Host) + if err != nil { + return err + } + if url.Scheme != "http" && url.Scheme != "https" { + return errors.New("host is invalid") + } + r.Host = strings.TrimRight(url.String(), "/") + if r.Username == "" { + return errors.New("username is required") + } + return nil +} + +func (r *LoginReq) Decode(ctx *gin.Context) error { + return json.NewDecoder(ctx.Request.Body).Decode(r) +} + +func Login(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + + req := LoginReq{} + if err := model.Decode(ctx, &req); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + backend := ctx.Query("backend") + cli := vendor.LoadEmbyClient(backend) + + data, err := cli.Login(ctx, &emby.LoginReq{ + Host: req.Host, + Username: req.Username, + Password: req.Password, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + if data.ServerId == "" { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorStringResp("serverID is empty")) + return + } + + _, err = db.CreateOrSaveEmbyVendor(&dbModel.EmbyVendor{ + UserID: user.ID, + ServerID: data.ServerId, + Host: req.Host, + APIKey: data.Token, + Backend: backend, + EmbyUserID: data.UserId, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + _, err = user.EmbyCache().StoreOrRefreshWithDynamicFunc(ctx, data.ServerId, func(ctx context.Context, key string) (*cache.EmbyUserCacheData, error) { + return &cache.EmbyUserCacheData{ + Host: req.Host, + ServerID: key, + APIKey: data.Token, + Backend: backend, + UserID: data.UserId, + }, nil + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + ctx.Status(http.StatusNoContent) +} + +func Logout(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + + var req model.ServerIDReq + if err := model.Decode(ctx, &req); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err)) + return + } + + err := db.DeleteEmbyVendor(user.ID, req.ServerID) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + eucd, ok := user.EmbyCache().LoadCache(req.ServerID) + if ok { + eucdr, _ := eucd.Raw() + go logoutEmby(eucdr) + } + + ctx.Status(http.StatusNoContent) +} + +func logoutEmby(eucd *cache.EmbyUserCacheData) { + if eucd == nil || eucd.APIKey == "" { + return + } + _, _ = vendor.LoadEmbyClient(eucd.Backend).Logout(context.Background(), &emby.LogoutReq{ + Host: eucd.Host, + Token: eucd.APIKey, + }) +} diff --git a/server/handlers/vendors/vendoremby/me.go b/server/handlers/vendors/vendoremby/me.go new file mode 100644 index 00000000..6a49d3ff --- /dev/null +++ b/server/handlers/vendors/vendoremby/me.go @@ -0,0 +1,83 @@ +package vendoremby + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/synctv-org/synctv/internal/db" + "github.com/synctv-org/synctv/internal/op" + "github.com/synctv-org/synctv/internal/vendor" + "github.com/synctv-org/synctv/server/model" + "github.com/synctv-org/vendors/api/emby" +) + +type EmbyMeResp = model.VendorMeResp[*emby.SystemInfoResp] + +func Me(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + + serverID := ctx.Query("serverID") + if serverID == "" { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(errors.New("serverID is required"))) + return + } + + eucd, err := user.EmbyCache().LoadOrStore(ctx, serverID) + if err != nil { + if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { + ctx.JSON(http.StatusBadRequest, model.NewAPIErrorStringResp("emby server not found")) + return + } + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + data, err := vendor.LoadEmbyClient(eucd.Backend).GetSystemInfo(ctx, &emby.SystemInfoReq{ + Host: eucd.Host, + Token: eucd.APIKey, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + ctx.JSON(http.StatusOK, model.NewAPIDataResp(&EmbyMeResp{ + IsLogin: true, + Info: data, + })) +} + +type EmbyBindsResp []*struct { + ServerID string `json:"serverId"` + Host string `json:"host"` +} + +func Binds(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + + ev, err := db.GetEmbyVendors(user.ID) + if err != nil { + if errors.Is(err, db.NotFoundError(db.ErrVendorNotFound)) { + ctx.JSON(http.StatusOK, model.NewAPIDataResp(&EmbyMeResp{ + IsLogin: false, + })) + return + } + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err)) + return + } + + resp := make(EmbyBindsResp, len(ev)) + for i, v := range ev { + resp[i] = &struct { + ServerID string `json:"serverId"` + Host string `json:"host"` + }{ + ServerID: v.ServerID, + Host: v.Host, + } + } + + ctx.JSON(http.StatusOK, model.NewAPIDataResp(resp)) +}