fix: case

pull/232/merge v0.7.10
zijiren233 3 months ago
parent b935a0e664
commit 9edef8d683

@ -1,4 +1,4 @@
FROM golang:1.23-alpine as builder
FROM golang:1.23-alpine AS builder
ARG VERSION=dev

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -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))
}
Loading…
Cancel
Save