mirror of https://github.com/synctv-org/synctv
parent
b935a0e664
commit
9edef8d683
@ -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…
Reference in New Issue