From 7d9f4417ae4a1c71fa00174f82f40b356dd7d102 Mon Sep 17 00:00:00 2001 From: zijiren233 Date: Wed, 27 Dec 2023 16:01:11 +0800 Subject: [PATCH] Feat: support mutil emby client --- cmd/root.go | 2 +- go.mod | 2 +- go.sum | 4 +- internal/cache/bilibili.go | 4 +- internal/cache/cache.go | 109 +++++++++++++----- internal/cache/emby.go | 63 ++++++---- internal/db/movie.go | 6 +- internal/db/update.go | 67 ++++++----- internal/db/user.go | 2 +- internal/db/vendorBackend.go | 2 +- internal/db/vendorRecord.go | 55 ++++++--- internal/model/movie.go | 16 ++- internal/model/setting.go | 11 +- internal/model/user.go | 2 +- internal/model/vendorBackend.go | 8 +- internal/model/vendorRecord.go | 29 +++-- internal/op/movie.go | 3 +- server/handlers/init.go | 2 + server/handlers/vendors/vendorAlist/login.go | 3 +- .../handlers/vendors/vendorBilibili/login.go | 6 +- server/handlers/vendors/vendorEmby/list.go | 82 +++++++++++-- server/handlers/vendors/vendorEmby/login.go | 61 +++++++--- server/handlers/vendors/vendorEmby/me.go | 49 ++++++-- 23 files changed, 428 insertions(+), 160 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 7f13e5a..170f277 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -40,7 +40,7 @@ func init() { home = "~" } RootCmd.PersistentFlags().StringVar(&flags.DataDir, "data-dir", filepath.Join(home, ".synctv"), "data dir") - RootCmd.PersistentFlags().BoolVar(&flags.ForceAutoMigrate, "force-auto-migrate", false, "force auto migrate") + RootCmd.PersistentFlags().BoolVar(&flags.ForceAutoMigrate, "force-auto-migrate", version.Version == "dev", "force auto migrate") } func init() { diff --git a/go.mod b/go.mod index b564e91..ad68afc 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/soheilhy/cmux v0.1.5 github.com/spf13/cobra v1.8.0 - github.com/synctv-org/vendors v0.2.2-0.20231226121731-35912737bbb2 + github.com/synctv-org/vendors v0.2.2-0.20231227065102-90bfed7f8a05 github.com/ulule/limiter/v3 v3.11.2 github.com/zencoder/go-dash/v3 v3.0.3 github.com/zijiren233/gencontainer v0.0.0-20231213075414-f7f4c8261dca diff --git a/go.sum b/go.sum index a620ef9..f6791bd 100644 --- a/go.sum +++ b/go.sum @@ -350,8 +350,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/synctv-org/vendors v0.2.2-0.20231226121731-35912737bbb2 h1:c9R7idpWvnqKvMxGkzQvJMoQ8os1shy+rgaawjk6xKw= -github.com/synctv-org/vendors v0.2.2-0.20231226121731-35912737bbb2/go.mod h1:Q+KEUh8ZgCSMjY5rCfz44+7POlHcGttZ/bYFiTDVBds= +github.com/synctv-org/vendors v0.2.2-0.20231227065102-90bfed7f8a05 h1:cWpk2uk9P6K1kuwnLy5Zay0GifYk3ss5bxUOMLb0yV4= +github.com/synctv-org/vendors v0.2.2-0.20231227065102-90bfed7f8a05/go.mod h1:Q+KEUh8ZgCSMjY5rCfz44+7POlHcGttZ/bYFiTDVBds= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= diff --git a/internal/cache/bilibili.go b/internal/cache/bilibili.go index 4cfca20..2532d89 100644 --- a/internal/cache/bilibili.go +++ b/internal/cache/bilibili.go @@ -130,8 +130,8 @@ func BilibiliSharedMpdCacheInitFunc(ctx context.Context, movie *model.Movie, arg }, nil } -func NewBilibiliNoSharedMovieCacheInitFunc(movie *model.Movie) func(ctx context.Context, args ...*BilibiliUserCache) (string, error) { - return func(ctx context.Context, args ...*BilibiliUserCache) (string, error) { +func NewBilibiliNoSharedMovieCacheInitFunc(movie *model.Movie) func(ctx context.Context, key string, args ...*BilibiliUserCache) (string, error) { + return func(ctx context.Context, key string, args ...*BilibiliUserCache) (string, error) { return BilibiliNoSharedMovieCacheInitFunc(ctx, movie, args...) } } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index adcf49f..8cc70f7 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -9,7 +9,7 @@ import ( "golang.org/x/exp/maps" ) -type MapRefreshFunc[T any, A any] func(ctx context.Context, args ...A) (T, error) +type MapRefreshFunc[T any, A any] func(ctx context.Context, key string, args ...A) (T, error) type MapCache[T any, A any] struct { lock sync.RWMutex @@ -36,82 +36,137 @@ func (b *MapCache[T, A]) clear() { maps.Clear(b.cache) } -func (b *MapCache[T, A]) LoadOrStore(ctx context.Context, id string, args ...A) (T, error) { +func (b *MapCache[T, A]) Delete(key string) { + b.lock.Lock() + defer b.lock.Unlock() + delete(b.cache, key) +} + +func (b *MapCache[T, A]) LoadOrStore(ctx context.Context, key string, args ...A) (T, error) { b.lock.RLock() - c, loaded := b.cache[id] + c, loaded := b.cache[key] if loaded { b.lock.RUnlock() return c.Get(ctx, args...) } b.lock.RUnlock() b.lock.Lock() - c, loaded = b.cache[id] + c, loaded = b.cache[key] if loaded { b.lock.Unlock() return c.Get(ctx, args...) } - c = refreshcache.NewRefreshCache[T, A](refreshcache.RefreshFunc[T, A](b.refreshFunc), b.maxAge) - b.cache[id] = c + c = refreshcache.NewRefreshCache[T, A](refreshcache.RefreshFunc[T, A](func(ctx context.Context, args ...A) (T, error) { + return b.refreshFunc(ctx, key, args...) + }), b.maxAge) + b.cache[key] = c b.lock.Unlock() return c.Get(ctx, args...) } -func (b *MapCache[T, A]) StoreOrRefresh(ctx context.Context, id string, args ...A) (T, error) { +func (b *MapCache[T, A]) StoreOrRefresh(ctx context.Context, key string, args ...A) (T, error) { b.lock.RLock() - c, ok := b.cache[id] + c, ok := b.cache[key] if ok { b.lock.RUnlock() return c.Refresh(ctx, args...) } b.lock.RUnlock() b.lock.Lock() - c, ok = b.cache[id] + c, ok = b.cache[key] if ok { b.lock.Unlock() return c.Refresh(ctx, args...) } - c = refreshcache.NewRefreshCache[T, A](refreshcache.RefreshFunc[T, A](b.refreshFunc), b.maxAge) - b.cache[id] = c + c = refreshcache.NewRefreshCache[T, A](refreshcache.RefreshFunc[T, A](func(ctx context.Context, args ...A) (T, error) { + return b.refreshFunc(ctx, key, args...) + }), b.maxAge) + b.cache[key] = c b.lock.Unlock() return c.Refresh(ctx, args...) } -func (b *MapCache[T, A]) LoadOrStoreWithDynamicFunc(ctx context.Context, id string, refreshFunc MapRefreshFunc[T, A], args ...A) (T, error) { +func (b *MapCache[T, A]) LoadCache(key string) (*refreshcache.RefreshCache[T, A], bool) { + b.lock.RLock() + c, ok := b.cache[key] + b.lock.RUnlock() + return c, ok +} + +func (b *MapCache[T, A]) LoadOrNewCache(key string) *refreshcache.RefreshCache[T, A] { + b.lock.RLock() + c, ok := b.cache[key] + if ok { + b.lock.RUnlock() + return c + } + b.lock.RUnlock() + b.lock.Lock() + c, ok = b.cache[key] + if ok { + b.lock.Unlock() + return c + } + c = refreshcache.NewRefreshCache[T, A](refreshcache.RefreshFunc[T, A](func(ctx context.Context, args ...A) (T, error) { + return b.refreshFunc(ctx, key, args...) + }), b.maxAge) + b.cache[key] = c + b.lock.Unlock() + return c +} + +func (b *MapCache[T, A]) LoadOrStoreWithDynamicFunc(ctx context.Context, key string, refreshFunc MapRefreshFunc[T, A], args ...A) (T, error) { b.lock.RLock() - c, loaded := b.cache[id] + c, loaded := b.cache[key] if loaded { b.lock.RUnlock() - return c.Data().Get(ctx, refreshcache.RefreshFunc[T, A](refreshFunc), args...) + return c.Data().Get(ctx, refreshcache.RefreshFunc[T, A](func(ctx context.Context, args ...A) (T, error) { + return refreshFunc(ctx, key, args...) + }), args...) } b.lock.RUnlock() b.lock.Lock() - c, loaded = b.cache[id] + c, loaded = b.cache[key] if loaded { b.lock.Unlock() - return c.Data().Get(ctx, refreshcache.RefreshFunc[T, A](refreshFunc), args...) + return c.Data().Get(ctx, refreshcache.RefreshFunc[T, A](func(ctx context.Context, args ...A) (T, error) { + return refreshFunc(ctx, key, args...) + }), args...) } - c = refreshcache.NewRefreshCache[T, A](refreshcache.RefreshFunc[T, A](b.refreshFunc), b.maxAge) - b.cache[id] = c + c = refreshcache.NewRefreshCache[T, A](refreshcache.RefreshFunc[T, A](func(ctx context.Context, args ...A) (T, error) { + return b.refreshFunc(ctx, key, args...) + }), b.maxAge) + b.cache[key] = c b.lock.Unlock() - return c.Data().Get(ctx, refreshcache.RefreshFunc[T, A](refreshFunc), args...) + return c.Data().Get(ctx, refreshcache.RefreshFunc[T, A](func(ctx context.Context, args ...A) (T, error) { + return refreshFunc(ctx, key, args...) + }), args...) } -func (b *MapCache[T, A]) StoreOrRefreshWithDynamicFunc(ctx context.Context, id string, refreshFunc MapRefreshFunc[T, A], args ...A) (T, error) { +func (b *MapCache[T, A]) StoreOrRefreshWithDynamicFunc(ctx context.Context, key string, refreshFunc MapRefreshFunc[T, A], args ...A) (T, error) { b.lock.RLock() - c, ok := b.cache[id] + c, ok := b.cache[key] if ok { b.lock.RUnlock() - return c.Data().Refresh(ctx, refreshcache.RefreshFunc[T, A](refreshFunc), args...) + return c.Data().Refresh(ctx, refreshcache.RefreshFunc[T, A](func(ctx context.Context, args ...A) (T, error) { + return refreshFunc(ctx, key, args...) + }), args...) } b.lock.RUnlock() b.lock.Lock() - c, ok = b.cache[id] + c, ok = b.cache[key] if ok { b.lock.Unlock() - return c.Data().Refresh(ctx, refreshcache.RefreshFunc[T, A](refreshFunc), args...) + return c.Data().Refresh(ctx, refreshcache.RefreshFunc[T, A](func(ctx context.Context, args ...A) (T, error) { + return refreshFunc(ctx, key, args...) + }), args...) } - c = refreshcache.NewRefreshCache[T, A](refreshcache.RefreshFunc[T, A](b.refreshFunc), b.maxAge) - b.cache[id] = c + c = refreshcache.NewRefreshCache[T, A](refreshcache.RefreshFunc[T, A](func(ctx context.Context, args ...A) (T, error) { + return b.refreshFunc(ctx, key, args...) + }), b.maxAge) + b.cache[key] = c b.lock.Unlock() - return c.Data().Refresh(ctx, refreshcache.RefreshFunc[T, A](refreshFunc), args...) + return c.Data().Refresh(ctx, refreshcache.RefreshFunc[T, A](func(ctx context.Context, args ...A) (T, error) { + return refreshFunc(ctx, key, args...) + }), args...) } diff --git a/internal/cache/emby.go b/internal/cache/emby.go index be65073..15ea829 100644 --- a/internal/cache/emby.go +++ b/internal/cache/emby.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "strings" "github.com/synctv-org/synctv/internal/db" "github.com/synctv-org/synctv/internal/model" @@ -16,36 +17,43 @@ import ( "github.com/zijiren233/gencontainer/refreshcache" ) -type EmbyUserCache = refreshcache.RefreshCache[*EmbyUserCacheData, struct{}] +type EmbyUserCache = MapCache[*EmbyUserCacheData, struct{}] type EmbyUserCacheData struct { - Host string - ApiKey string - Backend string + Host string + ServerID string + ApiKey string + Backend string } func NewEmbyUserCache(userID string) *EmbyUserCache { - f := EmbyAuthorizationCacheWithUserIDInitFunc(userID) - return refreshcache.NewRefreshCache(func(ctx context.Context, args ...struct{}) (*EmbyUserCacheData, error) { - return f(ctx) + return newMapCache(func(ctx context.Context, key string, args ...struct{}) (*EmbyUserCacheData, error) { + return EmbyAuthorizationCacheWithUserIDInitFunc(userID, key) }, 0) } -func EmbyAuthorizationCacheWithUserIDInitFunc(userID string) func(ctx context.Context, args ...struct{}) (*EmbyUserCacheData, error) { - return func(ctx context.Context, args ...struct{}) (*EmbyUserCacheData, error) { - v, err := db.GetEmbyVendor(userID) - if err != nil { - return nil, err - } - if v.ApiKey == "" || v.Host == "" { - return nil, db.ErrNotFound("vendor") - } - return &EmbyUserCacheData{ - Host: v.Host, - ApiKey: v.ApiKey, - Backend: v.Backend, - }, nil +func EmbyAuthorizationCacheWithUserIDInitFunc(userID, serverID string) (*EmbyUserCacheData, error) { + var ( + v *model.EmbyVendor + err error + ) + if serverID == "" { + v, err = db.GetEmbyFirstVendor(userID) + } else { + v, err = db.GetEmbyVendor(userID, serverID) + } + if err != nil { + return nil, err } + if v.ApiKey == "" || v.Host == "" { + return nil, db.ErrNotFound("vendor") + } + return &EmbyUserCacheData{ + Host: v.Host, + ServerID: v.ServerID, + ApiKey: v.ApiKey, + Backend: v.Backend, + }, nil } type EmbySource struct { @@ -76,7 +84,16 @@ func NewEmbyMovieCacheInitFunc(movie *model.Movie) func(ctx context.Context, arg if len(args) == 0 { return nil, errors.New("need emby user cache") } - aucd, err := args[0].Get(ctx) + + var serverID, itemID string + if s := strings.Split(movie.Base.VendorInfo.Emby.Path, "/"); len(s) == 2 { + serverID = s[0] + itemID = s[1] + } else { + return nil, errors.New("path is invalid") + } + + aucd, err := args[0].LoadOrStore(ctx, serverID) if err != nil { return nil, err } @@ -91,7 +108,7 @@ func NewEmbyMovieCacheInitFunc(movie *model.Movie) func(ctx context.Context, arg data, err := cli.GetItem(ctx, &emby.GetItemReq{ Host: aucd.Host, Token: aucd.ApiKey, - ItemId: movie.Base.VendorInfo.Emby.Path, + ItemId: itemID, }) if err != nil { return nil, err diff --git a/internal/db/movie.go b/internal/db/movie.go index c5ad33e..5a4eab2 100644 --- a/internal/db/movie.go +++ b/internal/db/movie.go @@ -48,7 +48,7 @@ func UpdateMovie(movie *model.Movie, columns ...clause.Column) error { } func SaveMovie(movie *model.Movie, columns ...clause.Column) error { - err := db.Model(movie).Clauses(clause.Returning{Columns: columns}).Where("room_id = ? AND id = ?", movie.RoomID, movie.ID).Save(movie).Error + err := db.Model(movie).Clauses(clause.Returning{Columns: columns}).Where("room_id = ? AND id = ?", movie.RoomID, movie.ID).Omit("created_at").Save(movie).Error return HandleNotFound(err, "room or movie") } @@ -65,10 +65,10 @@ func SwapMoviePositions(roomID, movie1ID, movie2ID string) (err error) { return HandleNotFound(err, "movie2") } movie1.Position, movie2.Position = movie2.Position, movie1.Position - err = tx.Save(movie1).Error + err = tx.Omit("created_at").Save(movie1).Error if err != nil { return err } - return tx.Save(movie2).Error + return tx.Omit("created_at").Save(movie2).Error }) } diff --git a/internal/db/update.go b/internal/db/update.go index 68b4ffe..8f5a6a5 100644 --- a/internal/db/update.go +++ b/internal/db/update.go @@ -33,42 +33,57 @@ var models = []any{ var dbVersions = map[string]dbVersion{ "0.0.1": { NextVersion: "0.0.2", + Upgrade: nil, + }, + "0.0.2": { + NextVersion: "0.0.3", Upgrade: func(db *gorm.DB) error { return db.Migrator().DropTable("streaming_vendor_infos") }, }, - "0.0.2": { + "0.0.3": { NextVersion: "", - Upgrade: nil, + Upgrade: func(db *gorm.DB) error { + return db.Migrator().DropTable("alist_vendors", "emby_vendors") + }, }, } func UpgradeDatabase() error { - var currentVersion string - if db.Migrator().HasTable(&model.Setting{}) { - setting := model.Setting{ - Name: "database_version", - Type: model.SettingTypeString, - Group: model.SettingGroupDatabase, - Value: CurrentVersion, - } - err := FirstOrCreateSettingItemValue(&setting) - if err != nil { + if conf.Conf.Database.Type == conf.DatabaseTypeMysql { + if err := db.Exec("SET FOREIGN_KEY_CHECKS = 0").Error; err != nil { return err } - currentVersion = setting.Value - if flags.ForceAutoMigrate || currentVersion != CurrentVersion { + defer func() { + err := db.Exec("SET FOREIGN_KEY_CHECKS = 1").Error + if err != nil { + log.Fatalf("failed to set foreign key checks: %s", err.Error()) + } + }() + } + + if !db.Migrator().HasTable(&model.Setting{}) { + return autoMigrate(models...) + } + + setting := model.Setting{ + Name: "database_version", + Type: model.SettingTypeString, + Group: model.SettingGroupDatabase, + Value: CurrentVersion, + } + err := FirstOrCreateSettingItemValue(&setting) + if err != nil { + return err + } + currentVersion := setting.Value + if flags.ForceAutoMigrate || currentVersion != CurrentVersion { + defer func() { err = autoMigrate(models...) if err != nil { - return err + log.Fatalf("failed to auto migrate: %s", err.Error()) } - } - } else { - err := autoMigrate(models...) - if err != nil { - return err - } - currentVersion = CurrentVersion + }() } version, ok := dbVersions[currentVersion] @@ -101,13 +116,7 @@ func autoMigrate(dst ...any) error { log.Info("migrating database...") switch conf.Conf.Database.Type { case conf.DatabaseTypeMysql: - if err := db.Exec("SET FOREIGN_KEY_CHECKS = 0").Error; err != nil { - return err - } - if err := db.Set("gorm:table_options", "ENGINE=InnoDB CHARSET=utf8mb4").AutoMigrate(dst...); err != nil { - return err - } - return db.Exec("SET FOREIGN_KEY_CHECKS = 1").Error + return db.Set("gorm:table_options", "ENGINE=InnoDB CHARSET=utf8mb4").AutoMigrate(dst...) case conf.DatabaseTypeSqlite3, conf.DatabaseTypePostgres: return db.AutoMigrate(dst...) default: diff --git a/internal/db/user.go b/internal/db/user.go index 588fd4a..6df4c3f 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -270,7 +270,7 @@ func LoadAndDeleteUserByID(userID string, columns ...clause.Column) (*model.User } func SaveUser(u *model.User) error { - return db.Save(u).Error + return db.Omit("created_at").Save(u).Error } func AddAdmin(u *model.User) error { diff --git a/internal/db/vendorBackend.go b/internal/db/vendorBackend.go index 1462608..d0f46b3 100644 --- a/internal/db/vendorBackend.go +++ b/internal/db/vendorBackend.go @@ -52,7 +52,7 @@ func CreateOrSaveVendorBackend(backend *model.VendorBackend) (*model.VendorBacke if err := tx.Where("backend_endpoint = ?", backend.Backend.Endpoint).First(&model.VendorBackend{}).Error; errors.Is(err, gorm.ErrRecordNotFound) { return tx.Create(&backend).Error } else { - return tx.Save(&backend).Error + return tx.Omit("created_at").Save(&backend).Error } }) } diff --git a/internal/db/vendorRecord.go b/internal/db/vendorRecord.go index 65df973..cb2b52e 100644 --- a/internal/db/vendorRecord.go +++ b/internal/db/vendorRecord.go @@ -13,15 +13,17 @@ func GetBilibiliVendor(userID string) (*model.BilibiliVendor, error) { return &vendor, HandleNotFound(err, "vendor") } -func CreateOrSaveBilibiliVendor(userID string, vendorInfo *model.BilibiliVendor) (*model.BilibiliVendor, error) { - vendorInfo.UserID = userID +func CreateOrSaveBilibiliVendor(vendorInfo *model.BilibiliVendor) (*model.BilibiliVendor, error) { + if vendorInfo.UserID == "" { + return nil, errors.New("user_id must not be empty") + } return vendorInfo, Transactional(func(tx *gorm.DB) error { if errors.Is(tx.First(&model.BilibiliVendor{ - UserID: userID, + UserID: vendorInfo.UserID, }).Error, gorm.ErrRecordNotFound) { return tx.Create(&vendorInfo).Error } else { - return tx.Save(&vendorInfo).Error + return tx.Omit("created_at").Save(&vendorInfo).Error } }) } @@ -36,15 +38,17 @@ func GetAlistVendor(userID string) (*model.AlistVendor, error) { return &vendor, HandleNotFound(err, "vendor") } -func CreateOrSaveAlistVendor(userID string, vendorInfo *model.AlistVendor) (*model.AlistVendor, error) { - vendorInfo.UserID = userID +func CreateOrSaveAlistVendor(vendorInfo *model.AlistVendor) (*model.AlistVendor, error) { + if vendorInfo.UserID == "" { + return nil, errors.New("user_id must not be empty") + } return vendorInfo, Transactional(func(tx *gorm.DB) error { if errors.Is(tx.First(&model.AlistVendor{ - UserID: userID, + UserID: vendorInfo.UserID, }).Error, gorm.ErrRecordNotFound) { return tx.Create(&vendorInfo).Error } else { - return tx.Save(&vendorInfo).Error + return tx.Omit("created_at").Save(&vendorInfo).Error } }) } @@ -53,25 +57,46 @@ func DeleteAlistVendor(userID string) error { return db.Where("user_id = ?", userID).Delete(&model.AlistVendor{}).Error } -func GetEmbyVendor(userID string) (*model.EmbyVendor, error) { +func GetEmbyVendors(userID string, scopes ...func(*gorm.DB) *gorm.DB) ([]*model.EmbyVendor, error) { + var vendors []*model.EmbyVendor + err := db.Scopes(scopes...).Where("user_id = ?", userID).Find(&vendors).Error + return vendors, err +} + +func GetEmbyVendorsCount(userID string, scopes ...func(*gorm.DB) *gorm.DB) (int64, error) { + var count int64 + err := db.Scopes(scopes...).Where("user_id = ?", userID).Model(&model.EmbyVendor{}).Count(&count).Error + return count, err +} + +func GetEmbyVendor(userID, serverID string) (*model.EmbyVendor, error) { + var vendor model.EmbyVendor + err := db.Where("user_id = ? AND server_id = ?", userID, serverID).First(&vendor).Error + return &vendor, HandleNotFound(err, "vendor") +} + +func GetEmbyFirstVendor(userID string) (*model.EmbyVendor, error) { var vendor model.EmbyVendor err := db.Where("user_id = ?", userID).First(&vendor).Error return &vendor, HandleNotFound(err, "vendor") } -func CreateOrSaveEmbyVendor(userID string, vendorInfo *model.EmbyVendor) (*model.EmbyVendor, error) { - vendorInfo.UserID = userID +func CreateOrSaveEmbyVendor(vendorInfo *model.EmbyVendor) (*model.EmbyVendor, error) { + if vendorInfo.UserID == "" || vendorInfo.ServerID == "" { + return nil, errors.New("user_id and server_id must not be empty") + } return vendorInfo, Transactional(func(tx *gorm.DB) error { if errors.Is(tx.First(&model.EmbyVendor{ - UserID: userID, + UserID: vendorInfo.UserID, + ServerID: vendorInfo.ServerID, }).Error, gorm.ErrRecordNotFound) { return tx.Create(&vendorInfo).Error } else { - return tx.Save(&vendorInfo).Error + return tx.Omit("created_at").Save(&vendorInfo).Error } }) } -func DeleteEmbyVendor(userID string) error { - return db.Where("user_id = ?", userID).Delete(&model.EmbyVendor{}).Error +func DeleteEmbyVendor(userID, serverID string) error { + return db.Where("user_id = ? AND server_id = ?", userID, serverID).Delete(&model.EmbyVendor{}).Error } diff --git a/internal/model/movie.go b/internal/model/movie.go index c938bd5..5928029 100644 --- a/internal/model/movie.go +++ b/internal/model/movie.go @@ -89,6 +89,13 @@ type AlistStreamingInfo struct { Password string `gorm:"type:varchar(256)" json:"password,omitempty"` } +func (a *AlistStreamingInfo) Validate() error { + if a.Path == "" { + return fmt.Errorf("path is empty") + } + return nil +} + func (a *AlistStreamingInfo) BeforeSave(tx *gorm.DB) error { if a.Password != "" { s, err := utils.CryptoToBase64([]byte(a.Password), utils.GenCryptoKey(a.Path)) @@ -116,5 +123,12 @@ func (a *AlistStreamingInfo) AfterFind(tx *gorm.DB) error { } type EmbyStreamingInfo struct { - Path string `gorm:"type:varchar(20)" json:"path,omitempty"` + Path string `gorm:"type:varchar(52)" json:"path,omitempty"` +} + +func (e *EmbyStreamingInfo) Validate() error { + if e.Path == "" { + return fmt.Errorf("path is empty") + } + return nil } diff --git a/internal/model/setting.go b/internal/model/setting.go index e71e6b0..cb75ed3 100644 --- a/internal/model/setting.go +++ b/internal/model/setting.go @@ -1,5 +1,7 @@ package model +import "time" + type SettingType string const ( @@ -26,8 +28,9 @@ const ( ) type Setting struct { - Name string `gorm:"primaryKey;type:varchar(256)"` - Value string `gorm:"not null;type:text"` - Type SettingType `gorm:"not null;default:string"` - Group SettingGroup `gorm:"not null"` + Name string `gorm:"primaryKey;type:varchar(256)"` + UpdatedAt time.Time + Value string `gorm:"not null;type:text"` + Type SettingType `gorm:"not null;default:string"` + Group SettingGroup `gorm:"not null"` } diff --git a/internal/model/user.go b/internal/model/user.go index 3d8185f..64fe61e 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -52,7 +52,7 @@ type User struct { Movies []Movie `gorm:"foreignKey:CreatorID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"` BilibiliVendor *BilibiliVendor `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` AlistVendor *AlistVendor `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` - EmbyVendor *EmbyVendor `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + EmbyVendor []*EmbyVendor `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` } func (u *User) CheckPassword(password string) bool { diff --git a/internal/model/vendorBackend.go b/internal/model/vendorBackend.go index 9c7af15..4caf965 100644 --- a/internal/model/vendorBackend.go +++ b/internal/model/vendorBackend.go @@ -50,9 +50,11 @@ func (b *Backend) Validate() error { } type VendorBackend struct { - Backend Backend `gorm:"embedded;embeddedPrefix:backend_" json:"backend"` - Enabled bool `gorm:"default:true" json:"enabled"` - UsedBy BackendUsedBy `gorm:"embedded;embeddedPrefix:used_by_" json:"usedBy"` + CreatedAt time.Time + UpdatedAt time.Time + Backend Backend `gorm:"embedded;embeddedPrefix:backend_" json:"backend"` + Enabled bool `gorm:"default:true" json:"enabled"` + UsedBy BackendUsedBy `gorm:"embedded;embeddedPrefix:used_by_" json:"usedBy"` } type BackendUsedBy struct { diff --git a/internal/model/vendorRecord.go b/internal/model/vendorRecord.go index 437cfe3..1fab7e1 100644 --- a/internal/model/vendorRecord.go +++ b/internal/model/vendorRecord.go @@ -1,14 +1,18 @@ package model import ( + "time" + "github.com/synctv-org/synctv/utils" "gorm.io/gorm" ) type BilibiliVendor struct { - UserID string `gorm:"primaryKey;type:char(32)"` - Backend string `gorm:"type:varchar(64)"` - Cookies map[string]string `gorm:"serializer:fastjson;type:text"` + CreatedAt time.Time + UpdatedAt time.Time + UserID string `gorm:"primaryKey;type:char(32)"` + Backend string `gorm:"type:varchar(64)"` + Cookies map[string]string `gorm:"not null;serializer:fastjson;type:text"` } func (b *BilibiliVendor) BeforeSave(tx *gorm.DB) error { @@ -40,9 +44,11 @@ func (b *BilibiliVendor) AfterFind(tx *gorm.DB) error { } type AlistVendor struct { + CreatedAt time.Time + UpdatedAt time.Time UserID string `gorm:"primaryKey;type:char(32)"` Backend string `gorm:"type:varchar(64)"` - Host string `gorm:"type:varchar(256)"` + Host string `gorm:"not null;type:varchar(256)"` Username string `gorm:"type:varchar(256)"` HashedPassword []byte } @@ -87,14 +93,17 @@ func (a *AlistVendor) AfterFind(tx *gorm.DB) error { } type EmbyVendor struct { - UserID string `gorm:"primaryKey;type:char(32)"` - Backend string `gorm:"type:varchar(64)"` - Host string `gorm:"type:varchar(256)"` - ApiKey string `gorm:"type:varchar(256)"` + CreatedAt time.Time + UpdatedAt time.Time + UserID string `gorm:"primaryKey;type:char(32)"` + Backend string `gorm:"type:varchar(64)"` + ServerID string `gorm:"primaryKey;type:char(32)"` + Host string `gorm:"not null;type:varchar(256)"` + ApiKey string `gorm:"not null;type:varchar(256)"` } func (e *EmbyVendor) BeforeSave(tx *gorm.DB) error { - key := utils.GenCryptoKey(e.UserID) + key := utils.GenCryptoKey(e.ServerID) var err error if e.Host, err = utils.CryptoToBase64([]byte(e.Host), key); err != nil { return err @@ -106,7 +115,7 @@ func (e *EmbyVendor) BeforeSave(tx *gorm.DB) error { } func (e *EmbyVendor) AfterSave(tx *gorm.DB) error { - key := utils.GenCryptoKey(e.UserID) + key := utils.GenCryptoKey(e.ServerID) if v, err := utils.DecryptoFromBase64(e.Host, key); err != nil { return err } else { diff --git a/internal/op/movie.go b/internal/op/movie.go index ce533c6..6f15c72 100644 --- a/internal/op/movie.go +++ b/internal/op/movie.go @@ -225,9 +225,10 @@ func (movie *Movie) validateVendorMovie() error { return movie.Movie.Base.VendorInfo.Bilibili.Validate() case model.VendorAlist: - // return movie.Movie.Base.VendorInfo.Alist.Validate() + return movie.Movie.Base.VendorInfo.Alist.Validate() case model.VendorEmby: + return movie.Movie.Base.VendorInfo.Emby.Validate() default: return fmt.Errorf("vendor not implement validate") diff --git a/server/handlers/init.go b/server/handlers/init.go index 911d614..1406991 100644 --- a/server/handlers/init.go +++ b/server/handlers/init.go @@ -257,5 +257,7 @@ func initVendor(vendor *gin.RouterGroup) { emby.POST("/list", vendorEmby.List) emby.GET("/me", vendorEmby.Me) + + emby.GET("/binds", vendorEmby.Binds) } } diff --git a/server/handlers/vendors/vendorAlist/login.go b/server/handlers/vendors/vendorAlist/login.go index a8b88c6..f858c32 100644 --- a/server/handlers/vendors/vendorAlist/login.go +++ b/server/handlers/vendors/vendorAlist/login.go @@ -65,7 +65,8 @@ func Login(ctx *gin.Context) { return } - _, err = db.CreateOrSaveAlistVendor(user.ID, &dbModel.AlistVendor{ + _, err = db.CreateOrSaveAlistVendor(&dbModel.AlistVendor{ + UserID: user.ID, Backend: backend, Host: req.Host, Username: req.Username, diff --git a/server/handlers/vendors/vendorBilibili/login.go b/server/handlers/vendors/vendorBilibili/login.go index ff0bf08..1449777 100644 --- a/server/handlers/vendors/vendorBilibili/login.go +++ b/server/handlers/vendors/vendorBilibili/login.go @@ -76,7 +76,8 @@ func LoginWithQR(ctx *gin.Context) { })) return case bilibili.QRCodeStatus_SUCCESS: - _, err = db.CreateOrSaveBilibiliVendor(user.ID, &dbModel.BilibiliVendor{ + _, err = db.CreateOrSaveBilibiliVendor(&dbModel.BilibiliVendor{ + UserID: user.ID, Cookies: resp.Cookies, Backend: backend, }) @@ -203,7 +204,8 @@ func LoginWithSMS(ctx *gin.Context) { ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) return } - _, err = db.CreateOrSaveBilibiliVendor(user.ID, &dbModel.BilibiliVendor{ + _, err = db.CreateOrSaveBilibiliVendor(&dbModel.BilibiliVendor{ + UserID: user.ID, Backend: backend, Cookies: c.Cookies, }) diff --git a/server/handlers/vendors/vendorEmby/list.go b/server/handlers/vendors/vendorEmby/list.go index 3dc1c15..52078a4 100644 --- a/server/handlers/vendors/vendorEmby/list.go +++ b/server/handlers/vendors/vendorEmby/list.go @@ -2,8 +2,10 @@ package vendorEmby import ( "errors" + "fmt" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" json "github.com/json-iterator/go" @@ -13,14 +15,20 @@ import ( "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 { + ServerID string `json:"-"` Path string `json:"path"` Keywords string `json:"keywords"` } func (r *ListReq) Validate() error { + if s := strings.Split(r.Path, "/"); len(s) == 2 { + r.ServerID = s[0] + r.Path = s[1] + } if r.Path == "" { return nil } @@ -54,19 +62,69 @@ func List(ctx *gin.Context) { return } - aucd, err := user.EmbyCache().Get(ctx) + page, size, err := utils.GetPageAndMax(ctx) if err != nil { - if errors.Is(err, db.ErrNotFound("vendor")) { - ctx.JSON(http.StatusBadRequest, model.NewApiErrorStringResp("emby not login")) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err)) + return + } + + if req.ServerID == "" { + if req.Keywords != "" { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("keywords is not supported when not choose server (server id is empty)")) return } - ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + socpes := [](func(*gorm.DB) *gorm.DB){ + db.OrderByCreatedAtAsc, + } + ev, err := db.GetEmbyVendors(user.ID, append(socpes, db.Paginate(page, size))...) + if err != nil { + if errors.Is(err, db.ErrNotFound("vendor")) { + ctx.JSON(http.StatusBadRequest, model.NewApiErrorStringResp("emby not login")) + return + } + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + total, err := db.GetEmbyVendorsCount(user.ID, socpes...) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + 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 } - page, size, err := utils.GetPageAndMax(ctx) + aucd, err := user.EmbyCache().LoadOrStore(ctx, req.ServerID) if err != nil { - ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err)) + if errors.Is(err, db.ErrNotFound("vendor")) { + ctx.JSON(http.StatusBadRequest, model.NewApiErrorStringResp("emby not login")) + return + } + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) return } @@ -84,22 +142,26 @@ func List(ctx *gin.Context) { return } - var resp EmbyFSListResp + var resp EmbyFSListResp = EmbyFSListResp{ + Paths: []*model.Path{ + {}, + }, + } for _, p := range data.Paths { var n = p.Name if p.Path == "1" { - n = "" + n = aucd.Host } resp.Paths = append(resp.Paths, &model.Path{ Name: n, - Path: p.Path, + 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: i.Id, + Path: fmt.Sprintf("%s/%s", aucd.ServerID, i.Id), IsDir: i.IsFolder, }, Type: i.Type, diff --git a/server/handlers/vendors/vendorEmby/login.go b/server/handlers/vendors/vendorEmby/login.go index 7aa41c9..22cf050 100644 --- a/server/handlers/vendors/vendorEmby/login.go +++ b/server/handlers/vendors/vendorEmby/login.go @@ -48,9 +48,10 @@ func Login(ctx *gin.Context) { backend := ctx.Query("backend") cli := vendor.LoadEmbyClient(backend) + var serverID string if req.ApiKey != "" { - _, err := cli.GetSystemInfo(ctx, &emby.SystemInfoReq{ + i, err := cli.GetSystemInfo(ctx, &emby.SystemInfoReq{ Host: req.Host, Token: req.ApiKey, }) @@ -58,6 +59,7 @@ func Login(ctx *gin.Context) { ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err)) return } + serverID = i.Id } else { data, err := cli.Login(ctx, &emby.LoginReq{ Host: req.Host, @@ -69,13 +71,20 @@ func Login(ctx *gin.Context) { return } req.ApiKey = data.Token + serverID = data.ServerId } - _, err := db.CreateOrSaveEmbyVendor(user.ID, &dbModel.EmbyVendor{ - UserID: user.ID, - Host: req.Host, - ApiKey: req.ApiKey, - Backend: backend, + if serverID == "" { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorStringResp("serverID is empty")) + return + } + + _, err := db.CreateOrSaveEmbyVendor(&dbModel.EmbyVendor{ + UserID: user.ID, + ServerID: serverID, + Host: req.Host, + ApiKey: req.ApiKey, + Backend: backend, }) if err != nil { @@ -83,11 +92,12 @@ func Login(ctx *gin.Context) { return } - _, err = user.EmbyCache().Data().Refresh(ctx, func(ctx context.Context, args ...struct{}) (*cache.EmbyUserCacheData, error) { + _, err = user.EmbyCache().StoreOrRefreshWithDynamicFunc(ctx, serverID, func(ctx context.Context, key string, args ...struct{}) (*cache.EmbyUserCacheData, error) { return &cache.EmbyUserCacheData{ - Host: req.Host, - ApiKey: req.ApiKey, - Backend: backend, + Host: req.Host, + ServerID: key, + ApiKey: req.ApiKey, + Backend: backend, }, nil }) if err != nil { @@ -98,19 +108,40 @@ func Login(ctx *gin.Context) { ctx.Status(http.StatusNoContent) } +type LogoutReq struct { + ServerID string `json:"serverId"` +} + +func (r *LogoutReq) Validate() error { + if r.ServerID == "" { + return errors.New("serverId is required") + } + return nil +} + +func (r *LogoutReq) Decode(ctx *gin.Context) error { + return json.NewDecoder(ctx.Request.Body).Decode(r) +} + func Logout(ctx *gin.Context) { user := ctx.MustGet("user").(*op.User) - err := db.DeleteEmbyVendor(user.ID) + var req LogoutReq + 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 := user.EmbyCache().Raw() - user.EmbyCache().Clear() - - go logoutEmby(eucd) + eucd, ok := user.EmbyCache().LoadCache(req.ServerID) + if ok { + go logoutEmby(eucd.Raw()) + } ctx.Status(http.StatusNoContent) } diff --git a/server/handlers/vendors/vendorEmby/me.go b/server/handlers/vendors/vendorEmby/me.go index b2a3f4c..316034c 100644 --- a/server/handlers/vendors/vendorEmby/me.go +++ b/server/handlers/vendors/vendorEmby/me.go @@ -17,14 +17,15 @@ type EmbyMeResp = model.VendorMeResp[*emby.SystemInfoResp] func Me(ctx *gin.Context) { user := ctx.MustGet("user").(*op.User) - eucd, err := user.EmbyCache().Get(ctx) + 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.ErrNotFound("vendor")) { - ctx.JSON(http.StatusOK, model.NewApiDataResp(&EmbyMeResp{ - IsLogin: false, - })) - return - } ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) return } @@ -43,3 +44,37 @@ func Me(ctx *gin.Context) { Info: data, })) } + +type EmbyBindsResp []*struct { + ServerID string `json:"serverID"` + Host string `json:"host"` +} + +func Binds(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.User) + + ev, err := db.GetEmbyVendors(user.ID) + if err != nil { + if errors.Is(err, db.ErrNotFound("vendor")) { + ctx.JSON(http.StatusOK, model.NewApiDataResp(&EmbyMeResp{ + IsLogin: false, + })) + return + } + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + var resp EmbyBindsResp = 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)) +}