diff --git a/internal/cache/alist.go b/internal/cache/alist.go index 1e37331..879b5f1 100644 --- a/internal/cache/alist.go +++ b/internal/cache/alist.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "path" "strings" "time" @@ -80,8 +81,8 @@ func AlistAuthorizationCacheWithConfigInitFunc(ctx context.Context, v *model.Ali type AlistMovieCache = refreshcache.RefreshCache[*AlistMovieCacheData, *AlistMovieCacheFuncArgs] -func NewAlistMovieCache(movie *model.Movie) *AlistMovieCache { - return refreshcache.NewRefreshCache(NewAlistMovieCacheInitFunc(movie), time.Minute*14) +func NewAlistMovieCache(movie *model.Movie, subPath string) *AlistMovieCache { + return refreshcache.NewRefreshCache(NewAlistMovieCacheInitFunc(movie, subPath), time.Minute*14) } type AlistProvider = string @@ -161,7 +162,7 @@ type AlistMovieCacheFuncArgs struct { UserAgent string } -func NewAlistMovieCacheInitFunc(movie *model.Movie) func(ctx context.Context, args ...*AlistMovieCacheFuncArgs) (*AlistMovieCacheData, error) { +func NewAlistMovieCacheInitFunc(movie *model.Movie, subPath string) func(ctx context.Context, args ...*AlistMovieCacheFuncArgs) (*AlistMovieCacheData, error) { return func(ctx context.Context, args ...*AlistMovieCacheFuncArgs) (*AlistMovieCacheData, error) { if len(args) == 0 { return nil, errors.New("need alist user cache") @@ -170,15 +171,26 @@ func NewAlistMovieCacheInitFunc(movie *model.Movie) func(ctx context.Context, ar if userCache == nil { return nil, errors.New("need alist user cache") } + if movie.IsFolder && subPath == "" { + return nil, errors.New("sub path is empty") + } var ( serverID string err error truePath string ) - serverID, truePath, err = model.GetAlistServerIdFromPath(movie.Base.VendorInfo.Alist.Path) + serverID, truePath, err = movie.MovieBase.VendorInfo.Alist.ServerIDAndFilePath() if err != nil { return nil, err } + if movie.IsFolder { + 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 := userCache.LoadOrStore(ctx, serverID) if err != nil { return nil, err @@ -186,12 +198,12 @@ func NewAlistMovieCacheInitFunc(movie *model.Movie) func(ctx context.Context, ar if aucd.Host == "" { return nil, errors.New("not bind alist vendor") } - cli := vendor.LoadAlistClient(movie.Base.VendorInfo.Backend) + cli := vendor.LoadAlistClient(movie.MovieBase.VendorInfo.Backend) fg, err := cli.FsGet(ctx, &alist.FsGetReq{ Host: aucd.Host, Token: aucd.Token, Path: truePath, - Password: movie.Base.VendorInfo.Alist.Password, + Password: movie.MovieBase.VendorInfo.Alist.Password, UserAgent: args[0].UserAgent, }) if err != nil { @@ -214,7 +226,7 @@ func NewAlistMovieCacheInitFunc(movie *model.Movie) func(ctx context.Context, ar Host: aucd.Host, Token: aucd.Token, Path: prefix + related.Name, - Password: movie.Base.VendorInfo.Alist.Password, + Password: movie.MovieBase.VendorInfo.Alist.Password, UserAgent: args[0].UserAgent, }) if err != nil { @@ -248,7 +260,7 @@ func NewAlistMovieCacheInitFunc(movie *model.Movie) func(ctx context.Context, ar Host: aucd.Host, Token: aucd.Token, Path: truePath, - Password: movie.Base.VendorInfo.Alist.Password, + Password: movie.MovieBase.VendorInfo.Alist.Password, Method: "video_preview", }) if err != nil { diff --git a/internal/cache/bilibili.go b/internal/cache/bilibili.go index 801d227..21235ef 100644 --- a/internal/cache/bilibili.go +++ b/internal/cache/bilibili.go @@ -50,9 +50,9 @@ func BilibiliSharedMpdCacheInitFunc(ctx context.Context, movie *model.Movie, arg } else { cookies = vendorInfo.Cookies } - cli := vendor.LoadBilibiliClient(movie.Base.VendorInfo.Backend) + cli := vendor.LoadBilibiliClient(movie.MovieBase.VendorInfo.Backend) var m, hevcM *mpd.MPD - biliInfo := movie.Base.VendorInfo.Bilibili + biliInfo := movie.MovieBase.VendorInfo.Bilibili switch { case biliInfo.Epid != 0: resp, err := cli.GetDashPGCURL(ctx, &bilibili.GetDashPGCURLReq{ @@ -177,9 +177,9 @@ func BilibiliNoSharedMovieCacheInitFunc(ctx context.Context, movie *model.Movie, } else { cookies = vendorInfo.Cookies } - cli := vendor.LoadBilibiliClient(movie.Base.VendorInfo.Backend) + cli := vendor.LoadBilibiliClient(movie.MovieBase.VendorInfo.Backend) var u string - biliInfo := movie.Base.VendorInfo.Bilibili + biliInfo := movie.MovieBase.VendorInfo.Bilibili switch { case biliInfo.Epid != 0: resp, err := cli.GetPGCURL(ctx, &bilibili.GetPGCURLReq{ @@ -239,7 +239,7 @@ func BilibiliSubtitleCacheInitFunc(ctx context.Context, movie *model.Movie, args return nil, errors.New("no bilibili user cache data") } - biliInfo := movie.Base.VendorInfo.Bilibili + biliInfo := movie.MovieBase.VendorInfo.Bilibili if biliInfo.Bvid == "" || biliInfo.Cid == 0 { return nil, errors.New("bvid or cid is empty") } @@ -255,7 +255,7 @@ func BilibiliSubtitleCacheInitFunc(ctx context.Context, movie *model.Movie, args cookies = vendorInfo.Cookies } - cli := vendor.LoadBilibiliClient(movie.Base.VendorInfo.Backend) + cli := vendor.LoadBilibiliClient(movie.MovieBase.VendorInfo.Backend) resp, err := cli.GetSubtitles(ctx, &bilibili.GetSubtitlesReq{ Cookies: utils.HttpCookieToMap(cookies), Bvid: biliInfo.Bvid, @@ -348,9 +348,9 @@ func genBilibiliLiveM3U8ListFile(urls []*bilibili.LiveStream) []byte { } func BilibiliLiveCacheInitFunc(ctx context.Context, movie *model.Movie, args ...struct{}) ([]byte, error) { - cli := vendor.LoadBilibiliClient(movie.Base.VendorInfo.Backend) + cli := vendor.LoadBilibiliClient(movie.MovieBase.VendorInfo.Backend) resp, err := cli.GetLiveStreams(ctx, &bilibili.GetLiveStreamsReq{ - Cid: movie.Base.VendorInfo.Bilibili.Cid, + Cid: movie.MovieBase.VendorInfo.Bilibili.Cid, Hls: true, }) if err != nil { diff --git a/internal/cache/emby.go b/internal/cache/emby.go index 7fea8e1..fc9bbb3 100644 --- a/internal/cache/emby.go +++ b/internal/cache/emby.go @@ -73,19 +73,22 @@ type EmbyMovieCacheData struct { type EmbyMovieCache = refreshcache.RefreshCache[*EmbyMovieCacheData, *EmbyUserCache] -func NewEmbyMovieCache(movie *model.Movie) *EmbyMovieCache { - cache := refreshcache.NewRefreshCache(NewEmbyMovieCacheInitFunc(movie), 0) - cache.SetClearFunc(NewEmbyMovieClearCacheFunc(movie)) +func NewEmbyMovieCache(movie *model.Movie, subPath string) *EmbyMovieCache { + cache := refreshcache.NewRefreshCache(NewEmbyMovieCacheInitFunc(movie, subPath), 0) + cache.SetClearFunc(NewEmbyMovieClearCacheFunc(movie, subPath)) return cache } -func NewEmbyMovieClearCacheFunc(movie *model.Movie) func(ctx context.Context, args ...*EmbyUserCache) error { +func NewEmbyMovieClearCacheFunc(movie *model.Movie, subPath string) func(ctx context.Context, args ...*EmbyUserCache) error { return func(ctx context.Context, args ...*MapCache[*EmbyUserCacheData, struct{}]) error { - if !movie.Base.VendorInfo.Emby.Transcode { + if !movie.MovieBase.VendorInfo.Emby.Transcode { return nil } + if len(args) == 0 { + return errors.New("need emby user cache") + } - serverID, _, err := model.GetEmbyServerIdFromPath(movie.Base.VendorInfo.Emby.Path) + serverID, err := movie.MovieBase.VendorInfo.Emby.ServerID() if err != nil { return err } @@ -115,20 +118,26 @@ func NewEmbyMovieClearCacheFunc(movie *model.Movie) func(ctx context.Context, ar } } -func NewEmbyMovieCacheInitFunc(movie *model.Movie) func(ctx context.Context, args ...*EmbyUserCache) (*EmbyMovieCacheData, error) { +func NewEmbyMovieCacheInitFunc(movie *model.Movie, subPath string) func(ctx context.Context, args ...*EmbyUserCache) (*EmbyMovieCacheData, error) { return func(ctx context.Context, args ...*EmbyUserCache) (*EmbyMovieCacheData, error) { if len(args) == 0 { return nil, errors.New("need emby user cache") } + if movie.IsFolder && subPath == "" { + return nil, errors.New("sub path is empty") + } var ( serverID string err error truePath string ) - serverID, truePath, err = model.GetEmbyServerIdFromPath(movie.Base.VendorInfo.Emby.Path) + serverID, truePath, err = movie.MovieBase.VendorInfo.Emby.ServerIDAndFilePath() if err != nil { return nil, err } + if movie.IsFolder { + truePath = subPath + } aucd, err := args[0].LoadOrStore(ctx, serverID) if err != nil { @@ -156,7 +165,7 @@ func NewEmbyMovieCacheInitFunc(movie *model.Movie) func(ctx context.Context, arg return nil, err } for i, v := range data.MediaSourceInfo { - if movie.Base.VendorInfo.Emby.Transcode && v.TranscodingUrl != "" { + if movie.MovieBase.VendorInfo.Emby.Transcode && v.TranscodingUrl != "" { resp.Sources[i].URL = fmt.Sprintf("%s/emby%s", aucd.Host, v.TranscodingUrl) resp.Sources[i].IsTranscode = true resp.Sources[i].Name = v.Name diff --git a/internal/db/movie.go b/internal/db/movie.go index 070a25c..9462796 100644 --- a/internal/db/movie.go +++ b/internal/db/movie.go @@ -16,10 +16,31 @@ func CreateMovies(movies []*model.Movie) error { }) } -func GetAllMoviesByRoomID(roomID string) []*model.Movie { +func WithParentMovieID(parentMovieID string) func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + if parentMovieID == "" { + return db.Where("base_parent_id IS NULL") + } + return db.Where("base_parent_id = ?", parentMovieID) + } +} + +func GetMoviesByRoomID(roomID string, scopes ...func(*gorm.DB) *gorm.DB) ([]*model.Movie, error) { movies := []*model.Movie{} - db.Where("room_id = ?", roomID).Order("position ASC").Find(&movies) - return movies + err := db.Where("room_id = ?", roomID).Order("position ASC").Scopes(scopes...).Find(&movies).Error + return movies, err +} + +func GetMoviesCountByRoomID(roomID string, scopes ...func(*gorm.DB) *gorm.DB) (int64, error) { + var count int64 + err := db.Model(&model.Movie{}).Where("room_id = ?", roomID).Scopes(scopes...).Count(&count).Error + return count, err +} + +func GetMovieByID(roomID, id string, scopes ...func(*gorm.DB) *gorm.DB) (*model.Movie, error) { + movie := &model.Movie{} + err := db.Where("room_id = ? AND id = ?", roomID, id).Scopes(scopes...).First(movie).Error + return movie, HandleNotFound(err, "room or movie") } func DeleteMovieByID(roomID, id string) error { @@ -37,15 +58,15 @@ func DeleteMoviesByID(roomID string, ids []string) error { }) } -func LoadAndDeleteMovieByID(roomID, id string, columns ...clause.Column) (*model.Movie, error) { +func LoadAndDeleteMovieByID(roomID, id string, columns []clause.Column) (*model.Movie, error) { movie := &model.Movie{} err := db.Unscoped().Clauses(clause.Returning{Columns: columns}).Where("room_id = ? AND id = ?", roomID, id).Delete(movie).Error return movie, HandleNotFound(err, "room or movie") } -func DeleteMoviesByRoomID(roomID string) error { +func DeleteMoviesByRoomID(roomID string, scopes ...func(*gorm.DB) *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error { - err := tx.Where("room_id = ?", roomID).Delete(&model.Movie{}).Error + err := tx.Where("room_id = ?", roomID).Scopes(scopes...).Delete(&model.Movie{}).Error if err != nil { return HandleNotFound(err, "room") } @@ -53,6 +74,10 @@ func DeleteMoviesByRoomID(roomID string) error { }) } +func DeleteMoviesByRoomIDAndParentID(roomID string, parentID string) error { + return DeleteMoviesByRoomID(roomID, WithParentMovieID(parentID)) +} + func LoadAndDeleteMoviesByRoomID(roomID string, columns ...clause.Column) ([]*model.Movie, error) { movies := []*model.Movie{} err := db.Transaction(func(tx *gorm.DB) error { diff --git a/internal/db/update.go b/internal/db/update.go index 87fc8c7..97cda1a 100644 --- a/internal/db/update.go +++ b/internal/db/update.go @@ -15,7 +15,7 @@ type dbVersion struct { Upgrade func(*gorm.DB) error } -const CurrentVersion = "0.0.8" +const CurrentVersion = "0.0.9" var models = []any{ new(model.Setting), @@ -70,6 +70,9 @@ var dbVersions = map[string]dbVersion{ NextVersion: "0.0.8", }, "0.0.8": { + NextVersion: "0.0.9", + }, + "0.0.9": { NextVersion: "", }, } diff --git a/internal/db/user.go b/internal/db/user.go index fad57ac..bf22380 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -66,10 +66,7 @@ func WithRegisteredByProvider(b bool) CreateUserConfig { func WithEmail(email string) CreateUserConfig { return func(u *model.User) { - u.Email = sql.NullString{ - String: email, - Valid: true, - } + u.Email = model.EmptyNullString(email) } } @@ -441,7 +438,7 @@ func UnbindEmail(uid string) error { if err := tx.Where("id = ?", uid).First(&user).Error; err != nil { return HandleNotFound(err, "user") } - if user.Email.String == "" { + if user.Email == "" { return errors.New("user has no email") } if user.RegisteredByEmail { diff --git a/internal/model/movie.go b/internal/model/movie.go index f32ef27..fc3d6cc 100644 --- a/internal/model/movie.go +++ b/internal/model/movie.go @@ -1,6 +1,7 @@ package model import ( + "database/sql/driver" "fmt" "strings" "time" @@ -16,7 +17,8 @@ type Movie struct { Position uint `gorm:"not null" json:"-"` RoomID string `gorm:"not null;index;type:char(32)" json:"-"` CreatorID string `gorm:"index;type:char(32)" json:"creatorId"` - Base BaseMovie `gorm:"embedded;embeddedPrefix:base_" json:"base"` + MovieBase `gorm:"embedded;embeddedPrefix:base_" json:"base"` + Children []*Movie `gorm:"foreignKey:ParentID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"-"` } func (m *Movie) BeforeCreate(tx *gorm.DB) error { @@ -26,17 +28,71 @@ func (m *Movie) BeforeCreate(tx *gorm.DB) error { return nil } -type BaseMovie struct { +func (m *Movie) BeforeSave(tx *gorm.DB) (err error) { + if m.ParentID != "" { + mv := &Movie{} + err = tx.Where("id = ?", m.ParentID).First(mv).Error + if err != nil { + return fmt.Errorf("load parent movie failed: %w", err) + } + if !mv.IsFolder { + return fmt.Errorf("parent is not a folder") + } + if mv.IsDynamicFolder() { + return fmt.Errorf("parent is a dynamic folder, cannot add child") + } + } + return +} + +type MovieBase struct { Url string `gorm:"type:varchar(8192)" json:"url"` - MoreSource map[string]string `gorm:"serializer:fastjson;type:text" json:"moreSource"` + MoreSource map[string]string `gorm:"serializer:fastjson;type:text" json:"moreSource,omitempty"` Name string `gorm:"not null;type:varchar(256)" json:"name"` Live bool `json:"live"` Proxy bool `json:"proxy"` RtmpSource bool `json:"rtmpSource"` Type string `json:"type"` - Headers map[string]string `gorm:"serializer:fastjson;type:text" json:"headers"` - Subtitles map[string]*Subtitle `gorm:"serializer:fastjson;type:text" json:"subtitles"` + Headers map[string]string `gorm:"serializer:fastjson;type:text" json:"headers,omitempty"` + Subtitles map[string]*Subtitle `gorm:"serializer:fastjson;type:text" json:"subtitles,omitempty"` VendorInfo VendorInfo `gorm:"embedded;embeddedPrefix:vendor_info_" json:"vendorInfo,omitempty"` + IsFolder bool `json:"isFolder"` + ParentID EmptyNullString `gorm:"type:char(32)" json:"parentId"` +} + +func (m *MovieBase) IsDynamicFolder() bool { + return m.IsFolder && m.VendorInfo.Vendor != "" +} + +type EmptyNullString string + +func (ns EmptyNullString) String() string { + return string(ns) +} + +// Scan implements the [Scanner] interface. +func (ns *EmptyNullString) Scan(value any) error { + if value == nil { + *ns = "" + return nil + } + switch v := value.(type) { + case []byte: + *ns = EmptyNullString(v) + case string: + *ns = EmptyNullString(v) + default: + return fmt.Errorf("unsupported type: %T", v) + } + return nil +} + +// Value implements the [driver.Valuer] interface. +func (ns EmptyNullString) Value() (driver.Value, error) { + if ns == "" { + return nil, nil + } + return string(ns), nil } type Subtitle struct { @@ -102,6 +158,28 @@ func GetAlistServerIdFromPath(path string) (serverID string, filePath string, er return before, after, nil } +func FormatAlistPath(serverID, filePath string) string { + return fmt.Sprintf("%s/%s", serverID, strings.Trim(filePath, "/")) +} + +func (a *AlistStreamingInfo) SetServerIDAndFilePath(serverID, filePath string) { + a.Path = FormatAlistPath(serverID, filePath) +} + +func (a *AlistStreamingInfo) ServerID() (string, error) { + serverID, _, err := GetAlistServerIdFromPath(a.Path) + return serverID, err +} + +func (a *AlistStreamingInfo) FilePath() (string, error) { + _, filePath, err := GetAlistServerIdFromPath(a.Path) + return filePath, err +} + +func (a *AlistStreamingInfo) ServerIDAndFilePath() (serverID, filePath string, err error) { + return GetAlistServerIdFromPath(a.Path) +} + func (a *AlistStreamingInfo) Validate() error { if a.Path == "" { return fmt.Errorf("path is empty") @@ -148,6 +226,28 @@ func GetEmbyServerIdFromPath(path string) (serverID string, filePath string, err return "", path, fmt.Errorf("path is invalid") } +func FormatEmbyPath(serverID, filePath string) string { + return fmt.Sprintf("%s/%s", serverID, filePath) +} + +func (e *EmbyStreamingInfo) SetServerIDAndFilePath(serverID, filePath string) { + e.Path = FormatEmbyPath(serverID, filePath) +} + +func (e *EmbyStreamingInfo) ServerID() (string, error) { + serverID, _, err := GetEmbyServerIdFromPath(e.Path) + return serverID, err +} + +func (e *EmbyStreamingInfo) FilePath() (string, error) { + _, filePath, err := GetEmbyServerIdFromPath(e.Path) + return filePath, err +} + +func (e *EmbyStreamingInfo) ServerIDAndFilePath() (serverID, filePath string, err error) { + return GetEmbyServerIdFromPath(e.Path) +} + func (e *EmbyStreamingInfo) Validate() error { if e.Path == "" { return fmt.Errorf("path is empty") diff --git a/internal/model/user.go b/internal/model/user.go index e4da480..eeb22f7 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -1,7 +1,6 @@ package model import ( - "database/sql" "fmt" "math/rand" "time" @@ -48,7 +47,7 @@ type User struct { UserProviders []*UserProvider `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Username string `gorm:"not null;uniqueIndex;type:varchar(32)"` HashedPassword []byte `gorm:"not null"` - Email sql.NullString `gorm:"type:varchar(128);uniqueIndex"` + Email EmptyNullString `gorm:"type:varchar(128);uniqueIndex"` Role Role `gorm:"not null;default:2"` RoomMembers []*RoomMember `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Rooms []*Room `gorm:"foreignKey:CreatorID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` diff --git a/internal/op/current.go b/internal/op/current.go index 01523fc..bbc9b34 100644 --- a/internal/op/current.go +++ b/internal/op/current.go @@ -11,9 +11,13 @@ type current struct { } type Current struct { - MovieID string - IsLive bool - Status Status + Movie CurrentMovie + Status Status +} + +type CurrentMovie struct { + ID string + IsLive bool } func newCurrent() *current { @@ -46,12 +50,11 @@ func (c *current) Current() Current { return c.current } -func (c *current) SetMovie(movieID string, isLive, play bool) { +func (c *current) SetMovie(movie CurrentMovie, play bool) { c.lock.Lock() defer c.lock.Unlock() - c.current.MovieID = movieID - c.current.IsLive = isLive + c.current.Movie = movie c.current.SetSeek(0, 0) c.current.Status.Playing = play } @@ -80,7 +83,7 @@ func (c *current) SetSeekRate(seek, rate, timeDiff float64) *Status { } func (c *Current) UpdateStatus() Status { - if c.IsLive { + if c.Movie.IsLive { c.Status.lastUpdate = time.Now() return c.Status } @@ -100,7 +103,7 @@ func (c *Current) setLiveStatus() Status { } func (c *Current) SetStatus(playing bool, seek, rate, timeDiff float64) Status { - if c.IsLive { + if c.Movie.IsLive { return c.setLiveStatus() } c.Status.Playing = playing @@ -115,7 +118,7 @@ func (c *Current) SetStatus(playing bool, seek, rate, timeDiff float64) Status { } func (c *Current) SetSeekRate(seek, rate, timeDiff float64) Status { - if c.IsLive { + if c.Movie.IsLive { return c.setLiveStatus() } if c.Status.Playing { @@ -129,7 +132,7 @@ func (c *Current) SetSeekRate(seek, rate, timeDiff float64) Status { } func (c *Current) SetSeek(seek, timeDiff float64) Status { - if c.IsLive { + if c.Movie.IsLive { return c.setLiveStatus() } if c.Status.Playing { diff --git a/internal/op/movie.go b/internal/op/movie.go index 4556cfc..6649184 100644 --- a/internal/op/movie.go +++ b/internal/op/movie.go @@ -29,29 +29,40 @@ type Movie struct { alistCache atomic.Pointer[cache.AlistMovieCache] bilibiliCache atomic.Pointer[cache.BilibiliMovieCache] embyCache atomic.Pointer[cache.EmbyMovieCache] + subPath string +} + +func (m *Movie) SubPath() string { + return m.subPath } func (m *Movie) ExpireId() uint64 { + if m.IsFolder { + return 0 + } switch { - case m.Movie.Base.VendorInfo.Vendor == model.VendorAlist: + case m.Movie.MovieBase.VendorInfo.Vendor == model.VendorAlist: amcd, _ := m.AlistCache().Raw() if amcd != nil && amcd.Ali != nil { return uint64(m.AlistCache().Last()) } - case m.Movie.Base.Live && m.Movie.Base.VendorInfo.Vendor == model.VendorBilibili: + case m.Movie.MovieBase.Live && m.Movie.MovieBase.VendorInfo.Vendor == model.VendorBilibili: return uint64(m.BilibiliCache().Live.Last()) } return uint64(crc32.ChecksumIEEE([]byte(m.Movie.ID))) } func (m *Movie) CheckExpired(expireId uint64) bool { + if m.IsFolder { + return false + } switch { - case m.Movie.Base.VendorInfo.Vendor == model.VendorAlist: + case m.Movie.MovieBase.VendorInfo.Vendor == model.VendorAlist: amcd, _ := m.AlistCache().Raw() if amcd != nil && amcd.Ali != nil { return time.Now().UnixNano()-int64(expireId) > m.AlistCache().MaxAge() } - case m.Movie.Base.Live && m.Movie.Base.VendorInfo.Vendor == model.VendorBilibili: + case m.Movie.MovieBase.Live && m.Movie.MovieBase.VendorInfo.Vendor == model.VendorBilibili: return time.Now().UnixNano()-int64(expireId) > m.BilibiliCache().Live.MaxAge() } return expireId != m.ExpireId() @@ -83,7 +94,7 @@ func (m *Movie) ClearCache() error { func (m *Movie) AlistCache() *cache.AlistMovieCache { c := m.alistCache.Load() if c == nil { - c = cache.NewAlistMovieCache(m.Movie) + c = cache.NewAlistMovieCache(m.Movie, m.subPath) if !m.alistCache.CompareAndSwap(nil, c) { return m.AlistCache() } @@ -105,7 +116,7 @@ func (m *Movie) BilibiliCache() *cache.BilibiliMovieCache { func (m *Movie) EmbyCache() *cache.EmbyMovieCache { c := m.embyCache.Load() if c == nil { - c = cache.NewEmbyMovieCache(m.Movie) + c = cache.NewEmbyMovieCache(m.Movie, m.subPath) if !m.embyCache.CompareAndSwap(nil, c) { return m.EmbyCache() } @@ -114,6 +125,9 @@ func (m *Movie) EmbyCache() *cache.EmbyMovieCache { } func (m *Movie) Channel() (*rtmps.Channel, error) { + if m.IsFolder { + return nil, errors.New("this is a folder") + } err := m.initChannel() if err != nil { return nil, err @@ -139,10 +153,10 @@ func (m *Movie) compareAndSwapInitChannel() *rtmps.Channel { func (m *Movie) initChannel() error { switch { - case m.Movie.Base.Live && m.Movie.Base.RtmpSource: + case m.Movie.MovieBase.Live && m.Movie.MovieBase.RtmpSource: m.compareAndSwapInitChannel() - case m.Movie.Base.Live && m.Movie.Base.Proxy: - u, err := url.Parse(m.Movie.Base.Url) + case m.Movie.MovieBase.Live && m.Movie.MovieBase.Proxy: + u, err := url.Parse(m.Movie.MovieBase.Url) if err != nil { return err } @@ -159,7 +173,7 @@ func (m *Movie) initChannel() error { return } cli := core.NewConnClient() - if err = cli.Start(m.Movie.Base.Url, av.PLAY); err != nil { + if err = cli.Start(m.Movie.MovieBase.Url, av.PLAY); err != nil { cli.Close() time.Sleep(time.Second) continue @@ -182,11 +196,11 @@ func (m *Movie) initChannel() error { return } r := resty.New().R() - for k, v := range m.Movie.Base.Headers { + for k, v := range m.Movie.MovieBase.Headers { r.SetHeader(k, v) } // r.SetHeader("User-Agent", UserAgent) - resp, err := r.Get(m.Movie.Base.Url) + resp, err := r.Get(m.Movie.MovieBase.Url) if err != nil { time.Sleep(time.Second) continue @@ -207,13 +221,16 @@ func (m *Movie) initChannel() error { } func (movie *Movie) Validate() error { - m := movie.Movie.Base + m := movie.Movie.MovieBase if m.VendorInfo.Vendor != "" { err := movie.validateVendorMovie() if err != nil { return err } } + if movie.IsFolder { + return nil + } switch { case m.RtmpSource && m.Proxy: return errors.New("rtmp source and proxy can't be true at the same time") @@ -274,15 +291,18 @@ func (movie *Movie) Validate() error { } func (movie *Movie) validateVendorMovie() error { - switch movie.Movie.Base.VendorInfo.Vendor { + switch movie.Movie.MovieBase.VendorInfo.Vendor { case model.VendorBilibili: - return movie.Movie.Base.VendorInfo.Bilibili.Validate() + if movie.IsFolder { + return errors.New("bilibili folder not support") + } + return movie.Movie.MovieBase.VendorInfo.Bilibili.Validate() case model.VendorAlist: - return movie.Movie.Base.VendorInfo.Alist.Validate() + return movie.Movie.MovieBase.VendorInfo.Alist.Validate() case model.VendorEmby: - return movie.Movie.Base.VendorInfo.Emby.Validate() + return movie.Movie.MovieBase.VendorInfo.Emby.Validate() default: return fmt.Errorf("vendor not implement validate") @@ -290,6 +310,9 @@ func (movie *Movie) validateVendorMovie() error { } func (m *Movie) Terminate() error { + if m.IsFolder { + return nil + } c := m.channel.Swap(nil) if c != nil { err := c.Close() @@ -300,8 +323,14 @@ func (m *Movie) Terminate() error { return nil } -func (m *Movie) Update(movie *model.BaseMovie) error { - m.Movie.Base = *movie - m.ClearCache() - return m.Terminate() +func (m *Movie) Close() error { + err := m.Terminate() + if err != nil { + return err + } + err = m.ClearCache() + if err != nil { + return err + } + return nil } diff --git a/internal/op/movies.go b/internal/op/movies.go index a721380..59df7b7 100644 --- a/internal/op/movies.go +++ b/internal/op/movies.go @@ -2,44 +2,21 @@ package op import ( "errors" - "sync" "time" "github.com/synctv-org/synctv/internal/db" "github.com/synctv-org/synctv/internal/model" - "github.com/synctv-org/synctv/utils" - "github.com/zijiren233/gencontainer/dllist" + "github.com/zijiren233/gencontainer/rwmap" rtmps "github.com/zijiren233/livelib/server" + "gorm.io/gorm" ) type movies struct { roomID string - lock sync.RWMutex - list dllist.Dllist[*Movie] - once sync.Once -} - -func (m *movies) init() { - m.once.Do(func() { - for _, m2 := range db.GetAllMoviesByRoomID(m.roomID) { - m.list.PushBack(&Movie{ - Movie: m2, - }) - } - }) -} - -func (m *movies) Len() int { - m.init() - m.lock.RLock() - defer m.lock.RUnlock() - return m.list.Len() + cache rwmap.RWMap[string, *Movie] } func (m *movies) AddMovie(mo *model.Movie) error { - m.init() - m.lock.Lock() - defer m.lock.Unlock() mo.Position = uint(time.Now().UnixMilli()) movie := &Movie{ Movie: mo, @@ -55,14 +32,14 @@ func (m *movies) AddMovie(mo *model.Movie) error { return err } - m.list.PushBack(movie) + old, ok := m.cache.Swap(mo.ID, movie) + if ok { + _ = old.Close() + } return nil } func (m *movies) AddMovies(mos []*model.Movie) error { - m.init() - m.lock.Lock() - defer m.lock.Unlock() inited := make([]*Movie, 0, len(mos)) for _, mo := range mos { mo.Position = uint(time.Now().UnixMilli()) @@ -84,7 +61,10 @@ func (m *movies) AddMovies(mos []*model.Movie) error { } for _, mo := range inited { - m.list.PushBack(mo) + old, ok := m.cache.Swap(mo.Movie.ID, mo) + if ok { + _ = old.Close() + } } return nil @@ -101,170 +81,132 @@ func (m *movies) GetChannel(id string) (*rtmps.Channel, error) { return movie.Channel() } -func (m *movies) Update(movieId string, movie *model.BaseMovie) error { - m.init() - m.lock.Lock() - defer m.lock.Unlock() - for e := m.list.Front(); e != nil; e = e.Next() { - if e.Value.Movie.ID == movieId { - err := e.Value.Update(movie) - if err != nil { - return err - } - return db.SaveMovie(e.Value.Movie) - } +func (m *movies) Update(movieId string, movie *model.MovieBase) error { + mv, err := db.GetMovieByID(m.roomID, movieId) + if err != nil { + return err } - return nil -} - -func (m *movies) Clear() error { - m.lock.Lock() - defer m.lock.Unlock() - err := db.DeleteMoviesByRoomID(m.roomID) + mv.MovieBase = *movie + err = db.SaveMovie(mv) if err != nil { return err } - for e := m.list.Front(); e != nil; e = e.Next() { - _ = e.Value.Terminate() + mm, ok := m.cache.LoadOrStore(mv.ID, &Movie{Movie: mv}) + if ok { + _ = mm.Close() } - m.list.Clear() return nil } +func (m *movies) Clear() error { + return m.DeleteMovieByParentID("") +} + func (m *movies) Close() error { - m.lock.Lock() - defer m.lock.Unlock() - for e := m.list.Front(); e != nil; e = e.Next() { - _ = e.Value.Terminate() + m.DeleteMovieAndChiledCache("") + return nil +} + +func (m *movies) DeleteMovieByParentID(parentID string) error { + err := db.DeleteMoviesByRoomIDAndParentID(m.roomID, parentID) + if err != nil { + return err } - m.list.Clear() + m.DeleteMovieAndChiledCache("") return nil } func (m *movies) DeleteMovieByID(id string) error { - m.init() - m.lock.Lock() - defer m.lock.Unlock() - err := db.DeleteMovieByID(m.roomID, id) if err != nil { return err } + m.DeleteMovieAndChiledCache(id) + return nil +} + +func (m *movies) DeleteMovieAndChiledCache(id ...string) { + idm := make(map[model.EmptyNullString]struct{}, len(id)) + for _, id := range id { + idm[model.EmptyNullString(id)] = struct{}{} + } + m.deleteMovieAndChiledCache(idm) +} - for e := m.list.Front(); e != nil; e = e.Next() { - if e.Value.Movie.ID == id { - _ = m.list.Remove(e).Terminate() - return nil +func (m *movies) deleteMovieAndChiledCache(ids map[model.EmptyNullString]struct{}) { + next := make(map[model.EmptyNullString]struct{}) + m.cache.Range(func(key string, value *Movie) bool { + if _, ok := ids[value.ParentID]; ok { + if value.IsFolder { + next[model.EmptyNullString(value.ID)] = struct{}{} + } else { + m.cache.Delete(key) + } + value.Close() } + return true + }) + if len(next) > 0 { + m.deleteMovieAndChiledCache(next) } - return errors.New("movie not found") } func (m *movies) DeleteMoviesByID(ids []string) error { - m.init() - m.lock.Lock() - defer m.lock.Unlock() - err := db.DeleteMoviesByID(m.roomID, ids) if err != nil { return err } - - for _, id := range ids { - for e := m.list.Front(); e != nil; e = e.Next() { - if e.Value.Movie.ID == id { - _ = m.list.Remove(e).Terminate() - break - } - } - } + m.DeleteMovieAndChiledCache(ids...) return nil } func (m *movies) GetMovieByID(id string) (*Movie, error) { - m.lock.RLock() - defer m.lock.RUnlock() - return m.getMovieByID(id) -} - -func (m *movies) getMovieByID(id string) (*Movie, error) { if id == "" { return nil, errors.New("movie id is nil") } - m.init() - for e := m.list.Front(); e != nil; e = e.Next() { - if e.Value.Movie.ID == id { - return e.Value, nil - } + mm, ok := m.cache.Load(id) + if ok { + return mm, nil } - return nil, errors.New("movie not found") -} - -func (m *movies) getMovieElementByID(id string) (*dllist.Element[*Movie], error) { - m.init() - for e := m.list.Front(); e != nil; e = e.Next() { - if e.Value.Movie.ID == id { - return e, nil - } + mv, err := db.GetMovieByID(m.roomID, id) + if err != nil { + return nil, err } - return nil, errors.New("movie not found") + mo := &Movie{Movie: mv} + mm, _ = m.cache.LoadOrStore(mv.ID, mo) + return mm, nil } func (m *movies) SwapMoviePositions(id1, id2 string) error { - m.init() - m.lock.Lock() - defer m.lock.Unlock() + return db.SwapMoviePositions(m.roomID, id1, id2) +} - err := db.SwapMoviePositions(m.roomID, id1, id2) - if err != nil { - return err +func (m *movies) GetMoviesWithPage(page, pageSize int, parentID string) ([]*model.Movie, int64, error) { + scopes := []func(*gorm.DB) *gorm.DB{ + db.WithParentMovieID(parentID), } - - movie1, err := m.getMovieElementByID(id1) + count, err := db.GetMoviesCountByRoomID(m.roomID, append(scopes, db.Paginate(page, pageSize))...) if err != nil { - return err + return nil, 0, err } - - movie2, err := m.getMovieElementByID(id2) + movies, err := db.GetMoviesByRoomID(m.roomID, scopes...) if err != nil { - return err + return nil, 0, err } - - movie1.Value.Movie.Position, movie2.Value.Movie.Position = movie2.Value.Movie.Position, movie1.Value.Movie.Position - - m.list.Swap(movie1, movie2) - return nil + return movies, count, nil } -func (m *movies) GetMoviesWithPage(page, pageSize int, creator string) ([]*Movie, int) { - m.init() - m.lock.RLock() - defer m.lock.RUnlock() - - var total int - if creator != "" { - for e := m.list.Front(); e != nil; e = e.Next() { - if e.Value.Movie.CreatorID == creator { - total++ - } - } - } else { - total = m.list.Len() +// IsParentOf check if parentID is the parent of id +func (m *movies) IsParentOf(parentID, id string) (bool, error) { + mv, err := m.GetMovieByID(id) + if err != nil { + return false, err } - - start, end := utils.GetPageItemsRange(total, page, pageSize) - ms := make([]*Movie, 0, end-start) - i := 0 - for e := m.list.Front(); e != nil; e = e.Next() { - if creator != "" && e.Value.Movie.CreatorID != creator { - continue - } - if i >= start && i < end { - ms = append(ms, e.Value) - } else if i >= end { - return ms, total - } - i++ + if mv.ParentID == "" { + return false, nil + } + if mv.ParentID == model.EmptyNullString(parentID) { + return true, nil } - return ms, total + return m.IsParentOf(string(mv.ParentID), id) } diff --git a/internal/op/room.go b/internal/op/room.go index 2b291a1..a9dce65 100644 --- a/internal/op/room.go +++ b/internal/op/room.go @@ -23,7 +23,7 @@ type Room struct { current *current initOnce utils.Once hub *Hub - movies movies + movies *movies members rwmap.RWMap[string, *model.RoomMember] } @@ -80,9 +80,19 @@ func (r *Room) CheckVersion(version uint32) bool { return atomic.LoadUint32(&r.version) == version } -func (r *Room) UpdateMovie(movieId string, movie *model.BaseMovie) error { - if r.current.current.MovieID == movieId { - return errors.New("cannot update current movie") +func (r *Room) UpdateMovie(movieId string, movie *model.MovieBase) error { + cid := r.current.current.Movie.ID + if cid != "" { + if cid == movieId { + return errors.New("cannot update current movie") + } + ok, err := r.IsParentOf(cid, movieId) + if err != nil { + return fmt.Errorf("check parent failed: %w", err) + } + if ok { + return errors.New("cannot update current movie's parent") + } } return r.movies.Update(movieId, movie) } @@ -329,31 +339,68 @@ func (r *Room) SetPassword(password string) error { return db.SetRoomHashedPassword(r.ID, hashedPassword) } -func (r *Room) GetMoviesCount() int { - return r.movies.Len() +func (r *Room) IsParentOf(movieID, parentID string) (bool, error) { + if parentID == "" { + return true, nil + } + return r.movies.IsParentOf(movieID, parentID) } func (r *Room) DeleteMovieByID(id string) error { - if r.current.current.MovieID == id { - return errors.New("cannot delete current movie") + if id == "" { + return errors.New("movie id is nil") + } + cid := r.current.current.Movie.ID + if cid != "" { + if cid == id { + return errors.New("cannot delete current movie") + } + ok, err := r.IsParentOf(cid, id) + if err != nil { + return fmt.Errorf("check parent failed: %w", err) + } + if ok { + return errors.New("cannot delete current movie's parent") + } } return r.movies.DeleteMovieByID(id) } func (r *Room) DeleteMoviesByID(ids []string) error { - if r.current.current.MovieID != "" { + cid := r.current.current.Movie.ID + if cid != "" { for _, id := range ids { - if id == r.current.current.MovieID { + if id == cid { return errors.New("cannot delete current movie") } + ok, err := r.IsParentOf(cid, id) + if err != nil { + return fmt.Errorf("check parent failed: %w", err) + } + if ok { + return errors.New("cannot delete current movie's parent") + } } } return r.movies.DeleteMoviesByID(ids) } func (r *Room) ClearMovies() error { - _ = r.SetCurrentMovie("", false) - return r.movies.Clear() + return r.ClearMoviesByParentID("") +} + +func (r *Room) ClearMoviesByParentID(parentID string) error { + cid := r.current.current.Movie.ID + if cid != "" { + ok, err := r.IsParentOf(cid, parentID) + if err != nil { + return fmt.Errorf("check parent failed: %w", err) + } + if ok { + return errors.New("cannot delete current movie's parent") + } + } + return r.movies.DeleteMovieByParentID(parentID) } func (r *Room) GetMovieByID(id string) (*Movie, error) { @@ -365,57 +412,62 @@ func (r *Room) Current() *Current { return &c } -func (r *Room) CurrentMovieID() string { - return r.current.current.MovieID +func (r *Room) CurrentMovie() CurrentMovie { + return r.current.current.Movie } var ErrNoCurrentMovie = errors.New("no current movie") -func (r *Room) CurrentMovie() (*Movie, error) { - if r.current.current.MovieID == "" { +func (r *Room) LoadCurrentMovie() (*Movie, error) { + id := r.current.current.Movie.ID + if id == "" { return nil, ErrNoCurrentMovie } - return r.GetMovieByID(r.current.current.MovieID) + return r.GetMovieByID(id) } func (r *Room) CheckCurrentExpired(expireId uint64) (bool, error) { - m, err := r.CurrentMovie() + m, err := r.LoadCurrentMovie() if err != nil { return false, err } return m.CheckExpired(expireId), nil } -func (r *Room) SetCurrentMovie(movieID string, play bool) error { - currentMovie, err := r.CurrentMovie() +func (r *Room) SetCurrentMovie(movieID string, subPath string, play bool) error { + currentMovie, err := r.LoadCurrentMovie() if err != nil { if err != ErrNoCurrentMovie { return err } } else { - err = currentMovie.ClearCache() - if err != nil { - return fmt.Errorf("clear cache failed: %w", err) - } + _ = currentMovie.ClearCache() } if movieID == "" { - r.current.SetMovie("", false, play) + r.current.SetMovie(CurrentMovie{}, false) return nil } m, err := r.GetMovieByID(movieID) if err != nil { return err } - r.current.SetMovie(m.ID, m.Base.Live, play) - return nil + if m.IsFolder && !m.IsDynamicFolder() { + return errors.New("cannot set static folder as current movie") + } + m.subPath = subPath + r.current.SetMovie(CurrentMovie{ + ID: m.ID, + IsLive: m.Live, + }, play) + return m.ClearCache() } func (r *Room) SwapMoviePositions(id1, id2 string) error { return r.movies.SwapMoviePositions(id1, id2) } -func (r *Room) GetMoviesWithPage(page, pageSize int, creator string) ([]*Movie, int) { - return r.movies.GetMoviesWithPage(page, pageSize, creator) +func (r *Room) GetMoviesWithPage(page, pageSize int, parentID string) ([]*model.Movie, int64, error) { + return r.movies.GetMoviesWithPage(page, pageSize, parentID) } func (r *Room) NewClient(user *User, conn *websocket.Conn) (*Client, error) { diff --git a/internal/op/rooms.go b/internal/op/rooms.go index 3d40498..2904aa3 100644 --- a/internal/op/rooms.go +++ b/internal/op/rooms.go @@ -67,9 +67,7 @@ func LoadOrInitRoom(room *model.Room) (*RoomEntry, error) { Room: *room, version: crc32.ChecksumIEEE(room.HashedPassword), current: newCurrent(), - movies: movies{ - roomID: room.ID, - }, + movies: &movies{roomID: room.ID}, }, time.Duration(settings.RoomTTL.Get())*time.Hour) return i, nil } diff --git a/internal/op/user.go b/internal/op/user.go index d79d55a..b3a7186 100644 --- a/internal/op/user.go +++ b/internal/op/user.go @@ -1,7 +1,6 @@ package op import ( - "database/sql" "errors" "hash/crc32" "sync/atomic" @@ -107,7 +106,7 @@ func (u *User) CreateRoom(name, password string, conf ...db.CreateRoomConfig) (* return CreateRoom(name, password, maxCount, append(conf, db.WithCreator(&u.User))...) } -func (u *User) NewMovie(movie *model.BaseMovie) (*model.Movie, error) { +func (u *User) NewMovie(movie *model.MovieBase) (*model.Movie, error) { if movie == nil { return nil, errors.New("movie is nil") } @@ -122,12 +121,12 @@ func (u *User) NewMovie(movie *model.BaseMovie) (*model.Movie, error) { } } return &model.Movie{ - Base: *movie, + MovieBase: *movie, CreatorID: u.ID, }, nil } -func (u *User) AddRoomMovie(room *Room, movie *model.BaseMovie) (*model.Movie, error) { +func (u *User) AddRoomMovie(room *Room, movie *model.MovieBase) (*model.Movie, error) { if !u.HasRoomPermission(room, model.PermissionAddMovie) { return nil, model.ErrNoPermission } @@ -148,7 +147,7 @@ func (u *User) AddRoomMovie(room *Room, movie *model.BaseMovie) (*model.Movie, e }) } -func (u *User) NewMovies(movies []*model.BaseMovie) ([]*model.Movie, error) { +func (u *User) NewMovies(movies []*model.MovieBase) ([]*model.Movie, error) { var ms = make([]*model.Movie, len(movies)) for i, m := range movies { movie, err := u.NewMovie(m) @@ -160,7 +159,7 @@ func (u *User) NewMovies(movies []*model.BaseMovie) ([]*model.Movie, error) { return ms, nil } -func (u *User) AddRoomMovies(room *Room, movies []*model.BaseMovie) ([]*model.Movie, error) { +func (u *User) AddRoomMovies(room *Room, movies []*model.MovieBase) ([]*model.Movie, error) { if !u.HasRoomPermission(room, model.PermissionAddMovie) { return nil, model.ErrNoPermission } @@ -308,7 +307,7 @@ func (u *User) SetUsername(username string) error { return nil } -func (u *User) UpdateRoomMovie(room *Room, movieID string, movie *model.BaseMovie) error { +func (u *User) UpdateRoomMovie(room *Room, movieID string, movie *model.MovieBase) error { if !u.HasRoomPermission(room, model.PermissionEditMovie) { return model.ErrNoPermission } @@ -389,6 +388,23 @@ func (u *User) ClearRoomMovies(room *Room) error { }) } +func (u *User) ClearRoomMoviesByParentID(room *Room, parentID string) error { + if !u.HasRoomPermission(room, model.PermissionDeleteMovie) { + return model.ErrNoPermission + } + err := room.ClearMoviesByParentID(parentID) + if err != nil { + return err + } + return room.Broadcast(&pb.ElementMessage{ + Type: pb.ElementMessageType_MOVIES_CHANGED, + MoviesChanged: &pb.Sender{ + Username: u.Username, + Userid: u.ID, + }, + }) +} + func (u *User) SwapRoomMoviePositions(room *Room, id1, id2 string) error { if !u.HasRoomPermission(room, model.PermissionEditMovie) { return model.ErrNoPermission @@ -406,11 +422,11 @@ func (u *User) SwapRoomMoviePositions(room *Room, id1, id2 string) error { }) } -func (u *User) SetRoomCurrentMovie(room *Room, movieID string, play bool) error { +func (u *User) SetRoomCurrentMovie(room *Room, movieID string, subPath string, play bool) error { if !u.HasRoomPermission(room, model.PermissionSetCurrentMovie) { return model.ErrNoPermission } - err := room.SetCurrentMovie(movieID, play) + err := room.SetCurrentMovie(movieID, subPath, play) if err != nil { return err } @@ -444,10 +460,7 @@ func (u *User) BindEmail(e string) error { if err != nil { return err } - u.Email = sql.NullString{ - String: e, - Valid: true, - } + u.Email = model.EmptyNullString(e) return nil } @@ -456,40 +469,40 @@ func (u *User) UnbindEmail() error { if err != nil { return err } - u.Email = sql.NullString{} + u.Email = "" return nil } var ErrEmailUnbound = errors.New("email unbound") func (u *User) SendTestEmail() error { - if u.Email.String == "" { + if u.Email == "" { return ErrEmailUnbound } - return email.SendTestEmail(u.Username, u.Email.String) + return email.SendTestEmail(u.Username, u.Email.String()) } func (u *User) SendRetrievePasswordCaptchaEmail(host string) error { - if u.Email.String == "" { + if u.Email == "" { return ErrEmailUnbound } - return email.SendRetrievePasswordCaptchaEmail(u.ID, u.Email.String, host) + return email.SendRetrievePasswordCaptchaEmail(u.ID, u.Email.String(), host) } func (u *User) VerifyRetrievePasswordCaptchaEmail(e, captcha string) (bool, error) { - if u.Email.String != e { + if u.Email.String() != e { return false, errors.New("email has changed, please resend the captcha email") } return email.VerifyRetrievePasswordCaptchaEmail(u.ID, e, captcha) } -func (u *User) GetRoomMoviesWithPage(room *Room, page, pageSize int) ([]*Movie, int) { - if u.HasRoomPermission(room, model.PermissionGetMovieList) { - return room.GetMoviesWithPage(page, pageSize, "") +func (u *User) GetRoomMoviesWithPage(room *Room, page, pageSize int, parentID string) ([]*model.Movie, int64, error) { + if !u.HasRoomPermission(room, model.PermissionGetMovieList) { + return nil, 0, model.ErrNoPermission } - return room.GetMoviesWithPage(page, pageSize, u.ID) + return room.GetMoviesWithPage(page, pageSize, parentID) } func (u *User) SetRoomCurrentSeekRate(room *Room, seek, rate, timeDiff float64) (*Status, error) { diff --git a/server/handlers/movie.go b/server/handlers/movie.go index 1a8aa8d..f4a1241 100644 --- a/server/handlers/movie.go +++ b/server/handlers/movie.go @@ -12,6 +12,7 @@ import ( "math/rand" "net/http" "net/url" + "path" "path/filepath" "strconv" "strings" @@ -21,12 +22,16 @@ import ( "github.com/sirupsen/logrus" "github.com/synctv-org/synctv/internal/cache" "github.com/synctv-org/synctv/internal/conf" + "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/rtmp" "github.com/synctv-org/synctv/internal/settings" + "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" + "github.com/synctv-org/vendors/api/emby" uhc "github.com/zijiren233/go-uhc" "github.com/zijiren233/livelib/protocol/hls" "github.com/zijiren233/livelib/protocol/httpflv" @@ -87,59 +92,65 @@ func GetPageItems[T any](ctx *gin.Context, items []T) ([]T, error) { func genMovieInfo( ctx context.Context, user *op.User, - room *op.Room, opMovie *op.Movie, - userAgent string, + userAgent, userToken string, -) (*model.MovieResp, error) { +) (*model.Movie, error) { if opMovie == nil || opMovie.ID == "" { - return &model.MovieResp{}, nil + return &model.Movie{}, nil + } + if opMovie.IsFolder { + if !opMovie.IsDynamicFolder() { + return nil, errors.New("movie is static folder, can't get movie info") + } } var movie = *opMovie.Movie - if movie.Base.VendorInfo.Vendor != "" { + if movie.MovieBase.VendorInfo.Vendor != "" { vendorMovie, err := genVendorMovie(ctx, user, opMovie, userAgent, userToken) if err != nil { return nil, err } movie = *vendorMovie - } else if movie.Base.RtmpSource || movie.Base.Live && movie.Base.Proxy { - switch movie.Base.Type { + } else if movie.MovieBase.RtmpSource || movie.MovieBase.Live && movie.MovieBase.Proxy { + switch movie.MovieBase.Type { case "m3u8": - movie.Base.Url = fmt.Sprintf("/api/movie/live/hls/list/%s.m3u8?token=%s", movie.ID, userToken) + movie.MovieBase.Url = fmt.Sprintf("/api/movie/live/hls/list/%s.m3u8?token=%s", movie.ID, userToken) case "flv": - movie.Base.Url = fmt.Sprintf("/api/movie/live/flv/%s.flv?token=%s", movie.ID, userToken) + movie.MovieBase.Url = fmt.Sprintf("/api/movie/live/flv/%s.flv?token=%s", movie.ID, userToken) default: return nil, errors.New("not support live movie type") } - movie.Base.Headers = nil - } else if movie.Base.Proxy { - movie.Base.Url = fmt.Sprintf("/api/movie/proxy/%s/%s?token=%s", movie.RoomID, movie.ID, userToken) - movie.Base.Headers = nil + movie.MovieBase.Headers = nil + } else if movie.MovieBase.Proxy { + movie.MovieBase.Url = fmt.Sprintf("/api/movie/proxy/%s/%s?token=%s", movie.RoomID, movie.ID, userToken) + movie.MovieBase.Headers = nil } - if movie.Base.Type == "" && movie.Base.Url != "" { - movie.Base.Type = utils.GetUrlExtension(movie.Base.Url) + if movie.MovieBase.Type == "" && movie.MovieBase.Url != "" { + movie.MovieBase.Type = utils.GetUrlExtension(movie.MovieBase.Url) } - resp := &model.MovieResp{ + resp := &model.Movie{ Id: movie.ID, CreatedAt: movie.CreatedAt.UnixMilli(), - Base: movie.Base, + Base: movie.MovieBase, Creator: op.GetUserName(movie.CreatorID), CreatorId: movie.CreatorID, + SubPath: opMovie.SubPath(), } return resp, nil } -func genCurrentRespWithCurrent(ctx context.Context, user *op.User, room *op.Room, current *op.Current, userAgent string, userToken string) (*model.CurrentMovieResp, error) { - if current.MovieID == "" { +func genCurrentRespWithCurrent(ctx context.Context, user *op.User, room *op.Room, userAgent string, userToken string) (*model.CurrentMovieResp, error) { + current := room.Current() + if current.Movie.ID == "" { return &model.CurrentMovieResp{ - Movie: &model.MovieResp{}, + Movie: &model.Movie{}, }, nil } - opMovie, err := room.GetMovieByID(current.MovieID) + opMovie, err := room.GetMovieByID(current.Movie.ID) if err != nil { return nil, fmt.Errorf("get current movie error: %w", err) } - mr, err := genMovieInfo(ctx, user, room, opMovie, userAgent, userToken) + mr, err := genMovieInfo(ctx, user, opMovie, userAgent, userToken) if err != nil { return nil, fmt.Errorf("gen current movie info error: %w", err) } @@ -156,7 +167,7 @@ func CurrentMovie(ctx *gin.Context) { user := ctx.MustGet("user").(*op.UserEntry).Value() log := ctx.MustGet("log").(*logrus.Entry) - currentResp, err := genCurrentRespWithCurrent(ctx, user, room, room.Current(), ctx.GetHeader("User-Agent"), ctx.MustGet("token").(string)) + currentResp, err := genCurrentRespWithCurrent(ctx, user, room, ctx.GetHeader("User-Agent"), ctx.MustGet("token").(string)) if err != nil { log.Errorf("gen current resp error: %v", err) ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) @@ -171,6 +182,17 @@ func Movies(ctx *gin.Context) { user := ctx.MustGet("user").(*op.UserEntry).Value() log := ctx.MustGet("log").(*logrus.Entry) + if !user.HasRoomPermission(room, dbModel.PermissionGetMovieList) { + ctx.AbortWithStatusJSON(http.StatusForbidden, model.NewApiErrorResp(dbModel.ErrNoPermission)) + return + } + + id := ctx.Query("id") + if len(id) != 0 && len(id) != 32 { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("id length must be 0 or 32")) + return + } + page, max, err := utils.GetPageAndMax(ctx) if err != nil { log.Errorf("get page and max error: %v", err) @@ -178,26 +200,223 @@ func Movies(ctx *gin.Context) { return } - m, total := user.GetRoomMoviesWithPage(room, int(page), int(max)) + if id != "" { + mv, err := room.GetMovieByID(id) + if err != nil { + log.Errorf("get room movie by id error: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err)) + return + } + if !mv.IsFolder { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("parent id is not folder")) + return + } + if mv.IsDynamicFolder() { + resp, err := listVendorDynamicMovie(ctx, user, room, mv.Movie, ctx.Query("subPath"), page, max) + if err != nil { + log.Errorf("vendor dynamic movie list error: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + ctx.JSON(http.StatusOK, model.NewApiDataResp(resp)) + return + } + } + + m, total, err := user.GetRoomMoviesWithPage(room, page, max, id) + if err != nil { + log.Errorf("get room movies with page error: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + paths, err := getParentMoviePath(room, id) + if err != nil { + log.Errorf("get parent movie path error: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + resp := &model.MoviesResp{ + Total: total, + Movies: make([]*model.Movie, len(m)), + Paths: paths, + } - mresp := make([]*model.MovieResp, len(m)) for i, v := range m { - mresp[i] = &model.MovieResp{ - Id: v.Movie.ID, - Base: v.Movie.Base, - Creator: op.GetUserName(v.Movie.CreatorID), + resp.Movies[i] = &model.Movie{ + Id: v.ID, + CreatedAt: v.CreatedAt.UnixMilli(), + Base: v.MovieBase, + Creator: op.GetUserName(v.CreatorID), + CreatorId: v.CreatorID, } // hide url and headers when proxy - if user.ID != v.Movie.CreatorID && v.Movie.Base.Proxy { - mresp[i].Base.Url = "" - mresp[i].Base.Headers = nil + if user.ID != v.CreatorID && v.MovieBase.Proxy { + resp.Movies[i].Base.Url = "" + resp.Movies[i].Base.Headers = nil } } - ctx.JSON(http.StatusOK, model.NewApiDataResp(gin.H{ - "total": total, - "movies": mresp, - })) + ctx.JSON(http.StatusOK, model.NewApiDataResp(resp)) +} + +func getParentMoviePath(room *op.Room, id string) ([]*model.MoviePath, error) { + paths := []*model.MoviePath{ + { + Name: "Home", + ID: "", + }, + } + if id == "" { + return paths, nil + } + for id != "" { + p, err := room.GetMovieByID(id) + if err != nil { + return nil, fmt.Errorf("get movie by id error: %w", err) + } + paths = append(paths, &model.MoviePath{ + Name: p.MovieBase.Name, + ID: p.ID, + }) + id = p.ParentID.String() + } + return paths, nil +} + +func listVendorDynamicMovie(ctx context.Context, reqUser *op.User, room *op.Room, movie *dbModel.Movie, subPath string, page, max int) (*model.MoviesResp, error) { + if reqUser.ID != movie.CreatorID { + return nil, fmt.Errorf("list vendor dynamic folder error: %w", dbModel.ErrNoPermission) + } + // creatorE, err := op.LoadOrInitUserByID(movie.CreatorID) + // if err != nil { + // return nil, err + // } + user := reqUser + + paths, err := getParentMoviePath(room, movie.ID) + if err != nil { + return nil, fmt.Errorf("get parent movie path error: %w", err) + } + resp := &model.MoviesResp{ + Paths: paths, + Dynamic: true, + } + + switch movie.MovieBase.VendorInfo.Vendor { + case dbModel.VendorAlist: + serverID, truePath, err := 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, fmt.Errorf("sub path is not in parent path") + } + truePath = newPath + aucd, err := user.AlistCache().LoadOrStore(ctx, serverID) + if err != nil { + if errors.Is(err, db.ErrNotFound("vendor")) { + return nil, errors.New("alist server not found") + } + return nil, err + } + var cli = vendor.LoadAlistClient(movie.VendorInfo.Backend) + data, err := cli.FsList(ctx, &alist.FsListReq{ + Token: aucd.Token, + Password: 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: movie.ID, + CreatedAt: movie.CreatedAt.UnixMilli(), + Creator: op.GetUserName(movie.CreatorID), + CreatorId: movie.CreatorID, + SubPath: fmt.Sprintf("/%s", strings.Trim(fmt.Sprintf("%s/%s", subPath, flr.Name), "/")), + Base: dbModel.MovieBase{ + Name: flr.Name, + IsFolder: flr.IsDir, + ParentID: dbModel.EmptyNullString(movie.ID), + VendorInfo: dbModel.VendorInfo{ + Vendor: dbModel.VendorAlist, + Backend: movie.VendorInfo.Backend, + Alist: &dbModel.AlistStreamingInfo{ + Path: dbModel.FormatAlistPath(serverID, fmt.Sprintf("/%s", strings.Trim(fmt.Sprintf("%s/%s", truePath, flr.Name), "/"))), + }, + }, + }, + } + } + resp.Paths = model.GenDefaultSubPaths(subPath, true, resp.Paths...) + + case dbModel.VendorEmby: + serverID, truePath, err := 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.ErrNotFound("vendor")) { + return nil, errors.New("emby server not found") + } + return nil, err + } + var cli = vendor.LoadEmbyClient(movie.VendorInfo.Backend) + data, err := cli.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: movie.ID, + CreatedAt: movie.CreatedAt.UnixMilli(), + Creator: op.GetUserName(movie.CreatorID), + CreatorId: movie.CreatorID, + SubPath: flr.Id, + Base: dbModel.MovieBase{ + Name: flr.Name, + IsFolder: flr.IsFolder, + ParentID: dbModel.EmptyNullString(movie.ID), + VendorInfo: dbModel.VendorInfo{ + Vendor: dbModel.VendorEmby, + Backend: movie.VendorInfo.Backend, + Emby: &dbModel.EmbyStreamingInfo{ + Path: dbModel.FormatEmbyPath(serverID, flr.Id), + }, + }, + }, + } + } + + default: + return nil, fmt.Errorf("%v vendor not implement list dynamic movie", movie.MovieBase.VendorInfo.Vendor) + } + + return resp, nil } func PushMovie(ctx *gin.Context) { @@ -212,7 +431,7 @@ func PushMovie(ctx *gin.Context) { return } - m, err := user.AddRoomMovie(room, (*dbModel.BaseMovie)(&req)) + m, err := user.AddRoomMovie(room, (*dbModel.MovieBase)(&req)) if err != nil { log.Errorf("push movie error: %v", err) if errors.Is(err, dbModel.ErrNoPermission) { @@ -242,11 +461,10 @@ func PushMovies(ctx *gin.Context) { return } - var ms []*dbModel.BaseMovie = make([]*dbModel.BaseMovie, len(req)) + var ms []*dbModel.MovieBase = make([]*dbModel.MovieBase, len(req)) for i, v := range req { - m := (*dbModel.BaseMovie)(v) - ms[i] = m + ms[i] = (*dbModel.MovieBase)(v) } m, err := user.AddRoomMovies(room, ms) @@ -304,7 +522,7 @@ func NewPublishKey(ctx *gin.Context) { return } - if !movie.Movie.Base.RtmpSource { + if !movie.Movie.MovieBase.RtmpSource { log.Errorf("new publish key error: %v", "only rtmp source movie can get publish key") ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("only live movie can get publish key")) return @@ -344,7 +562,7 @@ func EditMovie(ctx *gin.Context) { return } - if err := user.UpdateRoomMovie(room, req.Id, (*dbModel.BaseMovie)(&req.PushMovieReq)); err != nil { + if err := user.UpdateRoomMovie(room, req.Id, (*dbModel.MovieBase)(&req.PushMovieReq)); err != nil { log.Errorf("edit movie error: %v", err) if errors.Is(err, dbModel.ErrNoPermission) { ctx.AbortWithStatusJSON( @@ -397,7 +615,13 @@ func ClearMovies(ctx *gin.Context) { room := ctx.MustGet("room").(*op.RoomEntry).Value() user := ctx.MustGet("user").(*op.UserEntry).Value() - if err := user.ClearRoomMovies(room); err != nil { + var req model.ClearMoviesReq + if err := model.Decode(ctx, &req); err != nil { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err)) + return + } + + if err := user.ClearRoomMoviesByParentID(room, req.ParentId); err != nil { if errors.Is(err, dbModel.ErrNoPermission) { ctx.AbortWithStatusJSON( http.StatusForbidden, @@ -437,14 +661,14 @@ func ChangeCurrentMovie(ctx *gin.Context) { user := ctx.MustGet("user").(*op.UserEntry).Value() log := ctx.MustGet("log").(*logrus.Entry) - req := model.IdCanEmptyReq{} + req := model.SetRoomCurrentMovieReq{} err := model.Decode(ctx, &req) if err != nil { ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err)) return } - err = user.SetRoomCurrentMovie(room, req.Id, req.Id != "") + err = user.SetRoomCurrentMovie(room, req.Id, req.SubPath, true) if err != nil { log.Errorf("change current movie error: %v", err) if errors.Is(err, dbModel.ErrNoPermission) { @@ -492,22 +716,22 @@ func ProxyMovie(ctx *gin.Context) { return } - if m.Movie.Base.VendorInfo.Vendor != "" { + if m.Movie.MovieBase.VendorInfo.Vendor != "" { proxyVendorMovie(ctx, m) return } - if !m.Movie.Base.Proxy || m.Movie.Base.Live || m.Movie.Base.RtmpSource { + if !m.Movie.MovieBase.Proxy || m.Movie.MovieBase.Live || m.Movie.MovieBase.RtmpSource { ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("not support movie proxy")) return } - switch m.Movie.Base.Type { + switch m.Movie.MovieBase.Type { case "mpd": // TODO: cache mpd file fallthrough default: - err = proxyURL(ctx, m.Movie.Base.Url, m.Movie.Base.Headers) + err = proxyURL(ctx, m.Movie.MovieBase.Url, m.Movie.MovieBase.Headers) if err != nil { log.Errorf("proxy movie error: %v", err) return @@ -614,11 +838,11 @@ func JoinLive(ctx *gin.Context) { ctx.AbortWithStatusJSON(http.StatusNotFound, model.NewApiErrorResp(err)) return } - if m.Movie.Base.RtmpSource && !conf.Conf.Server.Rtmp.Enable { + if m.Movie.MovieBase.RtmpSource && !conf.Conf.Server.Rtmp.Enable { log.Error("join live error: rtmp is not enabled") ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("rtmp is not enabled")) return - } else if m.Movie.Base.Live && !settings.LiveProxy.Get() { + } else if m.Movie.MovieBase.Live && !settings.LiveProxy.Get() { log.Error("join live error: live proxy is not enabled") ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("live proxy is not enabled")) return @@ -632,7 +856,7 @@ func JoinLive(ctx *gin.Context) { joinType := ctx.DefaultQuery("type", "auto") if joinType == "auto" { - joinType = m.Movie.Base.Type + joinType = m.Movie.MovieBase.Type } switch joinType { case "flv": @@ -678,11 +902,11 @@ func JoinFlvLive(ctx *gin.Context) { ctx.AbortWithStatusJSON(http.StatusNotFound, model.NewApiErrorResp(err)) return } - if m.Movie.Base.RtmpSource && !conf.Conf.Server.Rtmp.Enable { + if m.Movie.MovieBase.RtmpSource && !conf.Conf.Server.Rtmp.Enable { log.Error("join flv live error: rtmp is not enabled") ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("rtmp is not enabled")) return - } else if m.Movie.Base.Live && !settings.LiveProxy.Get() { + } else if m.Movie.MovieBase.Live && !settings.LiveProxy.Get() { log.Error("join flv live error: live proxy is not enabled") ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("live proxy is not enabled")) return @@ -718,11 +942,11 @@ func JoinHlsLive(ctx *gin.Context) { ctx.AbortWithStatusJSON(http.StatusNotFound, model.NewApiErrorResp(err)) return } - if m.Movie.Base.RtmpSource && !conf.Conf.Server.Rtmp.Enable { + if m.Movie.MovieBase.RtmpSource && !conf.Conf.Server.Rtmp.Enable { log.Error("join hls live error: rtmp is not enabled") ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("rtmp is not enabled")) return - } else if m.Movie.Base.Live && !settings.LiveProxy.Get() { + } else if m.Movie.MovieBase.Live && !settings.LiveProxy.Get() { log.Error("join hls live error: live proxy is not enabled") ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("live proxy is not enabled")) return @@ -768,11 +992,11 @@ func ServeHlsLive(ctx *gin.Context) { ctx.AbortWithStatusJSON(http.StatusNotFound, model.NewApiErrorResp(err)) return } - if m.Movie.Base.RtmpSource && !conf.Conf.Server.Rtmp.Enable { + if m.Movie.MovieBase.RtmpSource && !conf.Conf.Server.Rtmp.Enable { log.Error("serve hls live error: rtmp is not enabled") ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("rtmp is not enabled")) return - } else if m.Movie.Base.Live && !settings.LiveProxy.Get() { + } else if m.Movie.MovieBase.Live && !settings.LiveProxy.Get() { log.Error("serve hls live error: live proxy is not enabled") ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("live proxy is not enabled")) return @@ -833,9 +1057,9 @@ func ServeHlsLive(ctx *gin.Context) { func proxyVendorMovie(ctx *gin.Context, movie *op.Movie) { log := ctx.MustGet("log").(*logrus.Entry) - switch movie.Movie.Base.VendorInfo.Vendor { + switch movie.Movie.MovieBase.VendorInfo.Vendor { case dbModel.VendorBilibili: - if movie.Base.Live { + if movie.MovieBase.Live { data, err := movie.BilibiliCache().Live.Get(ctx) if err != nil { log.Errorf("proxy vendor movie error: %v", err) @@ -853,7 +1077,7 @@ func proxyVendorMovie(ctx *gin.Context, movie *op.Movie) { t := ctx.Query("t") switch t { case "", "hevc": - if !movie.Movie.Base.Proxy { + if !movie.Movie.MovieBase.Proxy { log.Errorf("proxy vendor movie error: %v", "not support movie proxy") ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("not support movie proxy")) return @@ -901,7 +1125,7 @@ func proxyVendorMovie(ctx *gin.Context, movie *op.Movie) { ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("stream id out of range")) return } - headers := maps.Clone(movie.Movie.Base.Headers) + headers := maps.Clone(movie.Movie.MovieBase.Headers) if headers == nil { headers = map[string]string{ "Referer": "https://www.bilibili.com", @@ -1006,7 +1230,7 @@ func proxyVendorMovie(ctx *gin.Context, movie *op.Movie) { case cache.AlistProvider115: fallthrough default: - if !movie.Movie.Base.Proxy { + if !movie.Movie.MovieBase.Proxy { log.Errorf("proxy vendor movie error: %v", "not support movie proxy") ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("not support movie proxy")) return @@ -1024,7 +1248,7 @@ func proxyVendorMovie(ctx *gin.Context, movie *op.Movie) { t := ctx.Query("t") switch t { case "": - if !movie.Movie.Base.Proxy { + if !movie.Movie.MovieBase.Proxy { log.Errorf("proxy vendor movie error: %v", "not support movie proxy") ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("not support movie proxy")) return @@ -1126,20 +1350,24 @@ func proxyVendorMovie(ctx *gin.Context, movie *op.Movie) { } // user is the api requester -func genVendorMovie(ctx context.Context, user *op.User, opMovie *op.Movie, userAgent string, userToken string) (*dbModel.Movie, error) { +func genVendorMovie(ctx context.Context, user *op.User, opMovie *op.Movie, userAgent, userToken string) (*dbModel.Movie, error) { movie := *opMovie.Movie var err error - switch movie.Base.VendorInfo.Vendor { + switch movie.MovieBase.VendorInfo.Vendor { case dbModel.VendorBilibili: + if movie.IsFolder { + return nil, fmt.Errorf("bilibili folder not support") + } + bmc := opMovie.BilibiliCache() - if movie.Base.Live { - movie.Base.Url = fmt.Sprintf("/api/movie/proxy/%s/%s?token=%s", movie.RoomID, movie.ID, userToken) - movie.Base.Type = "m3u8" + if movie.MovieBase.Live { + movie.MovieBase.Url = fmt.Sprintf("/api/movie/proxy/%s/%s?token=%s", movie.RoomID, movie.ID, userToken) + movie.MovieBase.Type = "m3u8" return &movie, nil } else { - if !movie.Base.Proxy { + if !movie.MovieBase.Proxy { var s string - if movie.Base.VendorInfo.Bilibili.Shared { + if movie.MovieBase.VendorInfo.Bilibili.Shared { var u *op.UserEntry u, err = op.LoadOrInitUserByID(movie.CreatorID) if err != nil { @@ -1153,11 +1381,11 @@ func genVendorMovie(ctx context.Context, user *op.User, opMovie *op.Movie, userA return nil, err } - movie.Base.Url = s + movie.MovieBase.Url = s } else { - movie.Base.Url = fmt.Sprintf("/api/movie/proxy/%s/%s?token=%s", movie.RoomID, movie.ID, userToken) - movie.Base.Type = "mpd" - movie.Base.MoreSource = map[string]string{ + movie.MovieBase.Url = fmt.Sprintf("/api/movie/proxy/%s/%s?token=%s", movie.RoomID, movie.ID, userToken) + movie.MovieBase.Type = "mpd" + movie.MovieBase.MoreSource = map[string]string{ "hevc": fmt.Sprintf("/api/movie/proxy/%s/%s?token=%s&t=hevc", movie.RoomID, movie.ID, userToken), } } @@ -1166,10 +1394,10 @@ func genVendorMovie(ctx context.Context, user *op.User, opMovie *op.Movie, userA return nil, err } for k := range srt { - if movie.Base.Subtitles == nil { - movie.Base.Subtitles = make(map[string]*dbModel.Subtitle, len(srt)) + if movie.MovieBase.Subtitles == nil { + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(srt)) } - movie.Base.Subtitles[k] = &dbModel.Subtitle{ + movie.MovieBase.Subtitles[k] = &dbModel.Subtitle{ URL: fmt.Sprintf("/api/movie/proxy/%s/%s?t=subtitle&n=%s&token=%s", movie.RoomID, movie.ID, k, userToken), Type: "srt", } @@ -1192,10 +1420,10 @@ func genVendorMovie(ctx context.Context, user *op.User, opMovie *op.Movie, userA } for _, subt := range data.Subtitles { - if movie.Base.Subtitles == nil { - movie.Base.Subtitles = make(map[string]*dbModel.Subtitle, len(data.Subtitles)) + if movie.MovieBase.Subtitles == nil { + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(data.Subtitles)) } - movie.Base.Subtitles[subt.Name] = &dbModel.Subtitle{ + movie.MovieBase.Subtitles[subt.Name] = &dbModel.Subtitle{ URL: subt.URL, Type: subt.Type, } @@ -1203,23 +1431,23 @@ func genVendorMovie(ctx context.Context, user *op.User, opMovie *op.Movie, userA switch data.Provider { case cache.AlistProviderAli: - movie.Base.Url = fmt.Sprintf("/api/movie/proxy/%s/%s?token=%s", movie.RoomID, movie.ID, userToken) - movie.Base.Type = "m3u8" + movie.MovieBase.Url = fmt.Sprintf("/api/movie/proxy/%s/%s?token=%s", movie.RoomID, movie.ID, userToken) + movie.MovieBase.Type = "m3u8" for i, subt := range data.Subtitles { - if movie.Base.Subtitles == nil { - movie.Base.Subtitles = make(map[string]*dbModel.Subtitle, len(data.Subtitles)) + if movie.MovieBase.Subtitles == nil { + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(data.Subtitles)) } - movie.Base.Subtitles[subt.Name] = &dbModel.Subtitle{ + movie.MovieBase.Subtitles[subt.Name] = &dbModel.Subtitle{ URL: fmt.Sprintf("/api/movie/proxy/%s/%s?t=subtitle&id=%d&token=%s", movie.RoomID, movie.ID, i, userToken), Type: subt.Type, } } case cache.AlistProvider115: - if movie.Base.Proxy { - movie.Base.Url = fmt.Sprintf("/api/movie/proxy/%s/%s?token=%s", movie.RoomID, movie.ID, userToken) - movie.Base.Type = utils.GetUrlExtension(data.URL) + if movie.MovieBase.Proxy { + movie.MovieBase.Url = fmt.Sprintf("/api/movie/proxy/%s/%s?token=%s", movie.RoomID, movie.ID, userToken) + movie.MovieBase.Type = utils.GetUrlExtension(data.URL) // TODO: proxy subtitle } else { @@ -1230,10 +1458,10 @@ func genVendorMovie(ctx context.Context, user *op.User, opMovie *op.Movie, userA if err != nil { return nil, fmt.Errorf("refresh 115 movie cache error: %w", err) } - movie.Base.Url = data.URL - movie.Base.Subtitles = make(map[string]*dbModel.Subtitle, len(data.Subtitles)) + movie.MovieBase.Url = data.URL + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(data.Subtitles)) for _, subt := range data.Subtitles { - movie.Base.Subtitles[subt.Name] = &dbModel.Subtitle{ + movie.MovieBase.Subtitles[subt.Name] = &dbModel.Subtitle{ URL: subt.URL, Type: subt.Type, } @@ -1241,15 +1469,15 @@ func genVendorMovie(ctx context.Context, user *op.User, opMovie *op.Movie, userA } default: - if !movie.Base.Proxy { - movie.Base.Url = data.URL + if !movie.MovieBase.Proxy { + movie.MovieBase.Url = data.URL } else { - movie.Base.Url = fmt.Sprintf("/api/movie/proxy/%s/%s?token=%s", movie.RoomID, movie.ID, userToken) - movie.Base.Type = utils.GetUrlExtension(data.URL) + movie.MovieBase.Url = fmt.Sprintf("/api/movie/proxy/%s/%s?token=%s", movie.RoomID, movie.ID, userToken) + movie.MovieBase.Type = utils.GetUrlExtension(data.URL) } } - movie.Base.VendorInfo.Alist.Password = "" + movie.MovieBase.VendorInfo.Alist.Password = "" return &movie, nil case dbModel.VendorEmby: @@ -1262,31 +1490,31 @@ func genVendorMovie(ctx context.Context, user *op.User, opMovie *op.Movie, userA return nil, err } - if !movie.Base.Proxy { + if !movie.MovieBase.Proxy { if len(data.Sources) == 0 { return nil, errors.New("no source") } - movie.Base.Url = data.Sources[0].URL + movie.MovieBase.Url = data.Sources[0].URL for _, s := range data.Sources[0].Subtitles { - if movie.Base.Subtitles == nil { - movie.Base.Subtitles = make(map[string]*dbModel.Subtitle, len(data.Sources[0].Subtitles)) + if movie.MovieBase.Subtitles == nil { + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(data.Sources[0].Subtitles)) } - movie.Base.Subtitles[s.Name] = &dbModel.Subtitle{ + movie.MovieBase.Subtitles[s.Name] = &dbModel.Subtitle{ URL: s.URL, Type: s.Type, } } for _, s := range data.Sources[1:] { - if movie.Base.MoreSource == nil { - movie.Base.MoreSource = make(map[string]string, len(data.Sources)-1) + if movie.MovieBase.MoreSource == nil { + movie.MovieBase.MoreSource = make(map[string]string, len(data.Sources)-1) } - movie.Base.MoreSource[s.Name] = s.URL + movie.MovieBase.MoreSource[s.Name] = s.URL for _, subt := range s.Subtitles { - if movie.Base.Subtitles == nil { - movie.Base.Subtitles = make(map[string]*dbModel.Subtitle, len(s.Subtitles)) + if movie.MovieBase.Subtitles == nil { + movie.MovieBase.Subtitles = make(map[string]*dbModel.Subtitle, len(s.Subtitles)) } - movie.Base.Subtitles[subt.Name] = &dbModel.Subtitle{ + movie.MovieBase.Subtitles[subt.Name] = &dbModel.Subtitle{ URL: subt.URL, Type: subt.Type, } @@ -1298,7 +1526,7 @@ func genVendorMovie(ctx context.Context, user *op.User, opMovie *op.Movie, userA if si != len(data.Sources)-1 { continue } - if movie.Base.Url == "" { + if movie.MovieBase.Url == "" { return nil, errors.New("no source") } } @@ -1314,15 +1542,15 @@ func genVendorMovie(ctx context.Context, user *op.User, opMovie *op.Movie, userA Path: rawPath, RawQuery: rawQuery.Encode(), } - movie.Base.Url = u.String() - movie.Base.Type = utils.GetUrlExtension(es.URL) + 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.Base.Subtitles == nil { - movie.Base.Subtitles = make(map[string]*dbModel.Subtitle, len(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") @@ -1333,7 +1561,7 @@ func genVendorMovie(ctx context.Context, user *op.User, opMovie *op.Movie, userA Path: rawPath, RawQuery: rawQuery.Encode(), } - movie.Base.Subtitles[s.Name] = &dbModel.Subtitle{ + movie.MovieBase.Subtitles[s.Name] = &dbModel.Subtitle{ URL: u.String(), Type: s.Type, } diff --git a/server/handlers/user.go b/server/handlers/user.go index 249356c..e9a0f24 100644 --- a/server/handlers/user.go +++ b/server/handlers/user.go @@ -32,7 +32,7 @@ func Me(ctx *gin.Context) { Username: user.Username, Role: user.Role, CreatedAt: user.CreatedAt.UnixMilli(), - Email: user.Email.String, + Email: user.Email.String(), })) } @@ -304,7 +304,7 @@ func SendUserBindEmailCaptcha(ctx *gin.Context) { return } - if user.Email.String == req.Email { + if user.Email.String() == req.Email { ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("this email same as current email")) return } diff --git a/server/handlers/websocket.go b/server/handlers/websocket.go index 93d943e..c2ae3fe 100644 --- a/server/handlers/websocket.go +++ b/server/handlers/websocket.go @@ -238,8 +238,8 @@ func handleElementMsg(cli *op.Client, msg *pb.ElementMessage) error { }) case pb.ElementMessageType_CHECK_EXPIRED: current := cli.Room().Current() - if msg.ExpireId != 0 && current.MovieID != "" { - currentMovie, err := cli.Room().GetMovieByID(current.MovieID) + if msg.ExpireId != 0 && current.Movie.ID != "" { + currentMovie, err := cli.Room().GetMovieByID(current.Movie.ID) if err != nil { return cli.Send(&pb.ElementMessage{ Type: pb.ElementMessageType_ERROR, diff --git a/server/model/movie.go b/server/model/movie.go index 96e44b0..7b8e710 100644 --- a/server/model/movie.go +++ b/server/model/movie.go @@ -2,6 +2,8 @@ package model import ( "errors" + "fmt" + "strings" "github.com/gin-gonic/gin" json "github.com/json-iterator/go" @@ -20,7 +22,7 @@ var ( ErrEmptyIds = errors.New("empty ids") ) -type PushMovieReq model.BaseMovie +type PushMovieReq model.MovieBase func (p *PushMovieReq) Decode(ctx *gin.Context) error { return json.NewDecoder(ctx.Request.Body).Decode(p) @@ -90,6 +92,15 @@ func (i *IdCanEmptyReq) Validate() error { return nil } +type SetRoomCurrentMovieReq struct { + IdCanEmptyReq + SubPath string `json:"subPath"` +} + +func (s *SetRoomCurrentMovieReq) Decode(ctx *gin.Context) error { + return json.NewDecoder(ctx.Request.Body).Decode(s) +} + type EditMovieReq struct { IdReq PushMovieReq @@ -145,16 +156,72 @@ func (s *SwapMovieReq) Validate() error { return nil } -type MovieResp struct { +func GenDefaultSubPaths(path string, skipEmpty bool, paths ...*MoviePath) []*MoviePath { + if len(paths) == 0 { + return nil + } + id := paths[len(paths)-1].ID + path = strings.TrimRight(path, "/") + for _, v := range strings.Split(path, `/`) { + if v == "" && skipEmpty { + continue + } + if l := len(paths); l != 0 { + paths = append(paths, &MoviePath{ + Name: v, + ID: id, + SubPath: fmt.Sprintf("%s/%s", strings.TrimRight(paths[l-1].SubPath, "/"), v), + }) + } else { + paths = append(paths, &MoviePath{ + Name: v, + ID: id, + SubPath: v, + }) + } + } + return paths +} + +type MoviePath struct { + Name string `json:"name"` + ID string `json:"id"` + SubPath string `json:"subPath"` +} + +type MoviesResp struct { + Paths []*MoviePath `json:"paths"` + Movies []*Movie `json:"movies"` + Total int64 `json:"total"` + Dynamic bool `json:"dynamic"` +} + +type Movie struct { Id string `json:"id"` CreatedAt int64 `json:"createAt"` - Base model.BaseMovie `json:"base"` + Base model.MovieBase `json:"base"` Creator string `json:"creator"` CreatorId string `json:"creatorId"` + SubPath string `json:"subPath"` } type CurrentMovieResp struct { - Status op.Status `json:"status"` - Movie *MovieResp `json:"movie"` - ExpireId uint64 `json:"expireId"` + Status op.Status `json:"status"` + Movie *Movie `json:"movie"` + ExpireId uint64 `json:"expireId"` +} + +type ClearMoviesReq struct { + ParentId string `json:"parentId"` +} + +func (c *ClearMoviesReq) Decode(ctx *gin.Context) error { + return json.NewDecoder(ctx.Request.Body).Decode(c) +} + +func (c *ClearMoviesReq) Validate() error { + if c.ParentId != "" && len(c.ParentId) != 32 { + return fmt.Errorf("parent id length must be empty or 32") + } + return nil } diff --git a/server/model/vendor.go b/server/model/vendor.go index 142703c..6763323 100644 --- a/server/model/vendor.go +++ b/server/model/vendor.go @@ -23,7 +23,7 @@ type VendorFSListResp[T any] struct { func GenDefaultPaths(path string, skipEmpty bool, paths ...*Path) []*Path { path = strings.TrimRight(path, "/") for _, v := range strings.Split(path, `/`) { - if skipEmpty && v == "" { + if v == "" && skipEmpty { continue } if l := len(paths); l != 0 {